diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..03584b3
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,20 @@
+# General Info
+
+- [Pivotal Tracker Story](https://www.pivotaltracker.com/story/show/########)
+- [ ] Breaking?
+
+# Changes
+
+Explain your changes here (in such a way that you would understand why you made them a year from now).
+
+# Testing
+
+Explain how you tested your changes. If testing is not necessary, explain why.
+
+# Documentation
+
+Does this PR require documentation. If so, explain where it can be found.
+
+# Checklist
+
+- [ ] Name of branch corresponds to story
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
new file mode 100644
index 0000000..b9601a7
--- /dev/null
+++ b/.github/workflows/main.yml
@@ -0,0 +1,47 @@
+name: build
+
+on: [push, pull_request]
+
+jobs:
+ build:
+ services:
+ postgres:
+ image: postgres:16
+ env:
+ POSTGRES_PASSWORD: password
+ ports:
+ - 5432:5432
+ runs-on: ubuntu-latest
+ env:
+ DB_USER: postgres
+ DB_PASSWORD: password
+ RAILS_ENV: test
+ RUBY_ENV: test
+ RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
+ CANVAS_URL: http://bcourses.test.instructure.com/
+ steps:
+ - uses: actions/checkout@v1
+ - name: Install Ruby
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: 3.3.0
+ - name: Build and test with RSpec
+ run: |
+ gem install bundler
+ bundle install --jobs 4 --retry 3
+ rails db:create
+ rails db:migrate
+ bundle exec rspec
+ - name: Download and install Code Climate test reporter
+ run: |
+ curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter
+ chmod +x ./cc-test-reporter
+ env:
+ CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }}
+ - name: Run before-build command
+ run: ./cc-test-reporter before-build
+ - name: Run after-build command
+ run: |
+ ./cc-test-reporter after-build --exit-code $?
+ env:
+ CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }}
\ No newline at end of file
diff --git a/.github/workflows/swagger_validate.yml b/.github/workflows/swagger_validate.yml
new file mode 100644
index 0000000..4055592
--- /dev/null
+++ b/.github/workflows/swagger_validate.yml
@@ -0,0 +1,25 @@
+name: Swagger
+
+on:
+ push:
+ branches:
+ - master
+ - main
+ pull_request: {}
+
+jobs:
+ swagger:
+ runs-on: ubuntu-latest
+ name: Validate Spec
+ services:
+ swagger-editor:
+ image: swaggerapi/swagger-editor:next-v5
+ ports:
+ - 80:8080
+ steps:
+ - uses: actions/checkout@v2
+ - name: Validate Swagger Spec
+ uses: char0n/swagger-editor-validate@v1
+ with:
+ definition-file: app/assets/swagger/swagger.json
+ ignore-error: app/assets/swagger/shouldIgnoreError.js
diff --git a/.gitignore b/.gitignore
index c55bb71..2910ee8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -67,3 +67,9 @@ yarn-debug.log*
/storage/*
!/storage/.keep
/public/uploads
+
+/config/master.key
+
+/config/credentials/production.key
+
+.DS_Store
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..d18898b
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,62 @@
+# syntax = docker/dockerfile:1
+
+# Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile
+ARG RUBY_VERSION=3.3.0
+FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim as base
+
+# Rails app lives here
+WORKDIR /rails
+
+# Set production environment
+ENV RAILS_ENV="production" \
+ BUNDLE_DEPLOYMENT="1" \
+ BUNDLE_PATH="/usr/local/bundle" \
+ BUNDLE_WITHOUT="development"
+
+
+# Throw-away build stage to reduce size of final image
+FROM base as build
+
+# Install packages needed to build gems
+RUN apt-get update -qq && \
+ apt-get install --no-install-recommends -y build-essential git libvips pkg-config
+
+# Install application gems
+COPY Gemfile Gemfile.lock ./
+RUN bundle install && \
+ rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \
+ bundle exec bootsnap precompile --gemfile
+
+# Copy application code
+COPY . .
+
+# Precompile bootsnap code for faster boot times
+RUN bundle exec bootsnap precompile app/ lib/
+
+# Precompiling assets for production without requiring secret RAILS_MASTER_KEY
+RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
+
+
+# Final stage for app image
+FROM base
+
+# Install packages needed for deployment
+RUN apt-get update -qq && \
+ apt-get install --no-install-recommends -y curl libsqlite3-0 libvips && \
+ rm -rf /var/lib/apt/lists /var/cache/apt/archives
+
+# Copy built artifacts: gems, application
+COPY --from=build /usr/local/bundle /usr/local/bundle
+COPY --from=build /rails /rails
+
+# Run and own only the runtime files as a non-root user for security
+RUN useradd rails --create-home --shell /bin/bash && \
+ chown -R rails:rails db log storage tmp
+USER rails:rails
+
+# Entrypoint prepares the database.
+ENTRYPOINT ["/rails/bin/docker-entrypoint"]
+
+# Start the server by default, this can be overwritten at runtime
+EXPOSE 3000
+CMD ["./bin/rails", "server"]
diff --git a/Gemfile b/Gemfile
new file mode 100644
index 0000000..63eca33
--- /dev/null
+++ b/Gemfile
@@ -0,0 +1,97 @@
+source "https://rubygems.org"
+
+ruby "3.3.0"
+
+# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
+gem "rails", "~> 7.1.3"
+
+# The original asset pipeline for Rails [https://github.com/rails/sprockets-rails]
+gem "sprockets-rails"
+
+# Use postgres for all env dbs
+gem "pg"
+
+# Use the Puma web server [https://github.com/puma/puma]
+gem "puma", ">= 5.0"
+
+# Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails]
+gem "importmap-rails"
+
+# Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev]
+gem "turbo-rails"
+
+# Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev]
+gem "stimulus-rails"
+
+# Build JSON APIs with ease [https://github.com/rails/jbuilder]
+gem "jbuilder"
+
+# Use Redis adapter to run Action Cable in production
+# gem "redis", ">= 4.0.1"
+
+# Use Kredis to get higher-level data types in Redis [https://github.com/rails/kredis]
+# gem "kredis"
+
+# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword]
+# gem "bcrypt", "~> 3.1.7"
+
+# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
+gem "tzinfo-data", platforms: %i[ windows jruby ]
+
+# Reduces boot times through caching; required in config/boot.rb
+gem "bootsnap", require: false
+
+gem "lms-api"
+
+
+
+# Use Active Storage for file uploads [https://guides.rubyonrails.org/active_storage_overview.html]
+# gem "activestorage", "~> 7.0.0"
+
+# Use Rack CORS for handling Cross-Origin Resource Sharing (CORS), making cross-origin AJAX possible [
+
+# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images]
+# gem "image_processing", "~> 1.2"
+
+gem 'bootstrap', '~> 5.3.2'
+gem 'jquery-rails'
+gem 'sassc-rails', '~> 2.1' #dependency for bootstrap
+gem 'json'
+
+# Used to make http requests.
+gem 'faraday'
+
+# Used to allow dot notation of hashes.
+gem 'ostruct'
+
+group :development, :test do
+ # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
+ gem "debug", platforms: %i[ mri windows ]
+ gem 'dotenv-rails'
+end
+
+group :test do
+ gem 'rspec-rails'
+ gem 'guard-rspec'
+ gem 'simplecov', '~> 0.17.1' , :require => false
+ gem 'codeclimate-test-reporter'
+ gem 'cucumber-rails', :require => false
+ gem 'cucumber-rails-training-wheels'
+ gem 'database_cleaner'
+ gem 'timecop'
+ gem 'webmock'
+end
+
+group :development do
+ # Use console on exceptions pages [https://github.com/rails/web-console]
+ gem "web-console"
+
+ #for debug
+ gem 'byebug'
+
+ # Add speed badges [https://github.com/MiniProfiler/rack-mini-profiler]
+ # gem "rack-mini-profiler"
+
+ # Speed up commands on slow machines / big apps [https://github.com/rails/spring]
+ # gem "spring"
+end
diff --git a/Gemfile.lock b/Gemfile.lock
new file mode 100644
index 0000000..7aa4620
--- /dev/null
+++ b/Gemfile.lock
@@ -0,0 +1,441 @@
+GEM
+ remote: https://rubygems.org/
+ specs:
+ actioncable (7.1.3)
+ actionpack (= 7.1.3)
+ activesupport (= 7.1.3)
+ nio4r (~> 2.0)
+ websocket-driver (>= 0.6.1)
+ zeitwerk (~> 2.6)
+ actionmailbox (7.1.3)
+ actionpack (= 7.1.3)
+ activejob (= 7.1.3)
+ activerecord (= 7.1.3)
+ activestorage (= 7.1.3)
+ activesupport (= 7.1.3)
+ mail (>= 2.7.1)
+ net-imap
+ net-pop
+ net-smtp
+ actionmailer (7.1.3)
+ actionpack (= 7.1.3)
+ actionview (= 7.1.3)
+ activejob (= 7.1.3)
+ activesupport (= 7.1.3)
+ mail (~> 2.5, >= 2.5.4)
+ net-imap
+ net-pop
+ net-smtp
+ rails-dom-testing (~> 2.2)
+ actionpack (7.1.3)
+ actionview (= 7.1.3)
+ activesupport (= 7.1.3)
+ nokogiri (>= 1.8.5)
+ racc
+ rack (>= 2.2.4)
+ rack-session (>= 1.0.1)
+ rack-test (>= 0.6.3)
+ rails-dom-testing (~> 2.2)
+ rails-html-sanitizer (~> 1.6)
+ actiontext (7.1.3)
+ actionpack (= 7.1.3)
+ activerecord (= 7.1.3)
+ activestorage (= 7.1.3)
+ activesupport (= 7.1.3)
+ globalid (>= 0.6.0)
+ nokogiri (>= 1.8.5)
+ actionview (7.1.3)
+ activesupport (= 7.1.3)
+ builder (~> 3.1)
+ erubi (~> 1.11)
+ rails-dom-testing (~> 2.2)
+ rails-html-sanitizer (~> 1.6)
+ activejob (7.1.3)
+ activesupport (= 7.1.3)
+ globalid (>= 0.3.6)
+ activemodel (7.1.3)
+ activesupport (= 7.1.3)
+ activerecord (7.1.3)
+ activemodel (= 7.1.3)
+ activesupport (= 7.1.3)
+ timeout (>= 0.4.0)
+ activestorage (7.1.3)
+ actionpack (= 7.1.3)
+ activejob (= 7.1.3)
+ activerecord (= 7.1.3)
+ activesupport (= 7.1.3)
+ marcel (~> 1.0)
+ activesupport (7.1.3)
+ 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.6)
+ public_suffix (>= 2.0.2, < 6.0)
+ autoprefixer-rails (10.4.16.0)
+ execjs (~> 2)
+ base64 (0.2.0)
+ bigdecimal (3.1.6)
+ bindex (0.8.1)
+ bootsnap (1.18.3)
+ msgpack (~> 1.2)
+ bootstrap (5.3.2)
+ autoprefixer-rails (>= 9.1.0)
+ popper_js (>= 2.11.8, < 3)
+ builder (3.2.4)
+ byebug (11.1.3)
+ 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)
+ codeclimate-test-reporter (1.0.7)
+ simplecov
+ coderay (1.1.3)
+ concurrent-ruby (1.2.3)
+ connection_pool (2.4.1)
+ crack (1.0.0)
+ bigdecimal
+ rexml
+ crass (1.0.6)
+ cucumber (9.1.2)
+ builder (~> 3.2, >= 3.2.4)
+ cucumber-ci-environment (~> 9.2, >= 9.2.0)
+ cucumber-core (~> 12.0)
+ cucumber-cucumber-expressions (~> 17.0)
+ cucumber-gherkin (> 24, < 27)
+ cucumber-html-formatter (> 20.3, < 22)
+ cucumber-messages (> 19, < 25)
+ diff-lcs (~> 1.5)
+ mini_mime (~> 1.1, >= 1.1.5)
+ multi_test (~> 1.1, >= 1.1.0)
+ sys-uname (~> 1.2, >= 1.2.3)
+ cucumber-ci-environment (9.2.0)
+ cucumber-core (12.0.0)
+ cucumber-gherkin (>= 25, < 27)
+ cucumber-messages (>= 20, < 23)
+ cucumber-tag-expressions (~> 5.0, >= 5.0.4)
+ cucumber-cucumber-expressions (17.0.1)
+ cucumber-gherkin (26.2.0)
+ cucumber-messages (>= 19.1.4, < 22.1)
+ cucumber-html-formatter (21.2.0)
+ cucumber-messages (> 19, < 25)
+ cucumber-messages (22.0.0)
+ cucumber-rails (3.0.0)
+ capybara (>= 3.11, < 4)
+ cucumber (>= 5, < 10)
+ railties (>= 5.2, < 8)
+ cucumber-rails-training-wheels (1.0.0)
+ cucumber-rails (>= 1.1.1)
+ cucumber-tag-expressions (5.0.6)
+ database_cleaner (2.0.2)
+ database_cleaner-active_record (>= 2, < 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.1)
+ irb (~> 1.10)
+ reline (>= 0.3.8)
+ diff-lcs (1.5.1)
+ docile (1.4.0)
+ dotenv (3.0.3)
+ dotenv-rails (3.0.3)
+ dotenv (= 3.0.3)
+ railties (>= 6.1)
+ drb (2.2.0)
+ ruby2_keywords
+ erubi (1.12.0)
+ execjs (2.9.1)
+ faraday (2.9.0)
+ faraday-net_http (>= 2.0, < 3.2)
+ faraday-net_http (3.1.0)
+ net-http
+ ffi (1.16.3)
+ formatador (1.1.0)
+ globalid (1.2.1)
+ activesupport (>= 6.1)
+ guard (2.18.1)
+ formatador (>= 0.2.4)
+ listen (>= 2.7, < 4.0)
+ lumberjack (>= 1.0.12, < 2.0)
+ nenv (~> 0.1)
+ notiffany (~> 0.0)
+ pry (>= 0.13.0)
+ shellany (~> 0.0)
+ thor (>= 0.18.1)
+ guard-compat (1.2.1)
+ guard-rspec (4.7.3)
+ guard (~> 2.1)
+ guard-compat (~> 1.1)
+ rspec (>= 2.99.0, < 4.0)
+ hashdiff (1.1.0)
+ httparty (0.21.0)
+ mini_mime (>= 1.0.0)
+ multi_xml (>= 0.5.2)
+ i18n (1.14.1)
+ concurrent-ruby (~> 1.0)
+ importmap-rails (2.0.1)
+ actionpack (>= 6.0.0)
+ activesupport (>= 6.0.0)
+ railties (>= 6.0.0)
+ io-console (0.7.2)
+ irb (1.11.2)
+ rdoc
+ reline (>= 0.4.2)
+ jbuilder (2.11.5)
+ actionview (>= 5.0.0)
+ activesupport (>= 5.0.0)
+ jquery-rails (4.6.0)
+ rails-dom-testing (>= 1, < 3)
+ railties (>= 4.2.0)
+ thor (>= 0.14, < 2.0)
+ json (2.7.1)
+ listen (3.8.0)
+ rb-fsevent (~> 0.10, >= 0.10.3)
+ rb-inotify (~> 0.9, >= 0.9.10)
+ lms-api (1.24.0)
+ activesupport (>= 3.0)
+ httparty
+ loofah (2.22.0)
+ crass (~> 1.0.2)
+ nokogiri (>= 1.12.0)
+ lumberjack (1.2.10)
+ mail (2.8.1)
+ mini_mime (>= 0.1.1)
+ net-imap
+ net-pop
+ net-smtp
+ marcel (1.0.2)
+ matrix (0.4.2)
+ method_source (1.0.0)
+ mini_mime (1.1.5)
+ minitest (5.22.2)
+ msgpack (1.7.2)
+ multi_test (1.1.0)
+ multi_xml (0.6.0)
+ mutex_m (0.2.0)
+ nenv (0.3.0)
+ net-http (0.4.1)
+ uri
+ net-imap (0.4.10)
+ date
+ net-protocol
+ net-pop (0.1.2)
+ net-protocol
+ net-protocol (0.2.2)
+ timeout
+ net-smtp (0.4.0.1)
+ net-protocol
+ nio4r (2.7.0)
+ nokogiri (1.16.2-aarch64-linux)
+ racc (~> 1.4)
+ nokogiri (1.16.2-arm-linux)
+ racc (~> 1.4)
+ nokogiri (1.16.2-arm64-darwin)
+ racc (~> 1.4)
+ nokogiri (1.16.2-x86-linux)
+ racc (~> 1.4)
+ nokogiri (1.16.2-x86-mingw32)
+ racc (~> 1.4)
+ nokogiri (1.16.2-x86_64-darwin)
+ racc (~> 1.4)
+ nokogiri (1.16.2-x86_64-linux)
+ racc (~> 1.4)
+ notiffany (0.1.3)
+ nenv (~> 0.1)
+ shellany (~> 0.0)
+ ostruct (0.6.0)
+ pg (1.5.5)
+ pg (1.5.5-x86-mingw32)
+ popper_js (2.11.8)
+ pry (0.14.2)
+ coderay (~> 1.1)
+ method_source (~> 1.0)
+ psych (5.1.2)
+ stringio
+ public_suffix (5.0.4)
+ puma (6.4.2)
+ nio4r (~> 2.0)
+ racc (1.7.3)
+ rack (3.0.9)
+ rack-session (2.0.0)
+ rack (>= 3.0.0)
+ rack-test (2.1.0)
+ rack (>= 1.3)
+ rackup (2.1.0)
+ rack (>= 3)
+ webrick (~> 1.8)
+ rails (7.1.3)
+ actioncable (= 7.1.3)
+ actionmailbox (= 7.1.3)
+ actionmailer (= 7.1.3)
+ actionpack (= 7.1.3)
+ actiontext (= 7.1.3)
+ actionview (= 7.1.3)
+ activejob (= 7.1.3)
+ activemodel (= 7.1.3)
+ activerecord (= 7.1.3)
+ activestorage (= 7.1.3)
+ activesupport (= 7.1.3)
+ bundler (>= 1.15.0)
+ railties (= 7.1.3)
+ rails-dom-testing (2.2.0)
+ activesupport (>= 5.0.0)
+ minitest
+ nokogiri (>= 1.6)
+ rails-html-sanitizer (1.6.0)
+ loofah (~> 2.21)
+ nokogiri (~> 1.14)
+ railties (7.1.3)
+ actionpack (= 7.1.3)
+ activesupport (= 7.1.3)
+ irb
+ rackup (>= 1.0.0)
+ rake (>= 12.2)
+ thor (~> 1.0, >= 1.2.2)
+ zeitwerk (~> 2.6)
+ rake (13.1.0)
+ rb-fsevent (0.11.2)
+ rb-inotify (0.10.1)
+ ffi (~> 1.0)
+ rdoc (6.6.2)
+ psych (>= 4.0.0)
+ regexp_parser (2.9.0)
+ reline (0.4.2)
+ io-console (~> 0.5)
+ rexml (3.2.6)
+ rspec (3.13.0)
+ rspec-core (~> 3.13.0)
+ rspec-expectations (~> 3.13.0)
+ rspec-mocks (~> 3.13.0)
+ rspec-core (3.13.0)
+ rspec-support (~> 3.13.0)
+ rspec-expectations (3.13.0)
+ diff-lcs (>= 1.2.0, < 2.0)
+ rspec-support (~> 3.13.0)
+ rspec-mocks (3.13.0)
+ diff-lcs (>= 1.2.0, < 2.0)
+ rspec-support (~> 3.13.0)
+ rspec-rails (6.1.1)
+ actionpack (>= 6.1)
+ activesupport (>= 6.1)
+ railties (>= 6.1)
+ rspec-core (~> 3.12)
+ rspec-expectations (~> 3.12)
+ rspec-mocks (~> 3.12)
+ rspec-support (~> 3.12)
+ rspec-support (3.13.0)
+ ruby2_keywords (0.0.5)
+ 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
+ shellany (0.0.1)
+ simplecov (0.17.1)
+ docile (~> 1.1)
+ json (>= 1.8, < 3)
+ simplecov-html (~> 0.10.0)
+ simplecov-html (0.10.2)
+ sprockets (4.2.1)
+ concurrent-ruby (~> 1.0)
+ rack (>= 2.2.4, < 4)
+ sprockets-rails (3.4.2)
+ actionpack (>= 5.2)
+ activesupport (>= 5.2)
+ sprockets (>= 3.0.0)
+ stimulus-rails (1.3.3)
+ railties (>= 6.0.0)
+ stringio (3.1.0)
+ sys-uname (1.2.3)
+ ffi (~> 1.1)
+ thor (1.3.0)
+ tilt (2.3.0)
+ timecop (0.9.8)
+ timeout (0.4.1)
+ turbo-rails (2.0.0)
+ actionpack (>= 6.0.0)
+ activejob (>= 6.0.0)
+ railties (>= 6.0.0)
+ tzinfo (2.0.6)
+ concurrent-ruby (~> 1.0)
+ tzinfo-data (1.2024.1)
+ tzinfo (>= 1.0.0)
+ uri (0.13.0)
+ web-console (4.2.1)
+ actionview (>= 6.0.0)
+ activemodel (>= 6.0.0)
+ bindex (>= 0.4.0)
+ railties (>= 6.0.0)
+ webmock (3.23.0)
+ addressable (>= 2.8.0)
+ crack (>= 0.3.2)
+ hashdiff (>= 0.4.0, < 2.0.0)
+ webrick (1.8.1)
+ websocket-driver (0.7.6)
+ websocket-extensions (>= 0.1.0)
+ websocket-extensions (0.1.5)
+ xpath (3.2.0)
+ nokogiri (~> 1.8)
+ zeitwerk (2.6.13)
+
+PLATFORMS
+ aarch64-linux
+ arm-linux
+ arm64-darwin
+ x86-linux
+ x86-mingw32
+ x86_64-darwin
+ x86_64-linux
+
+DEPENDENCIES
+ bootsnap
+ bootstrap (~> 5.3.2)
+ byebug
+ codeclimate-test-reporter
+ cucumber-rails
+ cucumber-rails-training-wheels
+ database_cleaner
+ debug
+ dotenv-rails
+ faraday
+ guard-rspec
+ importmap-rails
+ jbuilder
+ jquery-rails
+ json
+ lms-api
+ ostruct
+ pg
+ puma (>= 5.0)
+ rails (~> 7.1.3)
+ rspec-rails
+ sassc-rails (~> 2.1)
+ simplecov (~> 0.17.1)
+ sprockets-rails
+ stimulus-rails
+ timecop
+ turbo-rails
+ tzinfo-data
+ web-console
+ webmock
+
+RUBY VERSION
+ ruby 3.3.0p0
+
+BUNDLED WITH
+ 2.5.6
diff --git a/Guardfile b/Guardfile
new file mode 100644
index 0000000..23074cd
--- /dev/null
+++ b/Guardfile
@@ -0,0 +1,70 @@
+# A sample Guardfile
+# More info at https://github.com/guard/guard#readme
+
+## Uncomment and set this to only include directories you want to watch
+# directories %w(app lib config test spec features) \
+# .select{|d| Dir.exist?(d) ? d : UI.warning("Directory #{d} does not exist")}
+
+## Note: if you are using the `directories` clause above and you are not
+## watching the project directory ('.'), then you will want to move
+## the Guardfile to a watched dir and symlink it back, e.g.
+#
+# $ mkdir config
+# $ mv Guardfile config/
+# $ ln -s config/Guardfile .
+#
+# and, you'll have to watch "config/Guardfile" instead of "Guardfile"
+
+# Note: The cmd option is now required due to the increasing number of ways
+# rspec may be run, below are examples of the most common uses.
+# * bundler: 'bundle exec rspec'
+# * bundler binstubs: 'bin/rspec'
+# * spring: 'bin/rspec' (This will use spring if running and you have
+# installed the spring binstubs per the docs)
+# * zeus: 'zeus rspec' (requires the server to be started separately)
+# * 'just' rspec: 'rspec'
+
+guard :rspec, cmd: "bundle exec rspec" do
+ require "guard/rspec/dsl"
+ dsl = Guard::RSpec::Dsl.new(self)
+
+ # Feel free to open issues for suggestions and improvements
+
+ # RSpec files
+ rspec = dsl.rspec
+ watch(rspec.spec_helper) { rspec.spec_dir }
+ watch(rspec.spec_support) { rspec.spec_dir }
+ watch(rspec.spec_files)
+
+ # Ruby files
+ ruby = dsl.ruby
+ dsl.watch_spec_files_for(ruby.lib_files)
+
+ # Rails files
+ rails = dsl.rails(view_extensions: %w(erb haml slim))
+ dsl.watch_spec_files_for(rails.app_files)
+ dsl.watch_spec_files_for(rails.views)
+
+ watch(rails.controllers) do |m|
+ [
+ rspec.spec.call("routing/#{m[1]}_routing"),
+ rspec.spec.call("controllers/#{m[1]}_controller"),
+ rspec.spec.call("acceptance/#{m[1]}")
+ ]
+ end
+
+ # Rails config changes
+ watch(rails.spec_helper) { rspec.spec_dir }
+ watch(rails.routes) { "#{rspec.spec_dir}/routing" }
+ watch(rails.app_controller) { "#{rspec.spec_dir}/controllers" }
+
+ # Capybara features specs
+ watch(rails.view_dirs) { |m| rspec.spec.call("features/#{m[1]}") }
+ watch(rails.layouts) { |m| rspec.spec.call("features/#{m[1]}") }
+
+ # Turnip features and steps
+ watch(%r{^spec/acceptance/(.+)\.feature$})
+ watch(%r{^spec/acceptance/steps/(.+)_steps\.rb$}) do |m|
+ Dir[File.join("**/#{m[1]}.feature")][0] || "spec/acceptance"
+ end
+end
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..b98dcea
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,30 @@
+# These targets were made with unix in mind. If you are not on unix, feel free to update for support.
+-include .env
+.DEFAULT_GOAL = dev
+
+env:
+ @export DB_PORT=${DB_PORT}
+ @export DB_USER=${DB_USER}
+ @export DB_PASSWORD=${DB_PASSWORD}
+ @export DB_NAME=${DB_NAME}
+ @echo 'db environment updated'
+
+dev: env
+ @command -v overmind > /dev/null 2>&1 || { echo >&2 "please install overmind first"; exit 1; }
+ @yarn run dev
+
+test: env
+ @echo 'Running rspec tests'
+ @bundle exec rspec
+
+db-migrate: env
+ bin/rails db:migrate
+
+db-seed: env
+ bin/rails db:seed
+
+init: env
+ @command -v yarn > /dev/null 2>&1 || { echo >&2 "please install yarn first"; exit 1; }
+ bin/rails db:setup
+ bin/rails db:migrate
+ bin/rails db:seed
diff --git a/Procfile.dev b/Procfile.dev
new file mode 100644
index 0000000..f6a9573
--- /dev/null
+++ b/Procfile.dev
@@ -0,0 +1 @@
+web: bundle exec rails s -p 3000
diff --git a/README.md b/README.md
index a5c055e..ac9c743 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,53 @@
-# flextensions
+# Flextensions
+
Back end/API for UC Berkeley EECS "Flextensions" software
+
+[](https://codeclimate.com/github/cs169/flextensions/maintainability) [](https://codeclimate.com/github/cs169/flextensions/test_coverage)
+
+## Installation
+
+### Environment Variables
+
+For the environment variables, you will need to configure on your local system (and any deployment machines) the following environment variables (recommended through a `.env` file at root):
+
+- DB_PORT (default: 5432)
+- DB_USER (default: postgres)
+- DB_PASSWORD (default: password)
+- DB_NAME (default: postgres)
+- CANVAS_URL (no default; will error if not included)
+
+Changing only the user and password then running `$make env` should be sufficient.
+
+### Postgres Installation
+
+#### MacOS
+
+- `brew install postgresql chromedriver`
+- Start postgres if necessary. `brew services start postgresql`
+
+#### Linux/WSL
+
+- `sudo apt install postgresql`
+- Create a postgres user.
+ - `sudo su - postgres` (to get into postgres shell)
+ - `createuser --interactive --pwprompt` (in postgres shell)
+ - Save `DB_USER` and `DB_PASSWORD` fields in the `.env` file.
+- Start postgres if necessary. `pg_ctlcluster 12 main start`
+ - Note: if you are using WSL2 on windows, the command to start postgres is `sudo service postgresql start`
+
+### Stand Up Server
+
+In order to stand up the server you must first install [Overmind](https://github.com/DarthSim/overmind).
+ Development has been tested with overmind 2.4.0
+
+With Overmind, you can run `$make dev` or `$make`
+
+## Notes
+There are now two separate instances of Canvas, each with it's own triad of prod/test/beta environments:
+1. [bcourses.berkeley.edu](bcourses.berkeley.edu)
+2. [ucberkeleysandbox.instructure.com](ucberkeleysandbox.instructure.com)
+
+We recommend developing in this order:
+1. [ucberkeleysandbox.instructure.com](ucberkeleysandbox.instructure.com) (no risk) - this is the one for which this repo currently has oauth2 keys (secrets)
+2. [bcourses.test.instructure.com](bcourses.test.instructure.com) (no risk of impacting courses, but contains real data)
+3. [bcourses.berkeley.edu](bcourses.berkeley.edu)
diff --git a/Rakefile b/Rakefile
new file mode 100644
index 0000000..9a5ea73
--- /dev/null
+++ b/Rakefile
@@ -0,0 +1,6 @@
+# Add your own tasks in files placed in lib/tasks ending in .rake,
+# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
+
+require_relative "config/application"
+
+Rails.application.load_tasks
diff --git a/app/Errors/failed_pipeline_error.rb b/app/Errors/failed_pipeline_error.rb
new file mode 100644
index 0000000..47a94fe
--- /dev/null
+++ b/app/Errors/failed_pipeline_error.rb
@@ -0,0 +1,17 @@
+##
+# Base error for a pipeline failure.
+class FailedPipelineError < StandardError
+ ##
+ # Constructor for error.
+ #
+ # @param [String] pipeline the pipeline that failed.
+ # @param [String] errorStage the stage in the pipeline the failure occured at.
+ # @param [String] additionalMessage any additional info for the error (defaults to "")
+ def initialize(pipeline, errorStage, additionalMessage="")
+ message = "An error occured with #{pipeline} at #{errorStage}"
+ if (additionalMessage.length >= 0)
+ message += ": #{additionalMessage}"
+ end
+ super(message)
+ end
+end
diff --git a/app/Errors/not_found_error.rb b/app/Errors/not_found_error.rb
new file mode 100644
index 0000000..252e481
--- /dev/null
+++ b/app/Errors/not_found_error.rb
@@ -0,0 +1,11 @@
+##
+# Base error for when values cannot be found.
+class NotFoundError < StandardError
+ ##
+ # Constructor for the error.
+ #
+ # @param [String] message the error message (defaults to "Not Found Error").
+ def initialize(message="Not Found Error")
+ super(message)
+ end
+end
diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js
new file mode 100644
index 0000000..ddd546a
--- /dev/null
+++ b/app/assets/config/manifest.js
@@ -0,0 +1,4 @@
+//= link_tree ../images
+//= link_directory ../stylesheets .css
+//= link_tree ../../javascript .js
+//= link_tree ../../../vendor/javascript .js
diff --git a/app/assets/images/.keep b/app/assets/images/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss
new file mode 100644
index 0000000..05c538d
--- /dev/null
+++ b/app/assets/stylesheets/application.scss
@@ -0,0 +1,17 @@
+/*
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
+ * listed below.
+ *
+ * Any CSS (and SCSS, if configured) file within this directory, lib/assets/stylesheets, or any plugin's
+ * vendor/assets/stylesheets directory can be referenced here using a relative path.
+ *
+ * You're free to add application-wide styles to this file and they'll appear at the bottom of the
+ * compiled file so the styles you add here take precedence over styles defined in any other CSS
+ * files in this directory. Styles in this file should be added after the last require_* statement.
+ * It is generally better to create a new file per style scope.
+ *
+ *= require_tree .
+ *= require_self
+ */
+
+ @import "bootstrap";
\ No newline at end of file
diff --git a/app/assets/swagger/shouldIgnoreError.js b/app/assets/swagger/shouldIgnoreError.js
new file mode 100644
index 0000000..4f5c9f9
--- /dev/null
+++ b/app/assets/swagger/shouldIgnoreError.js
@@ -0,0 +1,19 @@
+/**
+ * Chose which swagger errors to ignore.
+ * @param {Error} err the error being caught.
+ * @returns {boolean} whether or not to ignore the error.
+ */
+const shouldIgnoreError = (err) => {
+ // This error is because the validator is still based on swagger-editor instead of swagger-editor-next.
+ /**
+ * The space after the word 'error' is not a real space, but it is converted to one when being logged
+ * out (super fun to diagnose). It is, however, considered a whitespace character of some sort,
+ * so it can be caught by '\s'. Don't ask how long it took to diagnose this.
+ */
+ if (/Structural\ error\sat openapi/.test(err.location)) {
+ return true;
+ }
+ return false;
+};
+
+module.exports = shouldIgnoreError;
diff --git a/app/assets/swagger/swagger.json b/app/assets/swagger/swagger.json
new file mode 100644
index 0000000..42d058a
--- /dev/null
+++ b/app/assets/swagger/swagger.json
@@ -0,0 +1,581 @@
+{
+ "openapi": "3.1.0",
+ "info": {
+ "title": "Flextensions API v1",
+ "description": "Official Flextensions API Documentation.",
+ "version": "0.0.0",
+ "contact": {
+ "name": "Armando Fox",
+ "email": "fox@berkeley.edu"
+ },
+ "license": {
+ "name": "BSD-2",
+ "url": "https://github.com/saasbook/flextensions/blob/main/LICENSE"
+ }
+ },
+ "servers": [
+ {
+ "url": "https://sp24-04-flextensions-6b110b3023ee.herokuapp.com/api/v1",
+ "description": "prod"
+ },
+ {
+ "url": "http://localhost:3000/api/v1",
+ "description": "dev"
+ }
+ ],
+ "paths": {
+ "/ping": {
+ "get": {
+ "summary": "Pings the server",
+ "tags": [
+ "config"
+ ],
+ "responses": {
+ "200": {
+ "description": "Pong",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "string",
+ "example": "pong"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/swagger": {
+ "get": {
+ "summary": "Swagger OpenAPI Spec",
+ "tags": [
+ "config"
+ ],
+ "responses": {
+ "200": {
+ "description": "Valid OpenAPI JSON Spec",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/users": {
+ "post": {
+ "summary": "Create a new user",
+ "tags": [
+ "users"
+ ],
+ "responses": {
+ "501": {
+ "description": "This endpoint has not yet been implemented yet",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/courses/{course_id}/users": {
+ "parameters": [
+ {
+ "in": "path",
+ "name": "course_id",
+ "description": "id of course",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
+ "get": {
+ "summary": "List of users under the specified course",
+ "tags": [
+ "courses", "users"
+ ],
+ "responses": {
+ "501": {
+ "description": "The enpoint to list all users under the specified has not yet been implemented yet",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/courses/{course_id}/add_user/{user_id}": {
+ "parameters": [
+ {
+ "in": "path",
+ "name": "course_id",
+ "description": "id of course",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "in": "path",
+ "name": "user_id",
+ "description": "id of user",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
+ "post": {
+ "summary": "Add an existing user to the specified course",
+ "tags": [
+ "courses", "users"
+ ],
+ "responses": {
+ "501": {
+ "description": "The endpoint to add an existing user to the specified course has not yet been implemented yet",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/courses/{course_id}/users/{user_id}": {
+ "parameters": [
+ {
+ "in": "path",
+ "name": "course_id",
+ "description": "id of course",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "in": "path",
+ "name": "user_id",
+ "description": "id of user",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
+ "delete": {
+ "summary": "Delete the specified user from the specified course",
+ "tags": [
+ "courses", "users"
+ ],
+ "responses": {
+ "501": {
+ "description": "The endpoint to delete the specified user from the specified course has not yet been implemented yet",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/courses": {
+ "get": {
+ "summary": "List of courses for the current user",
+ "tags": [
+ "courses"
+ ],
+ "responses": {
+ "501": {
+ "description": "This endpoint has not yet been implemented yet",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object"
+ }
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "summary": "Create a new course",
+ "tags": [
+ "courses"
+ ],
+ "responses": {
+ "501": {
+ "description": "This endpoint has not yet been implemented yet",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/courses/{course_id}": {
+ "parameters": [
+ {
+ "in": "path",
+ "name": "course_id",
+ "description": "id of course",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
+ "delete": {
+ "summary": "Delete a course",
+ "tags": [
+ "courses"
+ ],
+ "responses": {
+ "501": {
+ "description": "This endpoint has not yet been implemented yet",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/courses/{course_id}/lms": {
+ "parameters": [
+ {
+ "in": "path",
+ "name": "course_id",
+ "description": "id of course",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
+ "get": {
+ "summary": "Index of all lms providers for the specified class",
+ "tags": [
+ "lms"
+ ],
+ "responses": {
+ "501": {
+ "description": "This endpoint has not yet been implemented yet",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/courses/{course_id}/lmss/{lms_id}": {
+ "parameters": [
+ {
+ "in": "path",
+ "name": "course_id",
+ "description": "id of course",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "in": "path",
+ "name": "lms_id",
+ "description": "id of lms",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "post": {
+ "summary": "Adds the specified LMS to the course",
+ "tags": [
+ "lms"
+ ],
+ "responses": {
+ "501": {
+ "description": "This endpoint has not yet been implemented yet",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object"
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "summary": "Removes the specified LMS to the course",
+ "tags": [
+ "lms"
+ ],
+ "responses": {
+ "501": {
+ "description": "This endpoint has not yet been implemented yet",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/courses/{course_id}/lmss/{lms_id}/assignments/{assignment_id}/extensions": {
+ "parameters": [
+ {
+ "in": "path",
+ "name": "course_id",
+ "description": "id of course",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "in": "path",
+ "name": "lms_id",
+ "description": "id of lms",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "path",
+ "name": "assignment_id",
+ "description": "id of assignment_id",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "in": "header",
+ "name": "Authorization",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
+ "post": {
+ "summary": "Creates an extension for the specified assignment",
+ "tags": [
+ "extension"
+ ],
+ "requestBody": {
+ "description": "Required info for the extension: Student ID and new due date",
+ "content": {
+ "application/json": {
+ "schema": {
+ },
+ "example": {
+ "SID": "1234567890",
+ "New Due Date": "01/01/2000"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "501": {
+ "description": "This endpoint has not yet been implemented yet",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object"
+ }
+ }
+ }
+ }
+ }
+ },
+ "get": {
+ "summary": "Gets all extensions for the specified assignment",
+ "tags": [
+ "extension"
+ ],
+ "responses": {
+ "501": {
+ "description": "This endpoint has not yet been implemented yet",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/courses/{course_id}/lmss/{lms_id}/assignments": {
+ "get": {
+ "summary": "Get all assignments from one (course, lms)",
+ "tags": [
+ "assignments"
+ ],
+ "parameters": [
+ {
+ "name": "course_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "lms_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "501": {
+ "description": "This endpoint has not yet been implemented yet",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object"
+ }
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "summary": "Create a new assignment from one (course, lms)",
+ "tags": [
+ "assignments"
+ ],
+ "parameters": [
+ {
+ "name": "course_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "lms_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "assignment_uid": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "501": {
+ "description": "This endpoint has not yet been implemented yet",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/courses/{course_id}/lmss/{lms_id}/assignments/{assignment_id}": {
+ "delete": {
+ "summary": "Delete an assignment from one (course, lms)",
+ "tags": [
+ "assignments"
+ ],
+ "parameters": [
+ {
+ "name": "course_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "lms_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "assignment_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "501": {
+ "description": "This endpoint has not yet been implemented yet",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/channels/application_cable/channel.rb b/app/channels/application_cable/channel.rb
new file mode 100644
index 0000000..d672697
--- /dev/null
+++ b/app/channels/application_cable/channel.rb
@@ -0,0 +1,4 @@
+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
new file mode 100644
index 0000000..0ff5442
--- /dev/null
+++ b/app/channels/application_cable/connection.rb
@@ -0,0 +1,4 @@
+module ApplicationCable
+ class Connection < ActionCable::Connection::Base
+ end
+end
diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb
new file mode 100644
index 0000000..a6c3d3d
--- /dev/null
+++ b/app/controllers/api/base_controller.rb
@@ -0,0 +1,11 @@
+module Api
+ class BaseController < ActionController::API
+ before_action :accessControlAllowOrigin
+
+ private
+
+ def accessControlAllowOrigin
+ response.set_header('Access-Control-Allow-Origin', '*')
+ end
+ end
+end
diff --git a/app/controllers/api/v1/assignments_controller.rb b/app/controllers/api/v1/assignments_controller.rb
new file mode 100644
index 0000000..ef6881f
--- /dev/null
+++ b/app/controllers/api/v1/assignments_controller.rb
@@ -0,0 +1,62 @@
+module Api
+ module V1
+ class AssignmentsController < ApplicationController
+ include CanvasValidationHelper
+
+ before_action :validate_ids!, only: [:create]
+ skip_before_action :verify_authenticity_token
+
+ def index
+ render json: { message: 'not yet implemented'} , status: 501
+ end
+
+ # POST /courses/:course_id/lms/:lms_id/assignments
+ def create
+ # Check if the course_to_lms association exists
+ course_to_lms = CourseToLms.find_by(course_id: params[:course_id], lms_id: params[:lms_id])
+ unless course_to_lms
+ render json: { error: 'No such Course_LMS association' }, status: :not_found
+ return
+ end
+
+ # Check if the assignment already exists
+ if Assignment.exists?(course_to_lms_id: course_to_lms.id, name: params[:name], external_assignment_id: params[:external_assignment_id])
+ render json: { message: 'Record already exists' }, status: :ok
+ return
+ end
+ # Create and render the assignment
+ assignment = Assignment.new(course_to_lms_id: course_to_lms.id, name: params[:name], external_assignment_id: params[:external_assignment_id])
+ if assignment.save
+ render json: assignment, status: :created
+ else
+ render json: assignment.errors, status: :unprocessable_entity
+ end
+ end
+
+ def destroy
+ render json: { message: 'not yet implemented'} , status: 501
+ end
+
+ private
+
+
+
+ def validate_ids!
+ begin
+ params.require([:course_id, :lms_id, :name, :external_assignment_id])
+ rescue ActionController::ParameterMissing => e
+ render json: { error: e.message }, status: :bad_request
+ return
+ end
+
+
+ # Validate that course_id and lms_id are integers
+ unless is_valid_course_id(params[:course_id].to_i) && is_valid_lms_id(params[:lms_id].to_i)
+ render json: { error: 'course_id and lms_id must be integers' }, status: :bad_request
+ return
+ end
+ end
+
+ end
+ end
+end
diff --git a/app/controllers/api/v1/base_controller.rb b/app/controllers/api/v1/base_controller.rb
new file mode 100644
index 0000000..c187f9f
--- /dev/null
+++ b/app/controllers/api/v1/base_controller.rb
@@ -0,0 +1,6 @@
+module Api
+ module V1
+ class BaseController < Api::BaseController
+ end
+ end
+end
diff --git a/app/controllers/api/v1/courses_controller.rb b/app/controllers/api/v1/courses_controller.rb
new file mode 100644
index 0000000..2abe867
--- /dev/null
+++ b/app/controllers/api/v1/courses_controller.rb
@@ -0,0 +1,78 @@
+module Api
+ module V1
+ class CoursesController < BaseController
+ include ActionController::Flash
+
+ def create
+ course_name = params[:course_name]
+ existing_course = Course.find_by(course_name: course_name)
+ if existing_course
+ render json: { message: 'A course with the same course name already exists.'}, status: :unprocessable_entity
+ return
+ end
+
+ new_course = Course.create(course_name: course_name)
+ new_course.save
+ render_response(new_course,
+ "Course created successfully",
+ "Failed to save the new course to the database"
+ )
+
+ end
+
+ def index
+ render :json => 'The index method of CoursesController is not yet implemented'.to_json, status: 501
+ end
+
+ def destroy
+ render :json => 'The destroy method of CoursesController is not yet implemented'.to_json, status: 501
+ end
+
+ def add_user
+ user_id = params[:user_id]
+ course_id = params[:course_id]
+ role = params[:role]
+
+ # Check if the provided course_id is valid i.e. exists in courses table
+ if !Course.find_by(id: course_id)
+ render json: { error: "The course does not exist." }, status: :not_found
+ return
+ end
+
+ # Check if the provided user is valid i.e. exists in users table
+ if !User.find_by(id: user_id)
+ render json: { error: "The user does not exist." }, status: :not_found
+ return
+ end
+
+ # Check if the user has been already added to the course
+ existing_user_to_course = UserToCourse.find_by(course_id: course_id, user_id: user_id)
+ if existing_user_to_course
+ render json: { error: "The user is already added to the course."}, status: :unprocessable_entity
+ return
+ end
+
+ # Add the user to the course with the desired role
+ new_user_to_course = UserToCourse.new(course_id: course_id, user_id: user_id, role: role)
+ new_user_to_course.save
+ render_response(new_user_to_course,
+ "User added to the course successfully.",
+ "Failed to add the user the to course."
+ )
+
+ end
+
+ private
+ def render_response(object, success_message, error_message)
+ if object.save
+ flash[:success] = success_message
+ render json: object, status: :created
+ else
+ flash[:error] = error_message
+ render json: { error: object.errors.full_messages }, status: :unprocessable_entity
+ end
+ end
+
+ end
+ end
+end
diff --git a/app/controllers/api/v1/extensions_controller.rb b/app/controllers/api/v1/extensions_controller.rb
new file mode 100644
index 0000000..b9f1b74
--- /dev/null
+++ b/app/controllers/api/v1/extensions_controller.rb
@@ -0,0 +1,71 @@
+module Api
+ module V1
+ class ExtensionsController < BaseController
+ before_action :set_facade
+
+ def index
+ render json: { message: 'not yet implemented'}, status: 501
+ end
+
+ def create
+ find_extension_params
+ # Get External Assignment object to find initial due date
+ assignment_response = @canvas_facade.get_assignment(
+ @course_to_lms.external_course_id.to_i,
+ @assignment.external_assignment_id.to_i,
+ )
+ if (assignment_response.status != 200)
+ render json: assignment_response.to_json, status: 500
+ return
+ end
+ assignment_json = JSON.parse(assignment_response.body)
+
+ # Provision Extension
+ response = @canvas_facade.provision_extension(
+ @course_to_lms.external_course_id.to_i,
+ params[:student_uid].to_i,
+ @assignment.external_assignment_id.to_i,
+ params[:new_due_date],
+ )
+ if !response.success?
+ render json: response.body, status: response.status
+ return
+ end
+ assignment_override = JSON.parse(response.body)
+
+ @extension = Extension.new(
+ assignment_id: @assignment.id,
+ student_email: nil,
+ initial_due_date: assignment_json["due_at"],
+ new_due_date: assignment_override["due_at"],
+ external_extension_id: assignment_override["id"],
+ last_processed_by_id: nil,
+ )
+ if !@extension.save
+ render json: {"error": "Extension requested, but local save failed"}.to_json, status: 500
+ return
+ end
+ render json: @extension.to_json, status: 200
+ end
+
+ def destroy
+ render json: { message: 'not yet implemented'}, status: 501
+ end
+
+ private
+
+ def set_facade
+ Rails.logger.info "Using CANVAS_URL: #{ENV['CANVAS_URL']}"
+ # not sure if auth key will be in the request headers or in cookie
+ @canvas_facade = CanvasFacade.new(request.headers['Authorization'])
+ end
+
+ def find_extension_params
+ @lms = Lms.find(params[:lms_id])
+ @course = Course.find(params[:course_id])
+ @assignment = Assignment.find(params[:assignment_id])
+ @course_to_lms = CourseToLms.find(@assignment.course_to_lms_id)
+ end
+ end
+ end
+ end
diff --git a/app/controllers/api/v1/lmss_controller.rb b/app/controllers/api/v1/lmss_controller.rb
new file mode 100644
index 0000000..15e1961
--- /dev/null
+++ b/app/controllers/api/v1/lmss_controller.rb
@@ -0,0 +1,67 @@
+module Api
+ module V1
+ class LmssController < BaseController
+ include CanvasValidationHelper
+ before_action :validate_ids!, only: [:create]
+
+ def index
+ render json: { message: 'not yet implemented'}, status: 501
+ end
+
+ def destroy
+ render json: { message: 'not yet implemented'}, status: 501
+ end
+
+ # POST /courses/:course_id/lmss
+ def create
+ course_id = params[:course_id]
+ lms_id = params[:lms_id]
+ external_course_id = params[:external_course_id]
+
+ # Ensure that the course and LMS exist
+ unless Course.exists?(course_id)
+ render json: { error: 'Course not found' }, status: :not_found
+ return
+ end
+ unless Lms.exists?(lms_id)
+ render json: { error: 'Lms not found' }, status: :not_found
+ return
+ end
+ # Ensure that the association does not already exist
+ existing_entry = CourseToLms.find_by(course_id: course_id, lms_id: lms_id, external_course_id: external_course_id)
+ if existing_entry
+ render json: { message: 'The association between the specified course and LMS already exists.' }, status: :ok
+ return
+ end
+ # Create the association
+ course_to_lms = CourseToLms.new(
+ course_id: course_id,
+ lms_id: lms_id,
+ external_course_id: external_course_id
+ )
+
+ if course_to_lms.save
+ render json: course_to_lms, status: :created
+ else
+ render json: course_to_lms.errors, status: :unprocessable_entity
+ end
+ end
+
+ private
+
+ def validate_ids!
+ begin
+ params.require([:course_id, :lms_id, :external_course_id])
+ rescue ActionController::ParameterMissing => e
+ render json: { error: e.message }, status: :bad_request
+ return
+ else
+ unless is_valid_course_id(params[:course_id].to_i) && is_valid_lms_id(params[:lms_id].to_i)
+ render json: { error: 'Invalid course_id or lms_id' }, status: :bad_request
+ return
+ end
+ end
+ end
+ end
+ end
+end
\ No newline at end of file
diff --git a/app/controllers/api/v1/ping_controller.rb b/app/controllers/api/v1/ping_controller.rb
new file mode 100644
index 0000000..c6bb787
--- /dev/null
+++ b/app/controllers/api/v1/ping_controller.rb
@@ -0,0 +1,9 @@
+module Api
+ module V1
+ class PingController < BaseController
+ def ping
+ render :json => 'pong'.to_json
+ end
+ end
+ end
+end
diff --git a/app/controllers/api/v1/swagger_controller.rb b/app/controllers/api/v1/swagger_controller.rb
new file mode 100644
index 0000000..19fdebe
--- /dev/null
+++ b/app/controllers/api/v1/swagger_controller.rb
@@ -0,0 +1,12 @@
+require 'json'
+
+module Api
+ module V1
+ class SwaggerController < BaseController
+ def read
+ specFile = File.read(Rails.root + 'app/assets/swagger/swagger.json')
+ render :json => specFile
+ end
+ end
+ end
+end
diff --git a/app/controllers/api/v1/users_controller.rb b/app/controllers/api/v1/users_controller.rb
new file mode 100644
index 0000000..2383e24
--- /dev/null
+++ b/app/controllers/api/v1/users_controller.rb
@@ -0,0 +1,35 @@
+module Api
+ module V1
+ class UsersController < BaseController
+ include ActionController::Flash
+
+ def create
+ email = params[:email]
+
+ # Check if the user already exists by email
+ existing_user = User.find_by(email: email)
+ if existing_user
+ render json: { message: 'A user with this email already exists.' }, status: :conflict
+ return
+ end
+
+ # Build a new user object with the given email
+ new_user = User.new(email: email)
+
+ if new_user.save
+ render json: { message: 'User created successfully', user: new_user }, status: :created
+ else
+ render json: { message: 'Failed to create user', errors: new_user.errors.full_messages }, status: :unprocessable_entity
+ end
+ end
+
+ def index
+ render :json => 'the index method of UsersController is not yet implemented'.to_json, status: 501
+ end
+
+ def destroy
+ render :json => 'the destroy method of UsersController is not yet implemented'.to_json, status: 501
+ end
+ end
+ end
+end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
new file mode 100644
index 0000000..09705d1
--- /dev/null
+++ b/app/controllers/application_controller.rb
@@ -0,0 +1,2 @@
+class ApplicationController < ActionController::Base
+end
diff --git a/app/controllers/bcourses_controller.rb b/app/controllers/bcourses_controller.rb
new file mode 100644
index 0000000..346c933
--- /dev/null
+++ b/app/controllers/bcourses_controller.rb
@@ -0,0 +1,24 @@
+class BcoursesController < ApplicationController
+ require 'lms_api'
+
+ def index
+ # For dev,test,staging, they share the same canvas_test_url
+ canvas_url = Rails.application.credentials.canvas.url
+ # Assuming LMS::Canvas.new expects a token directly. Adjust as needed for actual API wrapper usage.
+ canvas_api_key = Rails.application.credentials.canvas.api_key # this will be obtained from omniauth in later iterations
+ api = LMS::Canvas.new(canvas_url, canvas_api_key)
+
+ # Fetch courses list
+ courses_url = "#{canvas_url}/api/v1/courses"
+ @courses = api.api_get_request(courses_url)
+ rescue LMS::Canvas::RefreshTokenRequired => e
+ @error = "Token expired and needs refresh: #{e.message}"
+ # In the future, we'll redirect to the Omni-auth page that requests a new token
+ rescue SocketError, Errno::ECONNREFUSED => e
+ @error = "Network connection error: #{e.message}"
+ rescue Net::ReadTimeout, Net::OpenTimeout => e
+ @error = "Network timeout: #{e.message}"
+ rescue => e
+ @error = "An unexpected error occurred: #{e.message}"
+ end
+end
\ No newline at end of file
diff --git a/app/controllers/concerns/.keep b/app/controllers/concerns/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/app/errors/validation_error.rb b/app/errors/validation_error.rb
new file mode 100644
index 0000000..0feb998
--- /dev/null
+++ b/app/errors/validation_error.rb
@@ -0,0 +1,11 @@
+##
+# Base class for a validation error.
+class ValidationError < StandardError
+ ##
+ # Constructor for error.
+ #
+ # @param message the error message (defaults to "Validation Error").
+ def initialize(message="Validation Error")
+ super(message)
+ end
+end
diff --git a/app/facades/canvas_facade.rb b/app/facades/canvas_facade.rb
new file mode 100644
index 0000000..976440e
--- /dev/null
+++ b/app/facades/canvas_facade.rb
@@ -0,0 +1,266 @@
+require 'date'
+require 'faraday'
+require 'json'
+require 'ostruct'
+
+##
+# This is the facade for Canvas.
+class CanvasFacade < ExtensionFacadeBase
+
+ CANVAS_URL = ENV['CANVAS_URL']
+
+ ##
+ # Configures the facade with the canvas api endpoint configured in the environment.
+ #
+ # @param [String] masqueradeToken the token of the user to masquerade as.
+ # @param [Faraday::Connection] conn existing connection to use (defaults to nil).
+ def initialize(masqueradeToken, conn = nil)
+ @canvasApi = conn || Faraday.new(
+ url: "#{CanvasFacade::CANVAS_URL}/api/v1",
+ headers: {
+ Authorization: "Bearer #{masqueradeToken}"
+ }
+ )
+ end
+
+ ##
+ # Gets all courses for the authorized user.
+ #
+ # @return [Faraday::Response] list of the courses the user has access to.
+ def get_all_courses
+ @canvasApi.get('courses')
+ end
+
+ ##
+ # Gets a specified course that the authorized user has access to.
+ #
+ # @param [Integer] courseId the course id to look up.
+ # @return [Faraday::Response] information about the requested course.
+ def get_course(courseId)
+ @canvasApi.get("courses/#{courseId}")
+ end
+
+ ##
+ # Gets a list of assignments for a specified course.
+ #
+ # @param [Integer] courseId the course id to fetch the assignments of.
+ # @return [Faraday::Response] list of the assignments in the course.
+ def get_assignments(courseId)
+ @canvasApi.get("courses/#{courseId}/assignments")
+ end
+
+ ##
+ # Gets a specified assignment from a course.
+ #
+ # @param [Integer] courseId the course to fetch the assignment from.
+ # @param [Integer] assignmentId the id of the assignment to fetch.
+ # @return [Faraday::Response] information about the requested assignment.
+ def get_assignment(courseId, assignmentId)
+ @canvasApi.get("courses/#{courseId}/assignments/#{assignmentId}")
+ end
+
+ ##
+ # Gets a list of the assignment overrides for a specified assignment.
+ #
+ # @param [Integer] courseId the course to fetch the overrides from.
+ # @param [Integer] assignmentId the assignment to fetch the overrides from.
+ # @return [Faraday::Response] all of the overrides for the specified assignment.
+ def get_assignment_overrides(courseId, assignmentId)
+ @canvasApi.get("courses/#{courseId}/assignments/#{assignmentId}/overrides")
+ end
+
+ ##
+ # Creates a new assignment override.
+ #
+ # @param [Integer] courseId the id of the course to create the override for.
+ # @param [Integer] assignmentId the id of the assignment to create the override for.
+ # @param [Enumerable] studentIds the student ids to provision the override to.
+ # @param [String] title the title of the new override.
+ # @param [String] dueDate the new due date for the override.
+ # @param [String] unlockDate the date the override should unlock the assignment.
+ # @param [String] lockDate the date the override should lock the assignment.
+ # @return [Faraday::Response] information about the new override.
+ def create_assignment_override(courseId, assignmentId, studentIds, title, dueDate, unlockDate, lockDate)
+ @canvasApi.post("courses/#{courseId}/assignments/#{assignmentId}/overrides", {
+ assignment_override: {
+ student_ids: studentIds,
+ title: title,
+ due_at: dueDate,
+ unlock_at: unlockDate,
+ lock_at: lockDate,
+ }
+ })
+ end
+
+ ##
+ # Updates an existing assignment override.
+ #
+ # @param [Integer] courseId the id of the course to update the override for.
+ # @param [Integer] assignmentId the id of the assignment to update the override for.
+ # @param [Enumerable] studentIds the updated student ids to provision the override to.
+ # @param [String] title the updated title of the override.
+ # @param [String] dueDate the updated due date for the override.
+ # @param [String] unlockDate the updated date the override should unlock the assignment.
+ # @param [String] lockDate the updated date the override should lock the assignment.
+ # @return [Faraday::Response] information about the updated override.
+ def update_assignment_override(courseId, assignmentId, overrideId, studentIds, title, dueDate, unlockDate, lockDate)
+ @canvasApi.put("courses/#{courseId}/assignments/#{assignmentId}/overrides/#{overrideId}", {
+ student_ids: studentIds,
+ title: title,
+ due_at: dueDate,
+ unlock_at: unlockDate,
+ lock_at: lockDate,
+ })
+ end
+
+ ##
+ # Deletes an assignment override.
+ #
+ # @param [Integer] courseId the id of the course where the override to delete is provisioned.
+ # @param [Integer] assignmentId the assignment for which the override to delete is provisioned.
+ # @param [Integer] overrideId the id of the override to delete.
+ # @return [Faraday::Response] information about the deleted override.
+ def delete_assignment_override(courseId, assignmentId, overrideId)
+ @canvasApi.delete("courses/#{courseId}/assignments/#{assignmentId}/overrides/#{overrideId}")
+ end
+
+ ##
+ # Provisions a new extension to a user.
+ #
+ # @param [Integer] courseId the course to provision the extension in.
+ # @param [Integer] studentId the student to provisoin the extension for.
+ # @param [Integer] assignmentId the assignment the extension should be provisioned for.
+ # @param [String] newDueDate the date the assignment should be due.
+ # @return [Faraday::Response] the override that acts as the extension.
+ # @raises [FailedPipelineError] if the creation response body could not be parsed.
+ # @raises [NotFoundError] if the user has an existing override that cannot be located.
+ def provision_extension(courseId, studentId, assignmentId, newDueDate)
+ overrideTitle = "#{studentId} extended to #{newDueDate}"
+ createOverrideResponse = create_assignment_override(
+ courseId,
+ assignmentId,
+ [studentId],
+ overrideTitle,
+ newDueDate,
+ get_current_formatted_time(),
+ newDueDate,
+ )
+ # Either successful or error that is not explicitly handled here.
+ if (createOverrideResponse.status != 400)
+ return createOverrideResponse
+ end
+
+ decodedCreateOverrideResponseBody = nil
+ begin
+ decodedCreateOverrideResponseBody = JSON.parse(createOverrideResponse.body, object_class: OpenStruct)
+ rescue JSON::ParserError
+ raise FailedPipelineError.new('Update Student Extension', 'Parse Creation Response')
+ end
+ # This only fails if the student already has an override provisioned to them.
+ if (
+ decodedCreateOverrideResponseBody&.errors
+ &.assignment_override_students.any? { |studentError| studentError&.type != "taken" }
+ )
+ return createOverrideResponse
+ end
+
+ currOverride = get_existing_student_override(courseId, studentId, assignmentId)
+ if (currOverride == nil)
+ raise NotFoundError.new('could not find student\'s override')
+ end
+ if (currOverride.student_ids.length == 1)
+ return update_assignment_override(
+ courseId,
+ assignmentId,
+ currOverride.id,
+ currOverride.student_ids,
+ overrideTitle,
+ newDueDate,
+ get_current_formatted_time(),
+ newDueDate,
+ )
+ end
+ remove_student_from_override(courseId, currOverride, studentId)
+ create_assignment_override(
+ courseId,
+ assignmentId,
+ [studentId],overrideTitle,
+ newDueDate,
+ get_current_formatted_time(),
+ newDueDate,
+ )
+ end
+
+ private
+
+ ##
+ # Gets the existing override for a student.
+ #
+ # @param [Integer] courseId the courseId to check for an existing override.
+ # @param [Integer] studentId the student to check for an existing override for.
+ # @param [Integer] assignmentId the assignmnet to check for an existing override for.
+ # @return [OpenStruct|nil] the override if it is found or nil if not.
+ # @throws [FailedPipelineError] if the existing overrides response body could not be parsed.
+ def get_existing_student_override(courseId, studentId, assignmentId)
+ begin
+ allAssignmentOverrides = JSON.parse(
+ get_assignment_overrides(courseId, assignmentId).body,
+ object_class: OpenStruct
+ )
+ rescue JSON::ParserError
+ raise FailedPipelineError.new(
+ 'Update Student Extension',
+ 'Get Existing Student Override',
+ 'Parse Canvas Response',
+ )
+ end
+
+ allAssignmentOverrides.each do |override|
+ if (override&.student_ids.include?(studentId))
+ return override
+ end
+ end
+ return nil
+ end
+
+ ##
+ # Gets the current time as formatted for Canvas's version of iso8601.
+ #
+ # @return [String] the current time that Canvas likes.
+ def get_current_formatted_time
+ currDateTimeUnformatted = DateTime.now().iso8601
+ # This is some weird format of iso8601 standard that Canvas likes... Don't ask me.
+ /[0-9]{4}\-[0-9]{2}\-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}/.match(currDateTimeUnformatted)[0] + 'Z'
+ end
+
+ ##
+ # Removes a student from an existing override.
+ #
+ # @param [Integer] courseId the courseId to remove the student from the override of.
+ # @param [OpenStruct] override the existing override to remove the student from.
+ # @param [Integer] studentId the id of the student to remove from the override.
+ # @return [Faraday::Response] the new override if successful.
+ # @raises [FailedPipelineError] if the student could not be removed from the override.
+ def remove_student_from_override(courseId, override, studentId)
+ override.student_ids.delete(studentId)
+ res = update_assignment_override(
+ courseId,
+ override.assignment_id,
+ override.id,
+ override.student_ids,
+ override.title,
+ override.due_at,
+ override.unlock_at,
+ override.lock_at,
+ )
+ decodedBody = JSON.parse(res.body, object_class: OpenStruct)
+ if (decodedBody&.student_ids.include?(studentId))
+ raise FailedPipelineError.new(
+ 'Update Student Extension',
+ 'Remove Student from Existing Override',
+ 'Could not remove student',
+ )
+ end
+ return res
+ end
+end
diff --git a/app/facades/extension_facade_base.rb b/app/facades/extension_facade_base.rb
new file mode 100644
index 0000000..e21608e
--- /dev/null
+++ b/app/facades/extension_facade_base.rb
@@ -0,0 +1,15 @@
+##
+# Base class for all extension facades.
+class ExtensionFacadeBase
+ ##
+ # Provisions a new extension to a user.
+ #
+ # @param [Integer] courseId the course to provision the extension in.
+ # @param [Integer] studentId the student to provisoin the extension for.
+ # @param [Integer] assignmentId the assignment the extension should be provisioned for.
+ # @param [String] newDueDate the date the assignment should be due.
+ # @return [Hash] the extension that was provisioned.
+ def provision_extension(courseId, studentId, assignmentId, newDueDate)
+ raise NotImplementedError
+ end
+end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
new file mode 100644
index 0000000..de6be79
--- /dev/null
+++ b/app/helpers/application_helper.rb
@@ -0,0 +1,2 @@
+module ApplicationHelper
+end
diff --git a/app/helpers/canvas_validation_helper.rb b/app/helpers/canvas_validation_helper.rb
new file mode 100644
index 0000000..99a295d
--- /dev/null
+++ b/app/helpers/canvas_validation_helper.rb
@@ -0,0 +1,81 @@
+module CanvasValidationHelper
+ OVERRIDE_TITLE_MAX_CHARACTERS = 40
+
+ ##
+ # Checks if the provided course id is valid.
+ #
+ # @param [Integer] courseId the course id to check.
+ # @return [Boolean] whether the provided id is valid.
+ def is_valid_course_id(courseId)
+ courseId.is_a?(Integer) && courseId > 0
+ end
+
+ ##
+ # Checks if the provided lms id is valid.
+ #
+ # @param [Integer] lmsId the lms id to check.
+ # @return [Boolean] whether the provided id is valid.
+ def is_valid_lms_id(lmsId)
+ lmsId.is_a?(Integer) && lmsId > 0
+ end
+
+ ##
+ # Checks if the provided lms id is valid.
+ #
+ # @param [Integer] lmsId the lms id to check.
+ # @return [Boolean] whether the provided id is valid.
+ def is_valid_lms_id(lmsId)
+ lmsId.is_a?(Integer) && lmsId > 0
+ end
+
+ ##
+ # Checks if the provided assignment id is valid.
+ #
+ # @param [Integer] assignmentId the assignment id to check.
+ # @return [Boolean] whether the assignment id is valid.
+ def is_valid_assignment_id(assignmentId)
+ assignmentId.is_a?(Integer) && assignmentId > 0
+ end
+
+ ##
+ # Checks if the student id is valid.
+ #
+ # @param [Integer] studentId the student id to check.
+ # @return [Boolean] whether the student id is valid.
+ def is_valid_student_id(studentId)
+ studentId.is_a?(Integer) && studentId > 0
+ end
+
+ ##
+ # Checks if the list of student ids is valid.
+ #
+ # @param [Enumerable] the list of student ids to validate.
+ # @return [Boolean] whether all student ids in the list are valid.
+ def is_valid_student_ids(studentIds)
+ studentIds.all? do |studentId|
+ is_valid_student_id(studentId)
+ end
+ end
+
+ ##
+ # Checks to see if the provided override title is valid.
+ #
+ # @param [String] title the title to check.
+ # @return [Boolean] whether the title is valid.
+ def is_valid_title(title)
+ /^[A-Za-z0-9\-_ ]*$/.match?(title) && title.length < OVERRIDE_TITLE_MAX_CHARACTERS
+ end
+
+ ##
+ # Checks whether the provided date is a valid Canvas date.
+ # TODO: maybe want to refine this to actually valid dates not just format?
+ #
+ # @param [String] date the date string to validate.
+ # @return [Boolean] whether the date string is valid.
+ def is_valid_date(date)
+ if (date == nil)
+ return true
+ end
+ /^[0-9]{4}\-[0-9]{2}\-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$/.match?(date)
+ end
+end
diff --git a/app/javascript/application.js b/app/javascript/application.js
new file mode 100644
index 0000000..0d7b494
--- /dev/null
+++ b/app/javascript/application.js
@@ -0,0 +1,3 @@
+// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
+import "@hotwired/turbo-rails"
+import "controllers"
diff --git a/app/javascript/controllers/application.js b/app/javascript/controllers/application.js
new file mode 100644
index 0000000..1213e85
--- /dev/null
+++ b/app/javascript/controllers/application.js
@@ -0,0 +1,9 @@
+import { Application } from "@hotwired/stimulus"
+
+const application = Application.start()
+
+// Configure Stimulus development experience
+application.debug = false
+window.Stimulus = application
+
+export { application }
diff --git a/app/javascript/controllers/hello_controller.js b/app/javascript/controllers/hello_controller.js
new file mode 100644
index 0000000..5975c07
--- /dev/null
+++ b/app/javascript/controllers/hello_controller.js
@@ -0,0 +1,7 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ connect() {
+ this.element.textContent = "Hello World!"
+ }
+}
diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js
new file mode 100644
index 0000000..54ad4ca
--- /dev/null
+++ b/app/javascript/controllers/index.js
@@ -0,0 +1,11 @@
+// Import and register all your controllers from the importmap under controllers/*
+
+import { application } from "controllers/application"
+
+// Eager load all controllers defined in the import map under controllers/**/*_controller
+import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
+eagerLoadControllersFrom("controllers", application)
+
+// Lazy load controllers as they appear in the DOM (remember not to preload controllers in import map!)
+// import { lazyLoadControllersFrom } from "@hotwired/stimulus-loading"
+// lazyLoadControllersFrom("controllers", application)
diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb
new file mode 100644
index 0000000..d394c3d
--- /dev/null
+++ b/app/jobs/application_job.rb
@@ -0,0 +1,7 @@
+class ApplicationJob < ActiveJob::Base
+ # Automatically retry jobs that encountered a deadlock
+ # retry_on ActiveRecord::Deadlocked
+
+ # Most jobs are safe to ignore if the underlying records are no longer available
+ # discard_on ActiveJob::DeserializationError
+end
diff --git a/app/models/application_record.rb b/app/models/application_record.rb
new file mode 100644
index 0000000..b63caeb
--- /dev/null
+++ b/app/models/application_record.rb
@@ -0,0 +1,3 @@
+class ApplicationRecord < ActiveRecord::Base
+ primary_abstract_class
+end
diff --git a/app/models/assignment.rb b/app/models/assignment.rb
new file mode 100644
index 0000000..f395aa6
--- /dev/null
+++ b/app/models/assignment.rb
@@ -0,0 +1,8 @@
+# app/models/assignment.rb
+class Assignment < ApplicationRecord
+ belongs_to :course_to_lms
+
+ validates :name, presence: true
+ validates :external_assignment_id, presence: true
+ has_many :extensions
+ end
diff --git a/app/models/concerns/.keep b/app/models/concerns/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/app/models/course.rb b/app/models/course.rb
new file mode 100644
index 0000000..41de79c
--- /dev/null
+++ b/app/models/course.rb
@@ -0,0 +1,12 @@
+class Course < ApplicationRecord
+
+ # Associations
+ has_many :course_to_lmss
+ has_many :lmss, through: :course_to_lmss
+ has_many :user_to_courses
+ has_many :users, through: :user_to_courses
+
+ # Validations
+ validates :course_name, presence: true
+
+end
\ No newline at end of file
diff --git a/app/models/course_to_lms.rb b/app/models/course_to_lms.rb
new file mode 100644
index 0000000..cfe79b9
--- /dev/null
+++ b/app/models/course_to_lms.rb
@@ -0,0 +1,10 @@
+class CourseToLms < ApplicationRecord
+
+ # Associations
+ belongs_to :course
+ belongs_to :lms
+
+ # Validations
+ validates :course_id, presence: true
+ validates :lms_id, presence: true
+end
diff --git a/app/models/extension.rb b/app/models/extension.rb
new file mode 100644
index 0000000..e4f6aa7
--- /dev/null
+++ b/app/models/extension.rb
@@ -0,0 +1,14 @@
+# assignment_id: foreign key to local assignment
+# student_email: requires another api request to find student data (sid is given in first response). This currently doesn't exist in CanvasFacade
+# initial_due_date: also requires an api request to find assignment data (assignment id is given in first response)
+# Note that the assignment.due_at shows the due date as it is for whoever's logged in (which if it's a teacher, should be the original due date) but the actual original due date is never saved anywhere
+# new_due_date:
+# external_extension_id:
+# last_processed_by_id: Requires login/sessions to be properly implemented
+class Extension < ApplicationRecord
+ #Relationship with Assignment
+ belongs_to :assignment
+
+ #Relationship with User
+ has_one :user
+end
diff --git a/app/models/lms.rb b/app/models/lms.rb
new file mode 100644
index 0000000..743cdcb
--- /dev/null
+++ b/app/models/lms.rb
@@ -0,0 +1,8 @@
+class Lms < ApplicationRecord
+ #Relationship with Course (and CourseToLms)
+ has_many :course_to_lmss
+ has_many :courses, :through => :course_to_lmss
+
+ #Relationship with Assignment
+ has_many :assignments
+end
\ No newline at end of file
diff --git a/app/models/lms_credential.rb b/app/models/lms_credential.rb
new file mode 100644
index 0000000..c6e3c1a
--- /dev/null
+++ b/app/models/lms_credential.rb
@@ -0,0 +1,12 @@
+# app/models/lms_credential.rb
+class LmsCredential < ApplicationRecord
+ # Belongs to a User
+ belongs_to :user
+
+ # Encryption for tokens
+ encrypts :token, :refresh_token
+
+ # LMS must exist
+ validates :lms_name, presence: true
+ end
+
\ No newline at end of file
diff --git a/app/models/user.rb b/app/models/user.rb
new file mode 100644
index 0000000..c88fc18
--- /dev/null
+++ b/app/models/user.rb
@@ -0,0 +1,15 @@
+# app/models/user.rb
+class User < ApplicationRecord
+ validates :email, presence: true, uniqueness: true
+ validates :email, format: { with: URI::MailTo::EMAIL_REGEXP, message: 'must be a valid email address' }
+
+ # Associations
+ has_many :lms_credentials, dependent: :destroy
+
+ # Relationship with Extension
+ has_many :extensions
+
+ #Relationship with Course (and UserToCourse)
+ has_many :user_to_courses
+ has_many :courses, :through => :user_to_courses
+ end
\ No newline at end of file
diff --git a/app/models/user_to_course.rb b/app/models/user_to_course.rb
new file mode 100644
index 0000000..e670a3e
--- /dev/null
+++ b/app/models/user_to_course.rb
@@ -0,0 +1,10 @@
+class UserToCourse < ApplicationRecord
+ # Associations
+ belongs_to :user
+ belongs_to :course
+
+ # Validations
+ validates :user_id, presence: true
+ validates :course_id, presence: true
+ validates :role, presence: true
+end
diff --git a/app/views/bcourses/index.html.erb b/app/views/bcourses/index.html.erb
new file mode 100644
index 0000000..889b529
--- /dev/null
+++ b/app/views/bcourses/index.html.erb
@@ -0,0 +1,11 @@
+
Canvas Courses
+
+<% if @error %>
+ Error: <%= @error %>
+<% else %>
+
+ <% @courses.each do |course| %>
+ - <%= course['name'] %>
+ <% end %>
+
+<% end %>
\ No newline at end of file
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
new file mode 100644
index 0000000..c496405
--- /dev/null
+++ b/app/views/layouts/application.html.erb
@@ -0,0 +1,16 @@
+
+
+
+ Flextensions
+
+ <%= csrf_meta_tags %>
+ <%= csp_meta_tag %>
+
+ <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
+ <%= javascript_importmap_tags %>
+
+
+
+ <%= yield %>
+
+
diff --git a/app/views/layouts/mailer.html.erb b/app/views/layouts/mailer.html.erb
new file mode 100644
index 0000000..3aac900
--- /dev/null
+++ b/app/views/layouts/mailer.html.erb
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+ <%= yield %>
+
+
diff --git a/app/views/layouts/mailer.text.erb b/app/views/layouts/mailer.text.erb
new file mode 100644
index 0000000..37f0bdd
--- /dev/null
+++ b/app/views/layouts/mailer.text.erb
@@ -0,0 +1 @@
+<%= yield %>
diff --git a/bin/bundle b/bin/bundle
new file mode 100755
index 0000000..50da5fd
--- /dev/null
+++ b/bin/bundle
@@ -0,0 +1,109 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+#
+# This file was generated by Bundler.
+#
+# The application 'bundle' is installed as part of a gem, and
+# this file is here to facilitate running it.
+#
+
+require "rubygems"
+
+m = Module.new do
+ module_function
+
+ def invoked_as_script?
+ File.expand_path($0) == File.expand_path(__FILE__)
+ end
+
+ def env_var_version
+ ENV["BUNDLER_VERSION"]
+ end
+
+ def cli_arg_version
+ return unless invoked_as_script? # don't want to hijack other binstubs
+ return unless "update".start_with?(ARGV.first || " ") # must be running `bundle update`
+ bundler_version = nil
+ update_index = nil
+ ARGV.each_with_index do |a, i|
+ if update_index && update_index.succ == i && a.match?(Gem::Version::ANCHORED_VERSION_PATTERN)
+ bundler_version = a
+ end
+ next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/
+ bundler_version = $1
+ update_index = i
+ end
+ bundler_version
+ end
+
+ def gemfile
+ gemfile = ENV["BUNDLE_GEMFILE"]
+ return gemfile if gemfile && !gemfile.empty?
+
+ File.expand_path("../Gemfile", __dir__)
+ end
+
+ def lockfile
+ lockfile =
+ case File.basename(gemfile)
+ when "gems.rb" then gemfile.sub(/\.rb$/, ".locked")
+ else "#{gemfile}.lock"
+ end
+ File.expand_path(lockfile)
+ end
+
+ def lockfile_version
+ return unless File.file?(lockfile)
+ lockfile_contents = File.read(lockfile)
+ return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/
+ Regexp.last_match(1)
+ end
+
+ def bundler_requirement
+ @bundler_requirement ||=
+ env_var_version ||
+ cli_arg_version ||
+ bundler_requirement_for(lockfile_version)
+ end
+
+ def bundler_requirement_for(version)
+ return "#{Gem::Requirement.default}.a" unless version
+
+ bundler_gem_version = Gem::Version.new(version)
+
+ bundler_gem_version.approximate_recommendation
+ end
+
+ def load_bundler!
+ ENV["BUNDLE_GEMFILE"] ||= gemfile
+
+ activate_bundler
+ end
+
+ def activate_bundler
+ gem_error = activation_error_handling do
+ gem "bundler", bundler_requirement
+ end
+ return if gem_error.nil?
+ require_error = activation_error_handling do
+ require "bundler/version"
+ end
+ return if require_error.nil? && Gem::Requirement.new(bundler_requirement).satisfied_by?(Gem::Version.new(Bundler::VERSION))
+ warn "Activating bundler (#{bundler_requirement}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_requirement}'`"
+ exit 42
+ end
+
+ def activation_error_handling
+ yield
+ nil
+ rescue StandardError, LoadError => e
+ e
+ end
+end
+
+m.load_bundler!
+
+if m.invoked_as_script?
+ load Gem.bin_path("bundler", "bundle")
+end
diff --git a/bin/cucumber b/bin/cucumber
new file mode 100755
index 0000000..eb5e962
--- /dev/null
+++ b/bin/cucumber
@@ -0,0 +1,11 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+vendored_cucumber_bin = Dir["#{File.dirname(__FILE__)}/../vendor/{gems,plugins}/cucumber*/bin/cucumber"].first
+if vendored_cucumber_bin
+ load File.expand_path(vendored_cucumber_bin)
+else
+ require 'rubygems' unless ENV['NO_RUBYGEMS']
+ require 'cucumber'
+ load Cucumber::BINARY
+end
diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint
new file mode 100755
index 0000000..67ef493
--- /dev/null
+++ b/bin/docker-entrypoint
@@ -0,0 +1,8 @@
+#!/bin/bash -e
+
+# If running the rails server then create or migrate existing database
+if [ "${1}" == "./bin/rails" ] && [ "${2}" == "server" ]; then
+ ./bin/rails db:prepare
+fi
+
+exec "${@}"
diff --git a/bin/importmap b/bin/importmap
new file mode 100755
index 0000000..36502ab
--- /dev/null
+++ b/bin/importmap
@@ -0,0 +1,4 @@
+#!/usr/bin/env ruby
+
+require_relative "../config/application"
+require "importmap/commands"
diff --git a/bin/rails b/bin/rails
new file mode 100755
index 0000000..efc0377
--- /dev/null
+++ b/bin/rails
@@ -0,0 +1,4 @@
+#!/usr/bin/env ruby
+APP_PATH = File.expand_path("../config/application", __dir__)
+require_relative "../config/boot"
+require "rails/commands"
diff --git a/bin/rake b/bin/rake
new file mode 100755
index 0000000..4fbf10b
--- /dev/null
+++ b/bin/rake
@@ -0,0 +1,4 @@
+#!/usr/bin/env ruby
+require_relative "../config/boot"
+require "rake"
+Rake.application.run
diff --git a/bin/setup b/bin/setup
new file mode 100755
index 0000000..3cd5a9d
--- /dev/null
+++ b/bin/setup
@@ -0,0 +1,33 @@
+#!/usr/bin/env ruby
+require "fileutils"
+
+# path to your application root.
+APP_ROOT = File.expand_path("..", __dir__)
+
+def system!(*args)
+ system(*args, exception: true)
+end
+
+FileUtils.chdir APP_ROOT do
+ # This script is a way to set up or update your development environment automatically.
+ # This script is idempotent, so that you can run it at any time and get an expectable outcome.
+ # Add necessary setup steps to this file.
+
+ puts "== Installing dependencies =="
+ system! "gem install bundler --conservative"
+ system("bundle check") || system!("bundle install")
+
+ # puts "\n== Copying sample files =="
+ # unless File.exist?("config/database.yml")
+ # FileUtils.cp "config/database.yml.sample", "config/database.yml"
+ # end
+
+ puts "\n== Preparing database =="
+ system! "bin/rails db:prepare"
+
+ puts "\n== Removing old logs and tempfiles =="
+ system! "bin/rails log:clear tmp:clear"
+
+ puts "\n== Restarting application server =="
+ system! "bin/rails restart"
+end
diff --git a/config.ru b/config.ru
new file mode 100644
index 0000000..4a3c09a
--- /dev/null
+++ b/config.ru
@@ -0,0 +1,6 @@
+# This file is used by Rack-based servers to start the application.
+
+require_relative "config/environment"
+
+run Rails.application
+Rails.application.load_server
diff --git a/config/application.rb b/config/application.rb
new file mode 100644
index 0000000..6f498d2
--- /dev/null
+++ b/config/application.rb
@@ -0,0 +1,42 @@
+require_relative "boot"
+
+require "rails"
+# Pick the frameworks you want:
+require "active_model/railtie"
+require "active_job/railtie"
+require "active_record/railtie"
+require "active_storage/engine"
+require "action_controller/railtie"
+# require "action_mailer/railtie"
+require "action_mailbox/engine"
+require "action_text/engine"
+require "action_view/railtie"
+require "action_cable/engine"
+# require "rails/test_unit/railtie"
+
+# Require the gems listed in Gemfile, including any gems
+# you've limited to :test, :development, or :production.
+Bundler.require(*Rails.groups)
+
+module Flextensions
+ class Application < Rails::Application
+ # Initialize configuration defaults for originally generated Rails version.
+ config.load_defaults 7.1
+
+ # Please, add to the `ignore` list any other `lib` subdirectories that do
+ # not contain `.rb` files, or that should not be reloaded or eager loaded.
+ # Common ones are `templates`, `generators`, or `middleware`, for example.
+ 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
+ # in config/environments, which are processed later.
+ #
+ # config.time_zone = "Central Time (US & Canada)"
+ # config.eager_load_paths << Rails.root.join("extras")
+
+ # Don't generate system test files.
+ config.generators.system_tests = nil
+ end
+end
diff --git a/config/boot.rb b/config/boot.rb
new file mode 100644
index 0000000..988a5dd
--- /dev/null
+++ b/config/boot.rb
@@ -0,0 +1,4 @@
+ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
+
+require "bundler/setup" # Set up gems listed in the Gemfile.
+require "bootsnap/setup" # Speed up boot time by caching expensive operations.
diff --git a/config/cable.yml b/config/cable.yml
new file mode 100644
index 0000000..3a605e9
--- /dev/null
+++ b/config/cable.yml
@@ -0,0 +1,10 @@
+development:
+ adapter: async
+
+test:
+ adapter: test
+
+production:
+ adapter: redis
+ url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
+ channel_prefix: flextensions_production
diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc
new file mode 100644
index 0000000..280b821
--- /dev/null
+++ b/config/credentials.yml.enc
@@ -0,0 +1 @@
+s4+uOXQ6Tz7Uwtwl+vYfxi3y5zqparVIN1bn5fSmbqwv9R9loG6qHUbm+VmFLI712Om86EiC4ZtnwwZDfI9o5LqigxvlivikURB1A9aVcukP4R5nMBBy0YGnsyXuSduzOI+IzMJQJylN4XjCPuVsl0HG61WDh4tLBCWL1PeVLNB6FIXjei/svTq7/dmO57AirMS4KlfRjsBpCdch7UnzSo0P+2IZIKImPpg3b38kML0CBfGCmZ2HkEO4lZ9PcpH26Onc6+bap8T1gN8L2VTw9Ax8+6Bo9sispkTFmx0mgK7EsXtqkgNy0fzsv4eyrv9AyMu+qOI5mfYqaO7hYRhZ3P2mZJfurtQXW1HkoKr72x/NOeGhSsy5gehxzKRmK/9wHdv5hV22/j/gmlptNiGhQTAhZQtMeonLTU34FTZfrhomFQm8woLJuE65HCK368N9lAC8cO6QhOEgWo/c2tPPt/Fn84tlyHYkt5g0mGFtpF1AJf4caLMejD8TnXymcswxsSJ2YEP+MQYmHq1KDPEi16yuGi7r32CRY9ytUFOm3urKo+cM4B9MNPc48CiT/khLDu58e7NpLd8WlNBQXIUnsVMMc6zuTh1xLn6vBaUfCjJqfdPRUJxqbtqrYvN/zYu+B5kmDHEFx87kfs3MiZOZe4I1o08dBj2PDrRiqyvKtb0h/3LqR/F5gDijkFeu+a/dxyb10OAhx1p+BPWNKscVjW0C04hdL9/TBe4BiP2ZkS6Io+uI8RJCoLgGSIxWi2C5MZu54v3EPu99xiWFNrvNq2RwpLER1fK5/vz7r/g35e4yIhjYat+j80834vSJDEjLCxxQ6okDULPESpg=--Kpc2xbBqPogVWSsi--bS0eBQjgRPZfLhD0GcbRjA==
\ No newline at end of file
diff --git a/config/credentials/production.yml.enc b/config/credentials/production.yml.enc
new file mode 100644
index 0000000..fa2d41c
--- /dev/null
+++ b/config/credentials/production.yml.enc
@@ -0,0 +1 @@
+bS8n7fFmlNVqfVDH2BW8+Rvf5lwuL/zfxPOezUiLiPWqq0CHTIuylp1zZUjrICNsdDmGGbBIzvjaCK2HYH56IeMj8LpnA5asX+gqigbW2kDRgfLmZdKqb3KZ7vDvTzjyapEIFAFnmsyeHNm7s+an0BDfq/nN9cqSwZuzFjn+UlhdPA942gO/so7CQjMTvNJbJsLBkk8cl99SbXqO0Z3aEBMW6fLIx/2ic4zFE1RkV4FbK2CemSurTrJJX3WWstcBab9reKutRGHgaDg3Yg2uq3Y8pCnzv3fHQ2KmfgUW5cPgR4s4q8kyutbNe0CmUFAEERS956KIOHpNxYw1Qy+El1hJZApKgoWqhd5Botj04VlmVNNcY6fL1kRsd57PI3RwDRwmiK4BbWusDW2AfrsIZsd/iZzk04EBJZlXZ9gfiL9C/ebgRDzHExunO+VPJVevbCIVj+76xGMoq46rJQcb7/iZwWLiDoQvFYX+NdikqHrpMbbH4mtxeqSmZBcld/XPeZQu86PvWN91CofljRhqu4746/BTNj0Tz0oLonjcf61fKhbJC8+/bIvX8oUSoPRXa5iEKS63/4u2bTrXIg4ZoUdjPeC2diloexHHveXedgqEjhvSX8OS3I75ryfzTH0ihi+zWY4Q7KBb859OpHZe3nCWVFIajnQAxLQyRSA0CYf7X6e8fHncP9BnDNulMdySxAbVpIJ0xMG4zK/NYzLqgH3Mr3pvSnwzZo4cCRjQY/WXTZ6jOaFETGlC4WVS30fZghS+ijx+S7fDeOG71RZj5WsLqDyM00ZJRKA0akujrmiibsAcIabaJG7N810eRy+fSGGi911rhqP5e9PC+qfcJtS+J9cksCEaLsiNhkvLTaq/ffaflsy6Yc151q1Rks4dI5wnPkcdhEnT0iM7A5hUvLNpNrFZgsPrZhJx39hi3YpfWH33JWQTWf9k0T6wvev/97YV9zWnVidA1Dej7SMXg/vNzjTn+kKfRVxxzEDFokLr5OHc6LqrKnp+dTQ27Q==--pYw6joKw7fKDWHCj--goFeiYHE25Q+XhPi/zHbow==
\ No newline at end of file
diff --git a/config/cucumber.yml b/config/cucumber.yml
new file mode 100644
index 0000000..47a4663
--- /dev/null
+++ b/config/cucumber.yml
@@ -0,0 +1,8 @@
+<%
+rerun = File.file?('rerun.txt') ? IO.read('rerun.txt') : ""
+rerun = rerun.strip.gsub /\s/, ' '
+rerun_opts = rerun.empty? ? "--format #{ENV['CUCUMBER_FORMAT'] || 'progress'} features" : "--format #{ENV['CUCUMBER_FORMAT'] || 'pretty'} #{rerun}"
+std_opts = "--format #{ENV['CUCUMBER_FORMAT'] || 'pretty'} --strict --tags 'not @wip'"
+%>
+default: <%= std_opts %> features
+rerun: <%= rerun_opts %> --format rerun --out rerun.txt --strict --tags 'not @wip'
diff --git a/config/database.yml b/config/database.yml
new file mode 100644
index 0000000..2959a8e
--- /dev/null
+++ b/config/database.yml
@@ -0,0 +1,33 @@
+default: &default
+ adapter: postgresql
+ pool: 5
+ timeout: 5000
+ host: localhost
+ port: <%= ENV['DB_PORT'] || '5432' %>
+ username: <%= ENV['DB_USER'] || ENV['USER'] || 'postgres' %>
+ password: <%= ENV['DB_PASSWORD'] || 'password' %>
+ database: <%= ENV['DB_NAME'] || 'postgres' %>
+
+development:
+ <<: *default
+ database: flextensions_dev
+
+# Warning: The database defined as "test" will be erased and
+# re-generated from your development database when you run "rake".
+# Do not set this db to the same as development or production.
+test:
+ <<: *default
+ database: flextensions_test
+
+staging:
+ adapter: postgresql
+ pool: 5
+ timeout: 5000
+ database: flextensions_stage
+
+# MAKE SURE THE ENVIRONMENT CONFIG IS SET UP FOR PROD
+production:
+ adapter: postgresql
+ pool: 5
+ timeout: 5000
+ database: flextensions_prod
\ No newline at end of file
diff --git a/config/environment.rb b/config/environment.rb
new file mode 100644
index 0000000..cac5315
--- /dev/null
+++ b/config/environment.rb
@@ -0,0 +1,5 @@
+# Load the Rails application.
+require_relative "application"
+
+# Initialize the Rails application.
+Rails.application.initialize!
diff --git a/config/environments/development.rb b/config/environments/development.rb
new file mode 100644
index 0000000..b8ac1b7
--- /dev/null
+++ b/config/environments/development.rb
@@ -0,0 +1,82 @@
+require "active_support/core_ext/integer/time"
+
+Rails.application.configure do
+ # Configure 'rails notes' to inspect Cucumber files
+ config.annotations.register_directories('features')
+ config.annotations.register_extensions('feature') { |tag| /#\s*(#{tag}):?\s*(.*)$/ }
+
+ # Settings specified here will take precedence over those in config/application.rb.
+
+ # In the development environment your application's code is reloaded any time
+ # it changes. This slows down response time but is perfect for development
+ # since you don't have to restart the web server when you make code changes.
+ config.enable_reloading = true
+
+ # Do not eager load code on boot.
+ config.eager_load = false
+
+ # Show full error reports.
+ config.consider_all_requests_local = true
+
+ # Enable server timing
+ config.server_timing = true
+
+ # Enable/disable caching. By default caching is disabled.
+ # Run rails dev:cache to toggle caching.
+ if Rails.root.join("tmp/caching-dev.txt").exist?
+ config.action_controller.perform_caching = true
+ config.action_controller.enable_fragment_cache_logging = true
+
+ config.cache_store = :memory_store
+ config.public_file_server.headers = {
+ "Cache-Control" => "public, max-age=#{2.days.to_i}"
+ }
+ else
+ config.action_controller.perform_caching = false
+
+ config.cache_store = :null_store
+ end
+
+ # Store uploaded files on the local file system (see config/storage.yml for options).
+ config.active_storage.service = :local
+
+ # Don't care if the mailer can't send.
+ # config.action_mailer.raise_delivery_errors = false
+
+ # config.action_mailer.perform_caching = false
+
+ # Print deprecation notices to the Rails logger.
+ config.active_support.deprecation = :log
+
+ # Raise exceptions for disallowed deprecations.
+ config.active_support.disallowed_deprecation = :raise
+
+ # Tell Active Support which deprecation messages to disallow.
+ config.active_support.disallowed_deprecation_warnings = []
+
+ # Raise an error on page load if there are pending migrations.
+ config.active_record.migration_error = :page_load
+
+ # Highlight code that triggered database queries in logs.
+ config.active_record.verbose_query_logs = true
+
+ # Highlight code that enqueued background job in logs.
+ config.active_job.verbose_enqueue_logs = true
+
+ # 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
+
+ # Raise error when a before_action's only/except options reference missing actions
+ config.action_controller.raise_on_missing_callback_actions = true
+
+ config.hosts << "flextensions.lvh.me:3000"
+end
diff --git a/config/environments/production.rb b/config/environments/production.rb
new file mode 100644
index 0000000..2fc8194
--- /dev/null
+++ b/config/environments/production.rb
@@ -0,0 +1,103 @@
+require "active_support/core_ext/integer/time"
+
+Rails.application.configure do
+ # Settings specified here will take precedence over those in config/application.rb.
+
+ # Code is not reloaded between requests.
+ 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
+ # and those relying on copy on write to perform better.
+ # Rake tasks automatically ignore this option for performance.
+ config.eager_load = true
+
+ # Full error reports are disabled and caching is turned on.
+ config.consider_all_requests_local = false
+ config.action_controller.perform_caching = true
+
+ # 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 `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
+
+ # Store uploaded files on the local file system (see config/storage.yml for options).
+ config.active_storage.service = :local
+
+ # 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
+
+ # 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("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_name_prefix = "flextensions_production"
+
+ # config.action_mailer.perform_caching = false
+
+ # 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.
+ # config.action_mailer.raise_delivery_errors = false
+
+ # Enable locale fallbacks for I18n (makes lookups for any locale fall back to
+ # the I18n.default_locale when a translation cannot be found).
+ config.i18n.fallbacks = true
+
+ # Don't log any deprecations.
+ config.active_support.report_deprecations = false
+
+ # 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" } }
+ # --- BEGIN PROD DB CRED INITIALIZATION --- #
+ ENV['DB_PORT'] ||= String(Rails.application.credentials.config[:DB_PORT])
+ ENV['DB_USER'] ||= Rails.application.credentials.config[:DB_USER]
+ ENV['DB_PASSWORD'] ||= Rails.application.credentials.config[:DB_PASSWORD]
+ ENV['DB_NAME'] ||= Rails.application.credentials.config[:DB_NAME]
+ # --- END PROD DB CRED INITIALIZATION --- #
+end
diff --git a/config/environments/test.rb b/config/environments/test.rb
new file mode 100644
index 0000000..9da3cdf
--- /dev/null
+++ b/config/environments/test.rb
@@ -0,0 +1,68 @@
+require "active_support/core_ext/integer/time"
+
+# The test environment is used exclusively to run your application's
+# test suite. You never need to work with it otherwise. Remember that
+# your test database is "scratch space" for the test suite and is wiped
+# and recreated between test runs. Don't rely on the data there!
+
+Rails.application.configure do
+ # Configure 'rails notes' to inspect Cucumber files
+ config.annotations.register_directories('features')
+ config.annotations.register_extensions('feature') { |tag| /#\s*(#{tag}):?\s*(.*)$/ }
+
+ # Settings specified here will take precedence over those in config/application.rb.
+
+ # While tests run files are not watched, reloading is not necessary.
+ config.enable_reloading = false
+
+ # 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.
+ config.public_file_server.enabled = true
+ config.public_file_server.headers = {
+ "Cache-Control" => "public, max-age=#{1.hour.to_i}"
+ }
+
+ # Show full error reports and disable caching.
+ config.consider_all_requests_local = true
+ config.action_controller.perform_caching = false
+ config.cache_store = :null_store
+
+ # 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
+
+ # config.action_mailer.perform_caching = false
+
+ # Tell Action Mailer not to deliver emails to the real world.
+ # The :test delivery method accumulates sent emails in the
+ # ActionMailer::Base.deliveries array.
+ # config.action_mailer.delivery_method = :test
+
+ # Print deprecation notices to the stderr.
+ config.active_support.deprecation = :stderr
+
+ # Raise exceptions for disallowed deprecations.
+ config.active_support.disallowed_deprecation = :raise
+
+ # Tell Active Support which deprecation messages to disallow.
+ config.active_support.disallowed_deprecation_warnings = []
+
+ # 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
+
+ # 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/importmap.rb b/config/importmap.rb
new file mode 100644
index 0000000..909dfc5
--- /dev/null
+++ b/config/importmap.rb
@@ -0,0 +1,7 @@
+# Pin npm packages by running ./bin/importmap
+
+pin "application"
+pin "@hotwired/turbo-rails", to: "turbo.min.js"
+pin "@hotwired/stimulus", to: "stimulus.min.js"
+pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
+pin_all_from "app/javascript/controllers", under: "controllers"
diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb
new file mode 100644
index 0000000..2eeef96
--- /dev/null
+++ b/config/initializers/assets.rb
@@ -0,0 +1,12 @@
+# 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/content_security_policy.rb b/config/initializers/content_security_policy.rb
new file mode 100644
index 0000000..b3076b3
--- /dev/null
+++ b/config/initializers/content_security_policy.rb
@@ -0,0 +1,25 @@
+# Be sure to restart your server when you modify this file.
+
+# 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|
+# policy.default_src :self, :https
+# policy.font_src :self, :https, :data
+# policy.img_src :self, :https, :data
+# policy.object_src :none
+# policy.script_src :self, :https
+# policy.style_src :self, :https
+# # Specify URI for violation reports
+# # policy.report_uri "/csp-violation-report-endpoint"
+# end
+#
+# # 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 style-src)
+#
+# # Report violations without enforcing the policy.
+# # config.content_security_policy_report_only = true
+# end
diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb
new file mode 100644
index 0000000..c2d89e2
--- /dev/null
+++ b/config/initializers/filter_parameter_logging.rb
@@ -0,0 +1,8 @@
+# Be sure to restart your server when you modify this 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 += [
+ :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn
+]
diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb
new file mode 100644
index 0000000..f8dc07c
--- /dev/null
+++ b/config/initializers/inflections.rb
@@ -0,0 +1,21 @@
+# Be sure to restart your server when you modify this file.
+
+# Add new inflection rules using the following format. Inflections
+# are locale specific, and you may define rules for as many different
+# locales as you wish. All of these examples are active by default:
+# ActiveSupport::Inflector.inflections(:en) do |inflect|
+# inflect.plural /^(ox)$/i, "\\1en"
+# inflect.singular /^(ox)en/i, "\\1"
+# inflect.irregular "person", "people"
+# inflect.uncountable %w( fish sheep )
+# end
+
+
+ActiveSupport::Inflector.inflections(:en) do |inflect|
+ inflect.irregular "lms", "lmss"
+end
+
+# These inflection rules are supported but not enabled by default:
+# ActiveSupport::Inflector.inflections(:en) do |inflect|
+# inflect.acronym "RESTful"
+# end
diff --git a/config/initializers/permissions_policy.rb b/config/initializers/permissions_policy.rb
new file mode 100644
index 0000000..7db3b95
--- /dev/null
+++ b/config/initializers/permissions_policy.rb
@@ -0,0 +1,13 @@
+# 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 |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/en.yml b/config/locales/en.yml
new file mode 100644
index 0000000..6c349ae
--- /dev/null
+++ b/config/locales/en.yml
@@ -0,0 +1,31 @@
+# Files in the config/locales directory are used for internationalization and
+# are automatically loaded by Rails. If you want to use locales other than
+# English, add the necessary files in this directory.
+#
+# To use the locales, use `I18n.t`:
+#
+# I18n.t "hello"
+#
+# In views, this is aliased to just `t`:
+#
+# <%= t("hello") %>
+#
+# To use a different locale, set it with `I18n.locale`:
+#
+# I18n.locale = :es
+#
+# This would use the information in config/locales/es.yml.
+#
+# To learn more about the API, please read the Rails Internationalization guide
+# at https://guides.rubyonrails.org/i18n.html.
+#
+# Be aware that YAML interprets the following case-insensitive strings as
+# booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings
+# must be quoted to be interpreted as strings. For example:
+#
+# en:
+# "yes": yup
+# enabled: "ON"
+
+en:
+ hello: "Hello world"
diff --git a/config/puma.rb b/config/puma.rb
new file mode 100644
index 0000000..afa809b
--- /dev/null
+++ b/config/puma.rb
@@ -0,0 +1,35 @@
+# This configuration file will be evaluated by Puma. The top-level methods that
+# are invoked here are part of Puma's configuration DSL. For more information
+# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html.
+
+# Puma can serve each request in a thread from an internal thread pool.
+# The `threads` method setting takes two numbers: a minimum and maximum.
+# Any libraries that use thread pools should be configured to match
+# the maximum value specified for Puma. Default is set to 5 threads for minimum
+# and maximum; this matches the default thread size of Active Record.
+max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }
+min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count }
+threads min_threads_count, max_threads_count
+
+# Specifies that the worker count should equal the number of processors in production.
+if ENV["RAILS_ENV"] == "production"
+ require "concurrent-ruby"
+ worker_count = Integer(ENV.fetch("WEB_CONCURRENCY") { Concurrent.physical_processor_count })
+ workers worker_count if worker_count > 1
+end
+
+# Specifies the `worker_timeout` threshold that Puma will use to wait before
+# terminating a worker in development environments.
+worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development"
+
+# Specifies the `port` that Puma will listen on to receive requests; default is 3000.
+port ENV.fetch("PORT") { 3000 }
+
+# Specifies the `environment` that Puma will run in.
+environment ENV.fetch("RAILS_ENV") { "development" }
+
+# Specifies the `pidfile` that Puma will use.
+pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" }
+
+# Allow puma to be restarted by `bin/rails restart` command.
+plugin :tmp_restart
diff --git a/config/routes.rb b/config/routes.rb
new file mode 100644
index 0000000..f509fbe
--- /dev/null
+++ b/config/routes.rb
@@ -0,0 +1,15 @@
+Rails.application.routes.draw do
+ get 'bcourses/index'
+ get 'bcourses', to: 'bcourses#index'
+ # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
+
+ # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
+ # Can be used by load balancers and uptime monitors to verify that the app is live.
+ get "up" => "rails/health#show", as: :rails_health_check
+
+ # Defines the root path route ("/")
+ # root "posts#index"
+ namespace :api do
+ draw('api/v1')
+ end
+end
diff --git a/config/routes/api/v1.rb b/config/routes/api/v1.rb
new file mode 100644
index 0000000..68f95a4
--- /dev/null
+++ b/config/routes/api/v1.rb
@@ -0,0 +1,13 @@
+namespace :v1 do
+ get :ping, to: 'ping#ping'
+ get :swagger, to: 'swagger#read'
+ resources :users, only: [:create, :destroy, :index]
+ resources :courses, only: [:create, :destroy, :index] do
+ put 'add_user/:user_id', action: :add_user
+ resources :lmss, only: [:create, :destroy, :index] do
+ resources :assignments, only: [:create, :destroy, :index] do
+ resources :extensions, only: [:create, :destroy, :index]
+ end
+ end
+ end
+end
diff --git a/config/storage.yml b/config/storage.yml
new file mode 100644
index 0000000..4942ab6
--- /dev/null
+++ b/config/storage.yml
@@ -0,0 +1,34 @@
+test:
+ service: Disk
+ root: <%= Rails.root.join("tmp/storage") %>
+
+local:
+ service: Disk
+ root: <%= Rails.root.join("storage") %>
+
+# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
+# amazon:
+# service: S3
+# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
+# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
+# region: us-east-1
+# bucket: your_own_bucket-<%= Rails.env %>
+
+# Remember not to checkin your GCS keyfile to a repository
+# google:
+# service: GCS
+# project: your_project
+# credentials: <%= Rails.root.join("path/to/gcs.keyfile") %>
+# bucket: your_own_bucket-<%= Rails.env %>
+
+# Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key)
+# microsoft:
+# service: AzureStorage
+# storage_account_name: your_account_name
+# storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %>
+# container: your_container_name-<%= Rails.env %>
+
+# mirror:
+# service: Mirror
+# primary: local
+# mirrors: [ amazon, google, microsoft ]
diff --git a/db/migrate/20240314223623_create_users.rb b/db/migrate/20240314223623_create_users.rb
new file mode 100644
index 0000000..8bc9c3b
--- /dev/null
+++ b/db/migrate/20240314223623_create_users.rb
@@ -0,0 +1,10 @@
+class CreateUsers < ActiveRecord::Migration[7.1]
+ def change
+ create_table :users do |t|
+ t.string :email
+
+ t.timestamps
+ end
+ add_index :users, :email, unique: true
+ end
+end
diff --git a/db/migrate/20240314223852_create_courses.rb b/db/migrate/20240314223852_create_courses.rb
new file mode 100644
index 0000000..acee897
--- /dev/null
+++ b/db/migrate/20240314223852_create_courses.rb
@@ -0,0 +1,9 @@
+class CreateCourses < ActiveRecord::Migration[7.1]
+ def change
+ create_table :courses do |t|
+ t.string :course_name
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20240314225751_create_user_to_courses.rb b/db/migrate/20240314225751_create_user_to_courses.rb
new file mode 100644
index 0000000..c01a85b
--- /dev/null
+++ b/db/migrate/20240314225751_create_user_to_courses.rb
@@ -0,0 +1,11 @@
+class CreateUserToCourses < ActiveRecord::Migration[7.1]
+ def change
+ create_table :user_to_courses do |t|
+ t.references :user, foreign_key: true
+ t.references :course, foreign_key: true
+ t.string :role
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20240314225845_create_lmss.rb b/db/migrate/20240314225845_create_lmss.rb
new file mode 100644
index 0000000..796e218
--- /dev/null
+++ b/db/migrate/20240314225845_create_lmss.rb
@@ -0,0 +1,10 @@
+class CreateLmss < ActiveRecord::Migration[7.1]
+ def change
+ create_table :lmss do |t|
+ t.string :lms_name
+ t.boolean :use_auth_token
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20240314225935_create_course_to_lmss.rb b/db/migrate/20240314225935_create_course_to_lmss.rb
new file mode 100644
index 0000000..1473913
--- /dev/null
+++ b/db/migrate/20240314225935_create_course_to_lmss.rb
@@ -0,0 +1,10 @@
+class CreateCourseToLmss < ActiveRecord::Migration[7.1]
+ def change
+ create_table :course_to_lmss do |t|
+ t.references :lms, foreign_key: true
+ t.references :course, foreign_key: true
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20240314230024_create_assignments.rb b/db/migrate/20240314230024_create_assignments.rb
new file mode 100644
index 0000000..12cff94
--- /dev/null
+++ b/db/migrate/20240314230024_create_assignments.rb
@@ -0,0 +1,10 @@
+class CreateAssignments < ActiveRecord::Migration[7.1]
+ def change
+ create_table :assignments do |t|
+ t.references :lms, foreign_key: true
+ t.string :name
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20240314230055_create_extensions.rb b/db/migrate/20240314230055_create_extensions.rb
new file mode 100644
index 0000000..048953a
--- /dev/null
+++ b/db/migrate/20240314230055_create_extensions.rb
@@ -0,0 +1,14 @@
+class CreateExtensions < ActiveRecord::Migration[7.1]
+ def change
+ create_table :extensions do |t|
+ t.references :assignment, foreign_key: true
+ t.string :student_email
+ t.datetime :initial_due_date
+ t.datetime :new_due_date
+ t.references :users, foreign_key: true
+
+ t.timestamps
+ end
+ rename_column :extensions, :users_id, :last_processed_by_id
+ end
+end
diff --git a/db/migrate/20240314230145_create_lms_credentials.rb b/db/migrate/20240314230145_create_lms_credentials.rb
new file mode 100644
index 0000000..e12718d
--- /dev/null
+++ b/db/migrate/20240314230145_create_lms_credentials.rb
@@ -0,0 +1,14 @@
+class CreateLmsCredentials < ActiveRecord::Migration[7.1]
+ def change
+ create_table :lms_credentials do |t|
+ t.references :user, foreign_key: true
+ t.string :lms_name
+ t.string :username
+ t.string :password
+ t.string :token
+ t.string :refresh_token
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20240420012042_add_external_course_id_to_courses.rb b/db/migrate/20240420012042_add_external_course_id_to_courses.rb
new file mode 100644
index 0000000..a05e460
--- /dev/null
+++ b/db/migrate/20240420012042_add_external_course_id_to_courses.rb
@@ -0,0 +1,5 @@
+class AddExternalCourseIdToCourses < ActiveRecord::Migration[7.1]
+ def change
+ add_column :courses, :external_course_id, :string
+ end
+end
diff --git a/db/migrate/20240420012054_add_external_user_id_to_lms_credentials.rb b/db/migrate/20240420012054_add_external_user_id_to_lms_credentials.rb
new file mode 100644
index 0000000..4d8b932
--- /dev/null
+++ b/db/migrate/20240420012054_add_external_user_id_to_lms_credentials.rb
@@ -0,0 +1,5 @@
+class AddExternalUserIdToLmsCredentials < ActiveRecord::Migration[7.1]
+ def change
+ add_column :lms_credentials, :external_user_id, :string
+ end
+end
diff --git a/db/migrate/20240420012105_add_external_assignment_id_to_assignments.rb b/db/migrate/20240420012105_add_external_assignment_id_to_assignments.rb
new file mode 100644
index 0000000..a05f74c
--- /dev/null
+++ b/db/migrate/20240420012105_add_external_assignment_id_to_assignments.rb
@@ -0,0 +1,5 @@
+class AddExternalAssignmentIdToAssignments < ActiveRecord::Migration[7.1]
+ def change
+ add_column :assignments, :external_assignment_id, :string
+ end
+end
diff --git a/db/migrate/20240420012126_add_external_extension_id_to_extensions.rb b/db/migrate/20240420012126_add_external_extension_id_to_extensions.rb
new file mode 100644
index 0000000..9a8cb56
--- /dev/null
+++ b/db/migrate/20240420012126_add_external_extension_id_to_extensions.rb
@@ -0,0 +1,5 @@
+class AddExternalExtensionIdToExtensions < ActiveRecord::Migration[7.1]
+ def change
+ add_column :extensions, :external_extension_id, :string
+ end
+end
diff --git a/db/migrate/20240420211050_add_external_course_id_to_courseto_lmss.rb b/db/migrate/20240420211050_add_external_course_id_to_courseto_lmss.rb
new file mode 100644
index 0000000..604533d
--- /dev/null
+++ b/db/migrate/20240420211050_add_external_course_id_to_courseto_lmss.rb
@@ -0,0 +1,5 @@
+class AddExternalCourseIdToCoursetoLmss < ActiveRecord::Migration[7.1]
+ def change
+ add_column :course_to_lmss, :external_course_id, :string
+ end
+end
\ No newline at end of file
diff --git a/db/migrate/20240420211809_remove_external_course_id_from_courses.rb b/db/migrate/20240420211809_remove_external_course_id_from_courses.rb
new file mode 100644
index 0000000..c74ceca
--- /dev/null
+++ b/db/migrate/20240420211809_remove_external_course_id_from_courses.rb
@@ -0,0 +1,5 @@
+class RemoveExternalCourseIdFromCourses < ActiveRecord::Migration[7.1]
+ def change
+ remove_column :courses, :external_course_id, :string
+ end
+end
\ No newline at end of file
diff --git a/db/migrate/20240420232708_change_lms_id_to_course_to_lms_id_in_assignments.rb b/db/migrate/20240420232708_change_lms_id_to_course_to_lms_id_in_assignments.rb
new file mode 100644
index 0000000..e39c1e1
--- /dev/null
+++ b/db/migrate/20240420232708_change_lms_id_to_course_to_lms_id_in_assignments.rb
@@ -0,0 +1,8 @@
+class ChangeLmsIdToCourseToLmsIdInAssignments < ActiveRecord::Migration[7.1]
+ def change
+ remove_index :assignments, :lms_id
+ remove_column :assignments, :lms_id, :bigint
+ add_column :assignments, :course_to_lms_id, :bigint, null: false
+ add_foreign_key :assignments, :course_to_lmss, column: :course_to_lms_id
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
new file mode 100644
index 0000000..dbf9726
--- /dev/null
+++ b/db/schema.rb
@@ -0,0 +1,99 @@
+# This file is auto-generated from the current state of the database. Instead
+# of editing this file, please use the migrations feature of Active Record to
+# incrementally modify your database, and then regenerate this schema definition.
+#
+# This file is the source Rails uses to define your schema when running `bin/rails
+# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
+# be faster and is potentially less error prone than running all of your
+# migrations from scratch. Old migrations may fail to apply correctly if those
+# migrations use external dependencies or application code.
+#
+# It's strongly recommended that you check this file into your version control system.
+
+ActiveRecord::Schema[7.1].define(version: 2024_04_20_232708) do
+ # These are extensions that must be enabled in order to support this database
+ enable_extension "plpgsql"
+
+ create_table "assignments", force: :cascade do |t|
+ t.string "name"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.string "external_assignment_id"
+ t.bigint "course_to_lms_id", null: false
+ end
+
+ create_table "course_to_lmss", force: :cascade do |t|
+ t.bigint "lms_id"
+ t.bigint "course_id"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.string "external_course_id"
+ t.index ["course_id"], name: "index_course_to_lmss_on_course_id"
+ t.index ["lms_id"], name: "index_course_to_lmss_on_lms_id"
+ end
+
+ create_table "courses", force: :cascade do |t|
+ t.string "course_name"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ end
+
+ create_table "extensions", force: :cascade do |t|
+ t.bigint "assignment_id"
+ t.string "student_email"
+ t.datetime "initial_due_date"
+ t.datetime "new_due_date"
+ t.bigint "last_processed_by_id"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.string "external_extension_id"
+ t.index ["assignment_id"], name: "index_extensions_on_assignment_id"
+ t.index ["last_processed_by_id"], name: "index_extensions_on_last_processed_by_id"
+ end
+
+ create_table "lms_credentials", force: :cascade do |t|
+ t.bigint "user_id"
+ t.string "lms_name"
+ t.string "username"
+ t.string "password"
+ t.string "token"
+ t.string "refresh_token"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.string "external_user_id"
+ t.index ["user_id"], name: "index_lms_credentials_on_user_id"
+ end
+
+ create_table "lmss", force: :cascade do |t|
+ t.string "lms_name"
+ t.boolean "use_auth_token"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ end
+
+ create_table "user_to_courses", force: :cascade do |t|
+ t.bigint "user_id"
+ t.bigint "course_id"
+ t.string "role"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["course_id"], name: "index_user_to_courses_on_course_id"
+ t.index ["user_id"], name: "index_user_to_courses_on_user_id"
+ end
+
+ create_table "users", force: :cascade do |t|
+ t.string "email"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["email"], name: "index_users_on_email", unique: true
+ end
+
+ add_foreign_key "assignments", "course_to_lmss"
+ add_foreign_key "course_to_lmss", "courses"
+ add_foreign_key "course_to_lmss", "lmss"
+ add_foreign_key "extensions", "assignments"
+ add_foreign_key "extensions", "users", column: "last_processed_by_id"
+ add_foreign_key "lms_credentials", "users"
+ add_foreign_key "user_to_courses", "courses"
+ add_foreign_key "user_to_courses", "users"
+end
diff --git a/db/seeds.rb b/db/seeds.rb
new file mode 100644
index 0000000..4cc4299
--- /dev/null
+++ b/db/seeds.rb
@@ -0,0 +1,66 @@
+# This file should ensure the existence of records required to run the application in every environment (production,
+# development, test). The code here should be idempotent so that it can be executed at any point in every environment.
+# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup).
+#
+# Example:
+#
+# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name|
+# MovieGenre.find_or_create_by!(name: genre_name)
+# end
+
+LmsCredential.destroy_all
+Extension.destroy_all
+Assignment.destroy_all
+CourseToLms.destroy_all
+UserToCourse.destroy_all
+Course.destroy_all
+Lms.destroy_all
+User.destroy_all
+
+canvas = Lms.create!({
+ lms_name: "Canvas",
+ use_auth_token: true,
+})
+
+
+test_course = Course.create!({
+ course_name: "Test Course",
+})
+
+test_course_to_lms = CourseToLms.create!({
+ lms_id: canvas.id,
+ course_id: test_course.id,
+ external_course_id: "22222",
+})
+
+test_assignment = Assignment.create!({
+ course_to_lms_id: test_course_to_lms.id,
+ name: "Test Assignment",
+ external_assignment_id: "11111",
+})
+
+test_user = User.create!({
+ email: "testuser@example.com",
+})
+
+test_user_to_course = UserToCourse.create!({
+ user_id: test_user.id,
+ course_id: test_course.id,
+ role: "test",
+})
+
+test_extension = Extension.create!({
+ assignment_id: test_assignment.id,
+ student_email: "teststudent@example.com",
+ initial_due_date: DateTime.iso8601('2024-04-20'),
+ new_due_date: DateTime.iso8601('2024-04-30'),
+ last_processed_by_id: test_user.id,
+ external_extension_id: "33333",
+})
+
+test_lms_credential = LmsCredential.create!({
+ user_id: test_user.id,
+ lms_name: "canvas",
+ token: "test token",
+ external_user_id: "44444",
+})
diff --git a/features/step_definitions/.keep b/features/step_definitions/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/features/step_definitions/web_steps.rb b/features/step_definitions/web_steps.rb
new file mode 100644
index 0000000..4d9aab6
--- /dev/null
+++ b/features/step_definitions/web_steps.rb
@@ -0,0 +1,254 @@
+# TL;DR: YOU SHOULD DELETE THIS FILE
+#
+# This file was generated by Cucumber-Rails and is only here to get you a head start
+# These step definitions are thin wrappers around the Capybara/Webrat API that lets you
+# visit pages, interact with widgets and make assertions about page content.
+#
+# If you use these step definitions as basis for your features you will quickly end up
+# with features that are:
+#
+# * Hard to maintain
+# * Verbose to read
+#
+# A much better approach is to write your own higher level step definitions, following
+# the advice in the following blog posts:
+#
+# * http://benmabey.com/2008/05/19/imperative-vs-declarative-scenarios-in-user-stories.html
+# * http://dannorth.net/2011/01/31/whose-domain-is-it-anyway/
+# * http://elabs.se/blog/15-you-re-cuking-it-wrong
+#
+
+
+require 'uri'
+require 'cgi'
+require File.expand_path(File.join(File.dirname(__FILE__), "..", "support", "paths"))
+require File.expand_path(File.join(File.dirname(__FILE__), "..", "support", "selectors"))
+
+module WithinHelpers
+ def with_scope(locator)
+ locator ? within(*selector_for(locator)) { yield } : yield
+ end
+end
+World(WithinHelpers)
+
+# Single-line step scoper
+When /^(.*) within (.*[^:])$/ do |step, parent|
+ with_scope(parent) { When step }
+end
+
+# Multi-line step scoper
+When /^(.*) within (.*[^:]):$/ do |step, parent, table_or_string|
+ with_scope(parent) { When "#{step}:", table_or_string }
+end
+
+Given /^(?:|I )am on (.+)$/ do |page_name|
+ visit path_to(page_name)
+end
+
+When /^(?:|I )go to (.+)$/ do |page_name|
+ visit path_to(page_name)
+end
+
+When /^(?:|I )press "([^"]*)"$/ do |button|
+ click_button(button)
+end
+
+When /^(?:|I )follow "([^"]*)"$/ do |link|
+ click_link(link)
+end
+
+When /^(?:|I )fill in "([^"]*)" with "([^"]*)"$/ do |field, value|
+ fill_in(field, :with => value)
+end
+
+When /^(?:|I )fill in "([^"]*)" for "([^"]*)"$/ do |value, field|
+ fill_in(field, :with => value)
+end
+
+# Use this to fill in an entire form with data from a table. Example:
+#
+# When I fill in the following:
+# | Account Number | 5002 |
+# | Expiry date | 2009-11-01 |
+# | Note | Nice guy |
+# | Wants Email? | |
+#
+# TODO: Add support for checkbox, select or option
+# based on naming conventions.
+#
+When /^(?:|I )fill in the following:$/ do |fields|
+ fields.rows_hash.each do |name, value|
+ When %{I fill in "#{name}" with "#{value}"}
+ end
+end
+
+When /^(?:|I )select "([^"]*)" from "([^"]*)"$/ do |value, field|
+ select(value, :from => field)
+end
+
+When /^(?:|I )check "([^"]*)"$/ do |field|
+ check(field)
+end
+
+When /^(?:|I )uncheck "([^"]*)"$/ do |field|
+ uncheck(field)
+end
+
+When /^(?:|I )choose "([^"]*)"$/ do |field|
+ choose(field)
+end
+
+When /^(?:|I )attach the file "([^"]*)" to "([^"]*)"$/ do |path, field|
+ attach_file(field, File.expand_path(path))
+end
+
+Then /^(?:|I )should see "([^"]*)"$/ do |text|
+ if page.respond_to? :should
+ page.should have_content(text)
+ else
+ assert page.has_content?(text)
+ end
+end
+
+Then /^(?:|I )should see \/([^\/]*)\/$/ do |regexp|
+ regexp = Regexp.new(regexp)
+
+ if page.respond_to? :should
+ page.should have_xpath('//*', :text => regexp)
+ else
+ assert page.has_xpath?('//*', :text => regexp)
+ end
+end
+
+Then /^(?:|I )should not see "([^"]*)"$/ do |text|
+ if page.respond_to? :should
+ page.should have_no_content(text)
+ else
+ assert page.has_no_content?(text)
+ end
+end
+
+Then /^(?:|I )should not see \/([^\/]*)\/$/ do |regexp|
+ regexp = Regexp.new(regexp)
+
+ if page.respond_to? :should
+ page.should have_no_xpath('//*', :text => regexp)
+ else
+ assert page.has_no_xpath?('//*', :text => regexp)
+ end
+end
+
+Then /^the "([^"]*)" field(?: within (.*))? should contain "([^"]*)"$/ do |field, parent, value|
+ with_scope(parent) do
+ field = find_field(field)
+ field_value = (field.tag_name == 'textarea') ? field.text : field.value
+ if field_value.respond_to? :should
+ field_value.should =~ /#{value}/
+ else
+ assert_match(/#{value}/, field_value)
+ end
+ end
+end
+
+Then /^the "([^"]*)" field(?: within (.*))? should not contain "([^"]*)"$/ do |field, parent, value|
+ with_scope(parent) do
+ field = find_field(field)
+ field_value = (field.tag_name == 'textarea') ? field.text : field.value
+ if field_value.respond_to? :should_not
+ field_value.should_not =~ /#{value}/
+ else
+ assert_no_match(/#{value}/, field_value)
+ end
+ end
+end
+
+Then /^the "([^"]*)" field should have the error "([^"]*)"$/ do |field, error_message|
+ element = find_field(field)
+ classes = element.find(:xpath, '..')[:class].split(' ')
+
+ form_for_input = element.find(:xpath, 'ancestor::form[1]')
+ using_formtastic = form_for_input[:class].include?('formtastic')
+ error_class = using_formtastic ? 'error' : 'field_with_errors'
+
+ if classes.respond_to? :should
+ classes.should include(error_class)
+ else
+ assert classes.include?(error_class)
+ end
+
+ if page.respond_to?(:should)
+ if using_formtastic
+ error_paragraph = element.find(:xpath, '../*[@class="inline-errors"][1]')
+ error_paragraph.should have_content(error_message)
+ else
+ page.should have_content("#{field.titlecase} #{error_message}")
+ end
+ else
+ if using_formtastic
+ error_paragraph = element.find(:xpath, '../*[@class="inline-errors"][1]')
+ assert error_paragraph.has_content?(error_message)
+ else
+ assert page.has_content?("#{field.titlecase} #{error_message}")
+ end
+ end
+end
+
+Then /^the "([^"]*)" field should have no error$/ do |field|
+ element = find_field(field)
+ classes = element.find(:xpath, '..')[:class].split(' ')
+ if classes.respond_to? :should
+ classes.should_not include('field_with_errors')
+ classes.should_not include('error')
+ else
+ assert !classes.include?('field_with_errors')
+ assert !classes.include?('error')
+ end
+end
+
+Then /^the "([^"]*)" checkbox(?: within (.*))? should be checked$/ do |label, parent|
+ with_scope(parent) do
+ field_checked = find_field(label)['checked']
+ if field_checked.respond_to? :should
+ field_checked.should be_true
+ else
+ assert field_checked
+ end
+ end
+end
+
+Then /^the "([^"]*)" checkbox(?: within (.*))? should not be checked$/ do |label, parent|
+ with_scope(parent) do
+ field_checked = find_field(label)['checked']
+ if field_checked.respond_to? :should
+ field_checked.should be_false
+ else
+ assert !field_checked
+ end
+ end
+end
+
+Then /^(?:|I )should be on (.+)$/ do |page_name|
+ current_path = URI.parse(current_url).path
+ if current_path.respond_to? :should
+ current_path.should == path_to(page_name)
+ else
+ assert_equal path_to(page_name), current_path
+ end
+end
+
+Then /^(?:|I )should have the following query string:$/ do |expected_pairs|
+ query = URI.parse(current_url).query
+ actual_params = query ? CGI.parse(query) : {}
+ expected_params = {}
+ expected_pairs.rows_hash.each_pair{|k,v| expected_params[k] = v.split(',')}
+
+ if actual_params.respond_to? :should
+ actual_params.should == expected_params
+ else
+ assert_equal expected_params, actual_params
+ end
+end
+
+Then /^show me the page$/ do
+ save_and_open_page
+end
diff --git a/features/support/env.rb b/features/support/env.rb
new file mode 100644
index 0000000..3b97d14
--- /dev/null
+++ b/features/support/env.rb
@@ -0,0 +1,53 @@
+# IMPORTANT: This file is generated by cucumber-rails - edit at your own peril.
+# It is recommended to regenerate this file in the future when you upgrade to a
+# newer version of cucumber-rails. Consider adding your own code to a new file
+# instead of editing this one. Cucumber will automatically load all features/**/*.rb
+# files.
+
+
+require 'cucumber/rails'
+
+# By default, any exception happening in your Rails application will bubble up
+# to Cucumber so that your scenario will fail. This is a different from how
+# your application behaves in the production environment, where an error page will
+# be rendered instead.
+#
+# Sometimes we want to override this default behaviour and allow Rails to rescue
+# exceptions and display an error page (just like when the app is running in production).
+# Typical scenarios where you want to do this is when you test your error pages.
+# There are two ways to allow Rails to rescue exceptions:
+#
+# 1) Tag your scenario (or feature) with @allow-rescue
+#
+# 2) Set the value below to true. Beware that doing this globally is not
+# recommended as it will mask a lot of errors for you!
+#
+ActionController::Base.allow_rescue = false
+
+# Remove/comment out the lines below if your app doesn't have a database.
+# For some databases (like MongoDB and CouchDB) you may need to use :truncation instead.
+begin
+ DatabaseCleaner.strategy = :transaction
+rescue NameError
+ raise "You need to add database_cleaner to your Gemfile (in the :test group) if you wish to use it."
+end
+
+# You may also want to configure DatabaseCleaner to use different strategies for certain features and scenarios.
+# See the DatabaseCleaner documentation for details. Example:
+#
+# Before('@no-txn,@selenium,@culerity,@celerity,@javascript') do
+# # { except: [:widgets] } may not do what you expect here
+# # as Cucumber::Rails::Database.javascript_strategy overrides
+# # this setting.
+# DatabaseCleaner.strategy = :truncation
+# end
+#
+# Before('not @no-txn', 'not @selenium', 'not @culerity', 'not @celerity', 'not @javascript') do
+# DatabaseCleaner.strategy = :transaction
+# end
+#
+
+# Possible values are :truncation and :transaction
+# The :transaction strategy is faster, but might give you threading problems.
+# See https://github.com/cucumber/cucumber-rails/blob/master/features/choose_javascript_database_strategy.feature
+Cucumber::Rails::Database.javascript_strategy = :truncation
diff --git a/features/support/paths.rb b/features/support/paths.rb
new file mode 100644
index 0000000..290543c
--- /dev/null
+++ b/features/support/paths.rb
@@ -0,0 +1,38 @@
+# TL;DR: YOU SHOULD DELETE THIS FILE
+#
+# This file is used by web_steps.rb, which you should also delete
+#
+# You have been warned
+module NavigationHelpers
+ # Maps a name to a path. Used by the
+ #
+ # When /^I go to (.+)$/ do |page_name|
+ #
+ # step definition in web_steps.rb
+ #
+ def path_to(page_name)
+ case page_name
+
+ when /^the home\s?page$/
+ '/'
+
+ # Add more mappings here.
+ # Here is an example that pulls values out of the Regexp:
+ #
+ # when /^(.*)'s profile page$/i
+ # user_profile_path(User.find_by_login($1))
+
+ else
+ begin
+ page_name =~ /^the (.*) page$/
+ path_components = $1.split(/\s+/)
+ self.send(path_components.push('path').join('_').to_sym)
+ rescue NoMethodError, ArgumentError
+ raise "Can't find mapping from \"#{page_name}\" to a path.\n" +
+ "Now, go and add a mapping in #{__FILE__}"
+ end
+ end
+ end
+end
+
+World(NavigationHelpers)
diff --git a/features/support/selectors.rb b/features/support/selectors.rb
new file mode 100644
index 0000000..33bebc1
--- /dev/null
+++ b/features/support/selectors.rb
@@ -0,0 +1,44 @@
+# TL;DR: YOU SHOULD DELETE THIS FILE
+#
+# This file is used by web_steps.rb, which you should also delete
+#
+# You have been warned
+module HtmlSelectorsHelpers
+ # Maps a name to a selector. Used primarily by the
+ #
+ # When /^(.+) within (.+)$/ do |step, scope|
+ #
+ # step definitions in web_steps.rb
+ #
+ def selector_for(locator)
+ case locator
+
+ when "the page"
+ "html > body"
+
+ # Add more mappings here.
+ # Here is an example that pulls values out of the Regexp:
+ #
+ # when /^the (notice|error|info) flash$/
+ # ".flash.#{$1}"
+
+ # You can also return an array to use a different selector
+ # type, like:
+ #
+ # when /the header/
+ # [:xpath, "//header"]
+
+ # This allows you to provide a quoted selector as the scope
+ # for "within" steps as was previously the default for the
+ # web steps:
+ when /^"(.+)"$/
+ $1
+
+ else
+ raise "Can't find mapping from \"#{locator}\" to a selector.\n" +
+ "Now, go and add a mapping in #{__FILE__}"
+ end
+ end
+end
+
+World(HtmlSelectorsHelpers)
diff --git a/lib/assets/.keep b/lib/assets/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/lib/tasks/.keep b/lib/tasks/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/lib/tasks/cucumber.rake b/lib/tasks/cucumber.rake
new file mode 100644
index 0000000..0caa4d2
--- /dev/null
+++ b/lib/tasks/cucumber.rake
@@ -0,0 +1,69 @@
+# IMPORTANT: This file is generated by cucumber-rails - edit at your own peril.
+# It is recommended to regenerate this file in the future when you upgrade to a
+# newer version of cucumber-rails. Consider adding your own code to a new file
+# instead of editing this one. Cucumber will automatically load all features/**/*.rb
+# files.
+
+
+unless ARGV.any? {|a| a =~ /^gems/} # Don't load anything when running the gems:* tasks
+
+vendored_cucumber_bin = Dir["#{Rails.root}/vendor/{gems,plugins}/cucumber*/bin/cucumber"].first
+$LOAD_PATH.unshift(File.dirname(vendored_cucumber_bin) + '/../lib') unless vendored_cucumber_bin.nil?
+
+begin
+ require 'cucumber/rake/task'
+
+ namespace :cucumber do
+ Cucumber::Rake::Task.new({ok: 'test:prepare'}, 'Run features that should pass') do |t|
+ t.binary = vendored_cucumber_bin # If nil, the gem's binary is used.
+ t.fork = true # You may get faster startup if you set this to false
+ t.profile = 'default'
+ end
+
+ Cucumber::Rake::Task.new({wip: 'test:prepare'}, 'Run features that are being worked on') do |t|
+ t.binary = vendored_cucumber_bin
+ t.fork = true # You may get faster startup if you set this to false
+ t.profile = 'wip'
+ end
+
+ Cucumber::Rake::Task.new({rerun: 'test:prepare'}, 'Record failing features and run only them if any exist') do |t|
+ t.binary = vendored_cucumber_bin
+ t.fork = true # You may get faster startup if you set this to false
+ t.profile = 'rerun'
+ end
+
+ desc 'Run all features'
+ task all: [:ok, :wip]
+
+ task :statsetup do
+ require 'rails/code_statistics'
+ ::STATS_DIRECTORIES << %w(Cucumber\ features features) if File.exist?('features')
+ ::CodeStatistics::TEST_TYPES << "Cucumber features" if File.exist?('features')
+ end
+
+ end
+
+ desc 'Alias for cucumber:ok'
+ task cucumber: 'cucumber:ok'
+
+ task default: :cucumber
+
+ task features: :cucumber do
+ STDERR.puts "*** The 'features' task is deprecated. See rake -T cucumber ***"
+ end
+
+ # In case we don't have the generic Rails test:prepare hook, append a no-op task that we can depend upon.
+ task 'test:prepare' do
+ end
+
+ task stats: 'cucumber:statsetup'
+
+
+rescue LoadError
+ desc 'cucumber rake task not available (cucumber not installed)'
+ task :cucumber do
+ abort 'Cucumber rake task is not available. Be sure to install cucumber as a gem or plugin'
+ end
+end
+
+end
diff --git a/log/.keep b/log/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..763b53a
--- /dev/null
+++ b/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "flextensions",
+ "version": "1.0.0",
+ "description": "Back end/API for UC Berkeley EECS \"Flextensions\" software",
+ "directories": {
+ "lib": "lib"
+ },
+ "scripts": {
+ "dev": "overmind s -f Procfile.dev"
+ },
+ "author": "CS169l flextensions development team",
+ "license": "BSD-2-Clause"
+}
diff --git a/public/404.html b/public/404.html
new file mode 100644
index 0000000..2be3af2
--- /dev/null
+++ b/public/404.html
@@ -0,0 +1,67 @@
+
+
+
+ The page you were looking for doesn't exist (404)
+
+
+
+
+
+
+
+
+
The page you were looking for doesn't exist.
+
You may have mistyped the address or the page may have moved.
+
+
If you are the application owner check the logs for more information.
+
+
+
diff --git a/public/422.html b/public/422.html
new file mode 100644
index 0000000..c08eac0
--- /dev/null
+++ b/public/422.html
@@ -0,0 +1,67 @@
+
+
+
+ The change you wanted was rejected (422)
+
+
+
+
+
+
+
+
+
The change you wanted was rejected.
+
Maybe you tried to change something you didn't have access to.
+
+
If you are the application owner check the logs for more information.
+
+
+
diff --git a/public/500.html b/public/500.html
new file mode 100644
index 0000000..78a030a
--- /dev/null
+++ b/public/500.html
@@ -0,0 +1,66 @@
+
+
+
+ We're sorry, but something went wrong (500)
+
+
+
+
+
+
+
+
+
We're sorry, but something went wrong.
+
+
If you are the application owner check the logs for more information.
+
+
+
diff --git a/public/apple-touch-icon-precomposed.png b/public/apple-touch-icon-precomposed.png
new file mode 100644
index 0000000..e69de29
diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png
new file mode 100644
index 0000000..e69de29
diff --git a/public/favicon.ico b/public/favicon.ico
new file mode 100644
index 0000000..e69de29
diff --git a/public/robots.txt b/public/robots.txt
new file mode 100644
index 0000000..c19f78a
--- /dev/null
+++ b/public/robots.txt
@@ -0,0 +1 @@
+# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
diff --git a/spec/Facades/canvas_facade_spec.rb b/spec/Facades/canvas_facade_spec.rb
new file mode 100644
index 0000000..91d1267
--- /dev/null
+++ b/spec/Facades/canvas_facade_spec.rb
@@ -0,0 +1,410 @@
+require 'date'
+require 'json'
+require 'ostruct'
+require 'rails_helper'
+require 'timecop'
+
+describe CanvasFacade do
+ let(:mockAuthToken) { 'testAuthToken' }
+ let(:mockCourseId) { 16 }
+ let(:mockStudentId) { 22 }
+ let(:mockAssignmentId) { 18 }
+ let(:mockTitle) { 'mockOverrideTitle' }
+ let(:mockDate) { '2002-03-16:16:00:00Z' }
+ let(:mockOverrideId) { 8 }
+ let(:mockOverride) { {
+ id: mockOverrideId,
+ assignment_id: mockAssignmentId,
+ title: 'mockOverrideTitle',
+ due_at: mockDate,
+ unlock_at: mockDate,
+ lock_at: mockDate,
+ student_ids: [mockStudentId],
+ } }
+
+ describe('initialization') do
+ it 'sets the proper URL' do
+ expect(Faraday).to receive(:new).with(hash_including(
+ url: "#{ENV['CANVAS_URL']}/api/v1",
+ ))
+ described_class.new(mockAuthToken)
+ end
+
+ it 'sets the proper token' do
+ expect(Faraday).to receive(:new).with(hash_including(
+ headers: {
+ Authorization: "Bearer #{mockAuthToken}"
+ }
+ ))
+ described_class.new(mockAuthToken)
+ end
+ end
+
+ # Used https://danielabaron.me/blog/testing-faraday-with-rspec/ as reference.
+ let(:stubs) { Faraday::Adapter::Test::Stubs.new }
+ let(:conn) { Faraday.new { |builder| builder.adapter(:test, stubs) } }
+ let(:facade) { described_class.new(mockAuthToken, conn) }
+
+ # Allows each test to have its own set of stubs.
+ after do
+ Faraday.default_connection = nil
+ end
+
+ describe 'get_all_courses' do
+ before do
+ stubs.get('courses') { [200, {}, '{}'] }
+ end
+
+ it 'has correct response body on successful call' do
+ expect(facade.get_all_courses.body).to eq('{}')
+ stubs.verify_stubbed_calls
+ end
+ end
+
+ describe 'get_course' do
+ before do
+ stubs.get("courses/#{mockCourseId}") { [200, {}, '{}'] }
+ end
+
+ it 'has correct response body on successful call' do
+ expect(facade.get_course(mockCourseId).body).to eq('{}')
+ stubs.verify_stubbed_calls
+ end
+ end
+
+ describe('get_assignments') do
+ before do
+ stubs.get("courses/#{mockCourseId}/assignments") { [200, {}, '{}'] }
+ end
+
+ it 'has correct response body on successful call' do
+ expect(facade.get_assignments(mockCourseId).body).to eq('{}')
+ stubs.verify_stubbed_calls
+ end
+ end
+
+ describe('get_assignment') do
+ before do
+ stubs.get("courses/#{mockCourseId}/assignments/#{mockAssignmentId}") { [200, {}, '{}'] }
+ end
+
+ it 'has correct response body on successful call' do
+ expect(facade.get_assignment(mockCourseId, mockAssignmentId).body).to eq('{}')
+ stubs.verify_stubbed_calls
+ end
+ end
+
+ describe('get_assignment_overrides') do
+ before do
+ stubs.get(
+ "courses/#{mockCourseId}/assignments/#{mockAssignmentId}/overrides"
+ ) { [200, {}, '{}'] }
+ end
+
+ it 'has correct response body on successful call' do
+ expect(facade.get_assignment_overrides(mockCourseId, mockAssignmentId).body).to eq('{}')
+ stubs.verify_stubbed_calls
+ end
+ end
+
+ describe('create_assignment_override') do
+ let(:createAssignmentOverrideUrl) { "courses/#{mockCourseId}/assignments/#{mockAssignmentId}/overrides" }
+
+ before do
+ stubs.post(
+ createAssignmentOverrideUrl,
+ ) { [200, {}, '{}'] }
+ end
+
+ it 'has correct request body' do
+ expect(conn).to receive(:post).with(
+ createAssignmentOverrideUrl,
+ { assignment_override: {
+ student_ids: [mockStudentId],
+ title: mockTitle,
+ due_at: mockDate,
+ unlock_at: mockDate,
+ lock_at: mockDate,
+ }}
+ )
+
+ facade.create_assignment_override(
+ mockCourseId,
+ mockAssignmentId,
+ [mockStudentId],
+ mockTitle,
+ mockDate,
+ mockDate,
+ mockDate,
+ )
+ end
+
+ it 'has correct response body on successful call' do
+ expect(facade.create_assignment_override(
+ mockCourseId,
+ mockAssignmentId,
+ [mockStudentId],
+ mockTitle,
+ mockDate,
+ mockDate,
+ mockDate,
+ ).body).to eq('{}')
+ stubs.verify_stubbed_calls
+ end
+ end
+
+ describe('update_assignment_override') do
+ let(:updateAssignmentOverrideUrl) {
+ "courses/#{mockCourseId}/assignments/#{mockAssignmentId}/overrides/#{mockOverrideId}"
+ }
+ before do
+ stubs.put(
+ updateAssignmentOverrideUrl,
+ ) { [200, {}, '{}'] }
+ end
+
+ it 'has correct request body' do
+ expect(conn).to receive(:put).with(
+ updateAssignmentOverrideUrl,
+ {
+ student_ids: [mockStudentId],
+ title: mockTitle,
+ due_at: mockDate,
+ unlock_at: mockDate,
+ lock_at: mockDate,
+ }
+ )
+
+ facade.update_assignment_override(
+ mockCourseId,
+ mockAssignmentId,
+ mockOverrideId,
+ [mockStudentId],
+ mockTitle,
+ mockDate,
+ mockDate,
+ mockDate,
+ )
+ end
+
+ it 'has correct response body on successful call' do
+ expect(facade.update_assignment_override(
+ mockCourseId,
+ mockAssignmentId,
+ mockOverrideId,
+ [mockStudentId],
+ mockTitle,
+ mockDate,
+ mockDate,
+ mockDate,
+ ).body).to eq('{}')
+ stubs.verify_stubbed_calls
+ end
+ end
+
+ describe('delete_assignment_override') do
+ before do
+ stubs.delete(
+ "courses/#{mockCourseId}/assignments/#{mockAssignmentId}/overrides/#{mockOverrideId}"
+ ) { [200, {}, '{}'] }
+ end
+
+ it 'has correct response body on successful call' do
+ expect(facade.delete_assignment_override(
+ mockCourseId,
+ mockAssignmentId,
+ mockOverrideId,
+ ).body).to eq('{}')
+ stubs.verify_stubbed_calls
+ end
+ end
+
+ describe('provision_extension') do
+ let(:mockOverrideCreationUrl) { "courses/#{mockCourseId}/assignments/#{mockAssignmentId}/overrides" }
+ let(:mockCreationErrorResponseAlreadyExists) { [
+ 400,
+ {},
+ { errors: { assignment_override_students: [{
+ attribute: 'assignment_override_students',
+ type: 'taken',
+ message: 'already belongs to an assignment override'
+ }] } }.to_json
+ ] }
+
+ before do
+ allow(facade).to receive(:get_current_formatted_time).and_return(mockDate)
+ end
+
+ it 'returns correct response body on successful creation' do
+ stubs.post(mockOverrideCreationUrl) { [200, {}, '{}' ] }
+ expect(facade.provision_extension(
+ mockCourseId,
+ mockStudentId,
+ mockAssignmentId,
+ mockDate,
+ ).body).to eq('{}')
+ end
+
+ it 'throws a pipeline error if the creation response body is improperly formatted' do
+ stubs.post(mockOverrideCreationUrl) { [400, {}, '{invalid json}'] }
+ expect { facade.provision_extension(
+ mockCourseId,
+ mockStudentId,
+ mockAssignmentId,
+ mockDate,
+ ) }.to raise_error(FailedPipelineError)
+ end
+
+ it 'throws an error if the existing override cannot be found' do
+ stubs.post(mockOverrideCreationUrl) { mockCreationErrorResponseAlreadyExists }
+ expect(facade).to receive(:get_existing_student_override).and_return(nil)
+ expect { facade.provision_extension(
+ mockCourseId,
+ mockStudentId,
+ mockAssignmentId,
+ mockDate,
+ ) }.to raise_error(NotFoundError)
+ end
+
+ it 'updates the existing assignment override if the student is the only student the override is provisioned to' do
+ stubs.post(mockOverrideCreationUrl) { mockCreationErrorResponseAlreadyExists }
+ expect(facade).to receive(:get_existing_student_override).and_return(OpenStruct.new(mockOverride))
+ expect(facade).to receive(:update_assignment_override).with(
+ mockCourseId,
+ mockAssignmentId,
+ mockOverride[:id],
+ mockOverride[:student_ids],
+ "#{mockStudentId} extended to #{mockDate}",
+ mockDate,
+ mockDate,
+ mockDate,
+ )
+ facade.provision_extension(
+ mockCourseId,
+ mockStudentId,
+ mockAssignmentId,
+ mockDate,
+ )
+ end
+
+ it 'creates a new override if the student\'s existing one has multiple other students' do
+ mockOverride[:student_ids].append(mockStudentId + 1)
+ stubs.post(mockOverrideCreationUrl) { mockCreationErrorResponseAlreadyExists }
+ mockOverrideStruct = OpenStruct.new(mockOverride)
+ expect(facade).to receive(:get_existing_student_override).and_return(mockOverrideStruct)
+ expect(facade).to receive(:remove_student_from_override).with(
+ mockCourseId,
+ mockOverrideStruct,
+ mockStudentId,
+ )
+ facade.provision_extension(
+ mockCourseId,
+ mockStudentId,
+ mockAssignmentId,
+ mockDate,
+ )
+ end
+ end
+
+ describe 'get_existing_student_override' do
+ let(:getAssignmentOverridesUrl) { "courses/#{mockCourseId}/assignments/#{mockAssignmentId}/overrides" }
+ it 'throws an error if the overrides response body cannot be parsed' do
+ stubs.get(getAssignmentOverridesUrl) { [200, {}, '{invalid json}'] }
+ expect { facade.send(
+ :get_existing_student_override,
+ mockCourseId,
+ mockStudentId,
+ mockAssignmentId,
+ ) }.to raise_error(FailedPipelineError)
+ end
+
+ it 'returns the override that the student is listed in' do
+ mockOverrideWithoutStudent = mockOverride.clone
+ mockOverrideWithoutStudent[:student_ids] = [mockStudentId + 1]
+ stubs.get(getAssignmentOverridesUrl) { [
+ 200,
+ {},
+ [mockOverrideWithoutStudent, mockOverride].to_json,
+ ] }
+ expect(facade.send(
+ :get_existing_student_override,
+ mockCourseId,
+ mockStudentId,
+ mockAssignmentId,
+ ).student_ids[0]).to eq(mockStudentId)
+ end
+
+ it 'returns nil if no override for that student is found' do
+ mockOverride[:student_ids] = [mockStudentId + 1]
+ stubs.get(getAssignmentOverridesUrl) { [
+ 200,
+ {},
+ mockOverride.to_json
+ ] }
+ expect(facade.send(
+ :get_existing_student_override,
+ mockCourseId,
+ mockStudentId,
+ mockAssignmentId,
+ )).to eq(nil)
+ end
+ end
+
+ describe 'get_current_formatted_time' do
+ before do
+ Timecop.freeze(DateTime.new(2002, 03, 16, 16))
+ end
+
+ it 'outputs the current time in Canvas iso8601 formatting' do
+ expect(facade.send(:get_current_formatted_time)).to eq('2002-03-16T16:00:00Z')
+ end
+ end
+
+ describe 'remove_student_from_override' do
+ let(:mockOverrideStruct) { OpenStruct.new(mockOverride) }
+
+ before do
+ mockOverrideStruct.student_ids.append(mockStudentId + 1)
+ end
+
+ it 'removes the student and keeps everything else the same' do
+ mockOverrideWithoutStudent = OpenStruct.new(mockOverride)
+ mockOverrideWithoutStudent.student_ids = [mockStudentId + 1]
+ expect(facade).to receive(:update_assignment_override).with(
+ mockCourseId,
+ mockOverrideStruct.assignment_id,
+ mockOverrideStruct.id,
+ mockOverrideStruct.student_ids,
+ mockOverrideStruct.title,
+ mockOverrideStruct.due_at,
+ mockOverrideStruct.unlock_at,
+ mockOverrideStruct.lock_at,
+ ).and_return(OpenStruct.new({ body: mockOverrideWithoutStudent.to_h.to_json }))
+ expect(facade.send(
+ :remove_student_from_override,
+ mockCourseId,
+ mockOverrideStruct,
+ mockStudentId,
+ ))
+ end
+
+ it 'throws a pipeline error if the student cannot be removed from the override' do
+ expect(facade).to receive(:update_assignment_override).with(
+ mockCourseId,
+ mockOverrideStruct.assignment_id,
+ mockOverrideStruct.id,
+ mockOverrideStruct.student_ids,
+ mockOverrideStruct.title,
+ mockOverrideStruct.due_at,
+ mockOverrideStruct.unlock_at,
+ mockOverrideStruct.lock_at,
+ ).and_return(OpenStruct.new({ body: mockOverrideStruct.to_h.to_json }))
+ expect { facade.send(
+ :remove_student_from_override,
+ mockCourseId,
+ mockOverrideStruct,
+ mockStudentId,
+ ) }.to raise_error(FailedPipelineError)
+ end
+ end
+end
diff --git a/spec/Facades/extension_facade_base_spec.rb b/spec/Facades/extension_facade_base_spec.rb
new file mode 100644
index 0000000..5b6d2e9
--- /dev/null
+++ b/spec/Facades/extension_facade_base_spec.rb
@@ -0,0 +1,20 @@
+require 'rails_helper'
+
+describe ExtensionFacadeBase do
+ let(:facade) { described_class.new() }
+ let(:mockCourseId) { 16 }
+ let(:mockStudentId) { 22 }
+ let(:mockAssignmentId) { 18 }
+ let(:mockDate) { '2002-03-16:16:00:00Z' }
+
+ describe 'provision_extension' do
+ it 'throws not implemented error' do
+ expect { facade.provision_extension(
+ mockCourseId,
+ mockStudentId,
+ mockAssignmentId,
+ mockDate
+ ) }.to raise_error(NotImplementedError)
+ end
+ end
+end
diff --git a/spec/Helpers/canvas_validation_helper_spec.rb b/spec/Helpers/canvas_validation_helper_spec.rb
new file mode 100644
index 0000000..cbfe889
--- /dev/null
+++ b/spec/Helpers/canvas_validation_helper_spec.rb
@@ -0,0 +1,112 @@
+require 'rails_helper'
+require_relative '../../app/helpers/canvas_validation_helper'
+
+describe 'CanvasValidationHelper', type: :helper do
+ class Helper
+ include CanvasValidationHelper
+ end
+ attr_reader :helper
+
+ before do
+ @helper = Helper.new
+ end
+
+ describe 'is_valid_course_id' do
+ it 'returns true on positive integer input' do
+ expect(helper.is_valid_course_id(16)).to be(true)
+ end
+
+ it 'returns false on negative integer input' do
+ expect(helper.is_valid_course_id(-16)).to be(false)
+ end
+
+ it 'returns false on boolean input' do
+ expect(helper.is_valid_course_id(0.1)).to be(false)
+ end
+
+ it 'returns false on alphabetical input' do
+ expect(helper.is_valid_course_id('abc')).to be(false)
+ end
+ end
+
+ describe 'is_valid_assignment_id' do
+ it 'returns true on positive integer input' do
+ expect(helper.is_valid_assignment_id(16)).to be(true)
+ end
+
+ it 'returns false on negative integer input' do
+ expect(helper.is_valid_assignment_id(-16)).to be(false)
+ end
+
+ it 'returns false on boolean input' do
+ expect(helper.is_valid_assignment_id(0.1)).to be(false)
+ end
+
+ it 'returns false on alphabetical input' do
+ expect(helper.is_valid_assignment_id('abc')).to be(false)
+ end
+ end
+
+ describe 'is_valid_student_id' do
+ it 'returns true on positive integer input' do
+ expect(helper.is_valid_student_id(16)).to be(true)
+ end
+
+ it 'returns false on negative integer input' do
+ expect(helper.is_valid_student_id(-16)).to be(false)
+ end
+
+ it 'returns false on boolean input' do
+ expect(helper.is_valid_student_id(0.1)).to be(false)
+ end
+
+ it 'returns false on alphabetical input' do
+ expect(helper.is_valid_student_id('abc')).to be(false)
+ end
+ end
+
+ describe 'is_valid_student_ids' do
+ it 'returns true on positive integral input' do
+ expect(helper.is_valid_student_ids([16, 18])).to be(true)
+ end
+
+ it 'returns false on negative integer input' do
+ expect(helper.is_valid_student_ids([-16, 16])).to be(false)
+ end
+
+ it 'returns false on boolean input' do
+ expect(helper.is_valid_student_ids([16, 0.1])).to be(false)
+ end
+
+ it 'returns false on alphabetical input' do
+ expect(helper.is_valid_student_ids(['abc', 16])).to be(false)
+ end
+ end
+ describe 'is_valid_title' do
+ it 'returns true on valid input' do
+ expect(helper.is_valid_title('hello world')).to be(true)
+ end
+
+ it 'returns false invalid characters' do
+ expect(helper.is_valid_title('**')).to be(false)
+ end
+
+ it 'returns false on too long input' do
+ expect(helper.is_valid_title('A' * 50)).to be(false)
+ end
+ end
+
+ describe 'is_valid_date' do
+ it 'returns true on properly formatted dates' do
+ expect(helper.is_valid_date('2002-03-16T12:00:00Z')).to be(true)
+ end
+
+ it 'returns false on inproperly formatted dates' do
+ expect(helper.is_valid_date('March 16th 2002')).to be(false)
+ end
+
+ it 'returns false on strings with too much text' do
+ expect(helper.is_valid_date('2002-03-16T12:00:00Z*')).to be(false)
+ end
+ end
+end
diff --git a/spec/controllers/api/v1/assignments_controller_spec.rb b/spec/controllers/api/v1/assignments_controller_spec.rb
new file mode 100644
index 0000000..fef0236
--- /dev/null
+++ b/spec/controllers/api/v1/assignments_controller_spec.rb
@@ -0,0 +1,112 @@
+require 'rails_helper'
+module Api
+ module V1
+ describe AssignmentsController do
+ def json_response
+ JSON.parse(response.body)
+ end
+
+ let(:mock_course) { Course.create!(course_name: "Test Course") }
+ let(:mock_lms) { Lms.create!(lms_name: "Test LMS") }
+ let(:mock_course_to_lms) { CourseToLms.create!(course_id: mock_course.id, lms_id: mock_lms.id) }
+
+ let(:valid_params) { { name: "Test Assignment", external_assignment_id: "123ABC", course_id: mock_course.id, lms_id: mock_lms.id } }
+
+ before do
+ mock_course
+ mock_lms
+ mock_course_to_lms
+ end
+
+ after do
+ LmsCredential.destroy_all
+ Extension.destroy_all
+ Assignment.destroy_all
+ CourseToLms.destroy_all
+ UserToCourse.destroy_all
+ Course.destroy_all
+ Lms.destroy_all
+ User.destroy_all
+ end
+
+ describe "GET /api/v1/courses/:course_id/lmss/:lms_id/assignments" do
+ it "throws a 501 error" do
+ get :index, params: { course_id: mock_course.id, lms_id: mock_lms.id }
+ expect(response).to have_http_status(501)
+ end
+ end
+
+ describe "POST /api/v1/courses/:course_id/lmss/:lms_id/assignments" do
+ context 'when one or more parameters are missing' do
+ it 'returns status :bad_request when name is missing' do
+ post :create, params: valid_params.except(:name)
+ expect(response).to have_http_status(:bad_request)
+ expect(json_response["error"]).to include('param is missing or the value is empty: name')
+ end
+ end
+
+ context 'when course_id or lms_id are not integers' do
+ it 'returns status :bad_request if course_id is not an integer' do
+ post :create, params: valid_params.merge(course_id: 'abc')
+ expect(response).to have_http_status(:bad_request)
+ expect(json_response["error"]).to include('course_id and lms_id must be integers')
+ end
+
+ it 'returns status :bad_request if lms_id is not an integer' do
+ post :create, params: valid_params.merge(lms_id: 'xyz')
+ expect(response).to have_http_status(:bad_request)
+ expect(json_response["error"]).to include('course_id and lms_id must be integers')
+ end
+ end
+
+ context 'save is unsuccessful' do
+ it 'returns status :unprocessable_entity' do
+ allow_any_instance_of(Assignment).to receive(:save).and_return(false)
+ post :create, params: valid_params
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+ end
+
+ context 'when valid parameters are provided' do
+ it 'creates a new assignment and returns status :created' do
+ post :create, params: valid_params
+ expect(response).to have_http_status(:created)
+ expect(json_response["name"]).to eq(valid_params[:name])
+ expect(json_response["external_assignment_id"]).to eq(valid_params[:external_assignment_id])
+ end
+ end
+
+ context 'when course_to_lms does not exist' do
+ it 'returns status :not_found' do
+ # Ensure this course_to_lms does not exist
+ selected_course = CourseToLms.find_by(course_id: 1, lms_id: 1)
+ selected_course.destroy if selected_course
+ post :create, params: { course_id: 1, lms_id: 1, name: "Test Assignment", external_assignment_id: "123ABC" }
+ expect(response).to have_http_status(:not_found)
+ expect(response.body).to include('No such Course_LMS association')
+ end
+ end
+
+ context 'when the assignment already exists' do
+ it 'returns status :ok' do
+ Assignment.create!(course_to_lms_id: mock_course_to_lms.id, name: "Test Assignment", external_assignment_id: "123ABC")
+ post :create, params: valid_params
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include('Record already exists')
+ end
+ end
+
+
+
+ end
+
+ describe "DELETE /api/v1/courses/:course_id/lmss/:lms_id/assignments/:id" do
+ let(:mock_assignment_id) { 1 }
+ it "throws a 501 error" do
+ delete :destroy, params: { course_id: mock_course.id, lms_id: mock_lms.id, id: mock_assignment_id }
+ expect(response).to have_http_status(501)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/controllers/api/v1/courses_controller_spec.rb b/spec/controllers/api/v1/courses_controller_spec.rb
new file mode 100644
index 0000000..6846b8b
--- /dev/null
+++ b/spec/controllers/api/v1/courses_controller_spec.rb
@@ -0,0 +1,81 @@
+require 'rails_helper'
+module Api
+ module V1
+ describe CoursesController do
+ describe 'POST #create' do
+ context "when the new course is successfully created" do
+ let(:course_name) { "New Course" }
+
+ it "creates and saves a new course" do
+ post :create, params: { course_name: course_name }
+
+ expect(response).to have_http_status(:created)
+ expect(Course.find_by(course_name: course_name)).to be_present
+ expect(flash[:success]).to eq("Course created successfully")
+ expect(JSON.parse(response.body)['course_name']).to eq('New Course')
+ end
+ end
+
+ context "when a course with the same name already exists" do
+ let!(:existing_course) { Course.create(course_name: "Existing Course") }
+
+ it "does not create a new course with the same name and returns an error" do
+ post :create, params: { course_name: existing_course.course_name }
+
+ expect(Course.find_by(course_name: existing_course.course_name)).to be_present
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(JSON.parse(response.body)).to eq({ "message" => "A course with the same course name already exists." })
+ end
+ end
+ end
+
+ describe 'index' do
+ it 'throws a 501 error' do
+ get :index
+ expect(response.status).to eq(501)
+ end
+ end
+
+ describe 'destroy' do
+ it 'throws a 501 error' do
+ delete :destroy, params: { id: 16 }
+ expect(response.status).to eq(501)
+ end
+ end
+
+ describe 'add_user' do
+ let(:test_course) { Course.create(course_name: "Test Course") }
+ let(:test_user) { User.create!(email: "testuniqueuser@example.com") }
+
+ context "Provided parameters are valid" do
+ it "adds an existing user to an existing course" do
+ post :add_user, params: { course_id: test_course.id, user_id: test_user.id, role: "ta" }
+ expect(response).to have_http_status(:created)
+ expect(flash["success"]).to eq("User added to the course successfully.")
+ end
+ end
+
+ context "Provided parameter are invalid" do
+ it "returns an error if course is not existed in the courses table" do
+ post :add_user, params: { course_id: 123456, user_id: test_user.id, role: "ta" }
+ expect(response).to have_http_status(:not_found)
+ expect(JSON.parse(response.body)["error"]).to eq("The course does not exist.")
+ end
+
+ it "returns an error if user is not existed in the users table" do
+ post :add_user, params: { course_id: test_course.id, user_id: 123456, role: "ta" }
+ expect(response).to have_http_status(:not_found)
+ expect(JSON.parse(response.body)["error"]).to eq("The user does not exist.")
+ end
+
+ it "returns an error if the user is already associated with the course" do
+ post :add_user, params: { course_id: test_course.id, user_id: test_user.id, role: "student" }
+ post :add_user, params: { course_id: test_course.id, user_id: test_user.id, role: "student" }
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(JSON.parse(response.body)["error"]).to eq("The user is already added to the course.")
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/controllers/api/v1/extensions_controller_spec.rb b/spec/controllers/api/v1/extensions_controller_spec.rb
new file mode 100644
index 0000000..ac54bc6
--- /dev/null
+++ b/spec/controllers/api/v1/extensions_controller_spec.rb
@@ -0,0 +1,139 @@
+require 'rails_helper'
+require 'byebug'
+module Api
+ module V1
+ describe ExtensionsController do
+ describe "POST /api/v1/courses/:course_id/lmss/:lms_id/assignments/:assignment_id/extensions" do
+ before(:all) do
+ load "#{Rails.root}/db/seeds.rb"
+ @course = Course.take
+ @assignment = Assignment.take
+ @extension = Extension.take
+ @lms = Lms.take
+ @course_to_lms = CourseToLms.find(@assignment.course_to_lms_id)
+ @mock_student_uid = 123
+ @mock_new_due_date = '2024-04-16T16:00:00Z'
+ @auth_token = 'some_valid_token'
+ @mock_assignments_url = "#{ENV['CANVAS_URL']}/api/v1/courses/#{@course_to_lms.external_course_id}/assignments"
+ @mock_override_url = "#{@mock_assignments_url}/#{@assignment.external_assignment_id}/overrides"
+ end
+
+ context "with valid parameters" do
+ it "creates a new extension and returns a success status" do
+ stub_request(:post, @mock_override_url).
+ with(
+ body: {
+ assignment_override: hash_including({
+ "due_at" => @mock_new_due_date,
+ "lock_at" => @mock_new_due_date,
+ "student_ids" => [@mock_student_uid.to_s],
+ "title" => "#{@mock_student_uid} extended to #{@mock_new_due_date}",
+ })
+ },
+ headers: { Authorization: "Bearer #{@auth_token}" }
+ ).to_return(
+ status: 200,
+ body: { due_at: @mock_new_due_date, id: "3333" }.to_json,
+ headers: {},
+ )
+ stub_request(:get, "#{@mock_assignments_url}/#{@assignment.external_assignment_id}")
+ .to_return(
+ status: 200,
+ body: { due_at: '2024-04-13T16:00:00Z' }.to_json,
+ headers: {},
+ )
+
+ request.headers.merge!({'Authorization': @auth_token})
+ post :create, params: {
+ course_id: @course.id,
+ lms_id: @lms.id,
+ assignment_id: @assignment.id,
+ student_uid: @mock_student_uid,
+ new_due_date: @mock_new_due_date,
+ }
+ expect(response).to have_http_status(:success)
+ end
+ end
+
+ context "with missing parameters" do
+ it "raises an error" do
+ stub_request(:post, @mock_override_url).
+ to_return(status: 400)
+
+ stub_request(:get, "#{@mock_assignments_url}/#{@assignment.external_assignment_id}")
+ .to_return(
+ status: 200,
+ body: { due_at: '2024-04-13T16:00:00Z' }.to_json,
+ headers: {},
+ )
+
+ expect { post(:create, params: {
+ course_id: @course.id,
+ lms_id: @lms.id,
+ assignment_id: @assignment.id,
+ }) }.to raise_error(FailedPipelineError)
+ end
+ end
+
+ context "when canvas returns 500" do
+ it "returns a 500 status" do
+ stub_request(:post, @mock_override_url).
+ to_return(
+ status: 500,
+ body: { errors: ["unknown student ids"] }.to_json,
+ )
+
+ stub_request(:get, "#{@mock_assignments_url}/#{@assignment.external_assignment_id}")
+ .to_return(
+ status: 200,
+ body: { due_at: '2024-04-13T16:00:00Z' }.to_json,
+ headers: {},
+ )
+
+ post :create, params: {
+ course_id: @course.id,
+ lms_id: @lms.id,
+ assignment_id: @assignment.id,
+ student_uid: 9999,
+ new_due_date: 7,
+ }
+ expect(response).to have_http_status(500)
+ end
+ end
+
+ context "get assignment api call fails" do
+ it "doesn't request a new extension and returns a server error" do
+ stub_request(:post, @mock_override_url).
+ with(
+ body: hash_including({
+ due_at: @mock_new_due_date,
+ lock_at: @mock_new_due_date,
+ student_ids: [@mock_student_uid.to_s],
+ title: "#{@mock_student_uid} extended to #{@mock_new_due_date}"
+ }),
+ headers: { Authorization: "Bearer #{@auth_token}" }
+ ).
+ to_return(
+ status: 200,
+ body: { due_at: @mock_new_due_date, id: "3333" }.to_json,
+ headers: {}
+ )
+
+ stub_request(:get, "#{@mock_assignments_url}/#{@assignment.external_assignment_id}")
+ .to_return(status: 404)
+
+ request.headers.merge!({ Authorization: @auth_token })
+ post :create, params: {
+ course_id: @course.id,
+ lms_id: @lms.id,
+ assignment_id: @assignment.id,
+ student_uid: @mock_student_uid,
+ new_due_date: @mock_new_due_date
+ }
+ expect(response).to have_http_status(:error)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/controllers/api/v1/lmss_controller_spec.rb b/spec/controllers/api/v1/lmss_controller_spec.rb
new file mode 100644
index 0000000..d174920
--- /dev/null
+++ b/spec/controllers/api/v1/lmss_controller_spec.rb
@@ -0,0 +1,112 @@
+require 'rails_helper'
+module Api
+ module V1
+ describe LmssController do
+ def json_response
+ JSON.parse(response.body)
+ end
+
+ before do
+ # Manually create a course and LMS in the database
+ @course = Course.create!(course_name: "Mock CS169 Course")
+ @lms = Lms.create!(lms_name: "Mock Canvas", use_auth_token: true)
+ @external_course_id = "mock_external_course_id"
+ end
+
+ after do
+ # Clean up the specifically created data
+ LmsCredential.destroy_all
+ Extension.destroy_all
+ Assignment.destroy_all
+ CourseToLms.destroy_all
+ UserToCourse.destroy_all
+ Course.destroy_all
+ Lms.destroy_all
+ User.destroy_all
+ end
+
+ describe 'POST #create' do
+
+ context 'when lms_id is missing' do
+ it 'returns status :bad_request' do
+ post :create, params: { course_id: @course.id, external_course_id: @external_course_id}
+ expect(response).to have_http_status(:bad_request)
+ expect(response.body).to include('param is missing or the value is empty: lms_id')
+ end
+ end
+
+ context 'when course_id and lms_id are not invalid' do
+ it 'returns status :bad_request' do
+ post :create, params: { course_id: '-1', lms_id: '-1', external_course_id: @external_course_id }
+ expect(response).to have_http_status(:bad_request)
+ expect(response.body).to include('Invalid course_id or lms_id')
+ end
+ end
+
+ context 'when valid parameters are provided' do
+ it 'creates a new course_to_lms association and returns status :created' do
+ post :create, params: { course_id: @course.id, lms_id: @lms.id, external_course_id: @external_course_id}
+ expect(response).to have_http_status(:created)
+ expect(json_response['course_id']).to eq(@course.id)
+ expect(json_response['lms_id']).to eq(@lms.id)
+ end
+ end
+ context 'when course_to_lms fails to save' do
+ it 'returns status :unprocessable_entity' do
+ allow_any_instance_of(CourseToLms).to receive(:save).and_return(false)
+ post :create, params: { course_id: @course.id, lms_id: @lms.id, external_course_id: @external_course_id}
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+ end
+
+ context 'when course does not exist' do
+ it 'returns status :not_found' do
+ # Ensure that the course does not exist
+ selected_course = Course.find_by(id: @course.id)
+ selected_course.destroy if selected_course
+ post :create, params: { course_id: @course.id, lms_id: @lms.id, external_course_id: @external_course_id}
+ expect(response).to have_http_status(:not_found)
+ expect(response.body).to include('Course not found')
+ end
+ end
+
+ context 'when lms does not exist' do
+ it 'returns status :not_found' do
+ # Ensure that the LMS does not exist
+ selected_lms = Lms.find_by(id: @lms.id)
+ selected_lms.destroy if selected_lms
+
+ post :create, params: { course_id: @course.id, lms_id: 1, external_course_id: @external_course_id}
+ expect(response).to have_http_status(:not_found)
+ expect(response.body).to include('Lms not found')
+ end
+ end
+
+ context 'when the association already exists' do
+ it 'returns status :ok' do
+ CourseToLms.create!(course_id: @course.id, lms_id: @lms.id, external_course_id: @external_course_id)
+ post :create, params: { course_id: @course.id, lms_id: @lms.id, external_course_id: @external_course_id}
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include('The association between the specified course and LMS already exists.')
+ end
+ end
+ end
+
+
+
+ describe 'index' do
+ it 'throws a 501 error' do
+ get :index, params: { course_id: :mock_course_id }
+ expect(response.status).to eq(501)
+ end
+ end
+
+ describe 'destroy' do
+ it 'throws a 501 error' do
+ delete :destroy, params: { course_id: :mock_course_id, id: 18 }
+ expect(response.status).to eq(501)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/controllers/api/v1/ping_controller_spec.rb b/spec/controllers/api/v1/ping_controller_spec.rb
new file mode 100644
index 0000000..2322952
--- /dev/null
+++ b/spec/controllers/api/v1/ping_controller_spec.rb
@@ -0,0 +1,16 @@
+require 'rails_helper'
+module Api
+ module V1
+ describe PingController do
+ it 'returns a successful response' do
+ get :ping
+ expect(response).to be_successful
+ end
+
+ it 'returns pong as json' do
+ get :ping
+ expect(response.body).to eq('"pong"')
+ end
+ end
+ end
+end
diff --git a/spec/controllers/api/v1/swagger_controller_spec.rb b/spec/controllers/api/v1/swagger_controller_spec.rb
new file mode 100644
index 0000000..a71b180
--- /dev/null
+++ b/spec/controllers/api/v1/swagger_controller_spec.rb
@@ -0,0 +1,16 @@
+require 'rails_helper'
+module Api
+ module V1
+ describe SwaggerController do
+ let(:fileContents) { '{}' }
+ before :each do
+ allow(Rails).to receive(:root).and_return('')
+ allow(File).to receive(:read).with('app/assets/swagger/swagger.json').and_return(fileContents)
+ end
+ it 'returns the file content' do
+ get :read
+ expect(response.body).to eq(fileContents)
+ end
+ end
+ end
+end
diff --git a/spec/controllers/api/v1/users_controller_spec.rb b/spec/controllers/api/v1/users_controller_spec.rb
new file mode 100644
index 0000000..4f911ca
--- /dev/null
+++ b/spec/controllers/api/v1/users_controller_spec.rb
@@ -0,0 +1,62 @@
+require 'rails_helper'
+module Api
+ module V1
+ describe UsersController do
+ describe 'POST #create' do
+ context 'when creating a new user' do
+ it 'creates the user successfully' do
+ post :create, params: { email: 'test@example.com' }
+
+ expect(response).to have_http_status(:created)
+ expect(JSON.parse(response.body)['message']).to eq('User created successfully')
+ expect(User.exists?(email: 'test@example.com')).to be_truthy
+ end
+ end
+
+ context 'when user with the same email already exists' do
+ before do
+ User.create(email: 'existing@example.com')
+ end
+
+ it 'returns an error message' do
+ post :create, params: { email: 'existing@example.com' }
+
+ expect(response).to have_http_status(:conflict)
+ expect(JSON.parse(response.body)['message']).to eq('A user with this email already exists.')
+ end
+ end
+
+ context 'when email is missing or invalid' do
+ it 'returns an error when email is missing' do
+ post :create, params: { email: '' }
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(JSON.parse(response.body)['message']).to eq('Failed to create user')
+ end
+
+ it 'returns an error when email is invalid' do
+ # Assuming you add email format validation
+ post :create, params: { email: 'invalid-email' }
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(JSON.parse(response.body)['message']).to eq('Failed to create user')
+ end
+ end
+ end
+
+ describe 'index' do
+ it 'throws a 501 error' do
+ get :index
+ expect(response.status).to eq(501)
+ end
+ end
+
+ describe 'destroy' do
+ it 'throws a 501 error' do
+ delete :destroy, params: { id: 1 }
+ expect(response.status).to eq(501)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/controllers/bcourses_controller_spec.rb b/spec/controllers/bcourses_controller_spec.rb
new file mode 100644
index 0000000..39f9c58
--- /dev/null
+++ b/spec/controllers/bcourses_controller_spec.rb
@@ -0,0 +1,70 @@
+require 'rails_helper'
+require 'lms_api'
+
+RSpec.describe "Bcourses", type: :request do
+ describe 'GET /index' do
+ let(:canvas_api_key) { 'test_api_key' }
+ let(:api_mock) { instance_double('LMS::Canvas') }
+ let(:courses) { [{'name' => 'Course 1'}, {'name' => 'Course 2'}] }
+
+ before do
+
+ # Stub to return the URL
+ allow(Rails.application.credentials.canvas).to receive(:url).and_return("https://ucberkeley.test.instructure.com")
+ # Stub to return the API key
+ allow(Rails.application.credentials.canvas).to receive(:api_key).and_return('test_api_key')
+ allow(LMS::Canvas).to receive(:new).and_return(api_mock)
+ allow(api_mock).to receive(:api_get_request).and_return(courses)
+ end
+
+ context 'when API call is successful' do
+ it 'renders the index template with courses' do
+ get bcourses_path
+ expect(response).to be_successful
+ # puts response.body
+ expect(response.body).to include('Course 1')
+ expect(response.body).to include('Course 2')
+ end
+ end
+
+ context 'when token is expired' do
+ let(:error_message) { "Token expired and needs refresh" }
+ before do
+ allow(api_mock).to receive(:api_get_request).and_raise(LMS::Canvas::RefreshTokenRequired, 'Refresh token required')
+ end
+
+ it 'renders the index template with a token refresh error message' do
+ get bcourses_path
+ expect(response).to be_successful
+ expect(response.body).to include(error_message)
+ end
+ end
+
+ context 'when there is a network connection error' do
+ let(:error_message) { "Network connection error" }
+ before do
+ allow(api_mock).to receive(:api_get_request).and_raise(SocketError, 'Connection refused')
+ end
+
+ it 'renders the index template with a network error message' do
+ get bcourses_path
+ expect(response).to be_successful
+ expect(response.body).to include(error_message)
+ end
+ end
+
+ context 'when API call fails due to a StandardError (Unexpected Error)' do
+ let(:error_message) { "An unexpected error occurred: Error" }
+ before do
+ allow(api_mock).to receive(:api_get_request).and_raise(StandardError, 'Error')
+ end
+
+ it 'renders the index template with an error message' do
+ get bcourses_path
+ expect(response).to be_successful
+ expect(response.body).to include(error_message)
+ end
+ end
+
+ end
+end
diff --git a/spec/models/lms_credential_spec.rb b/spec/models/lms_credential_spec.rb
new file mode 100644
index 0000000..1bd8059
--- /dev/null
+++ b/spec/models/lms_credential_spec.rb
@@ -0,0 +1,53 @@
+# spec/models/lms_credential_spec.rb
+require 'rails_helper'
+
+class MockCanvas
+ # Simulate authentication using a token and refresh token
+ def self.authenticate(token, refresh_token)
+ token == 'sensitive_token' && refresh_token == 'sensitive_refresh_token'
+ end
+
+ # Simulate retrieving a service, returning 'service_object' if credentials are valid
+ def self.mock_get_service(token, refresh_token)
+ authenticate(token, refresh_token) ? 'service_object' : nil
+ end
+end
+
+
+RSpec.describe LmsCredential, type: :model do
+ describe 'Token Encryption' do
+ let(:user) { User.create!(email: 'test@example.com') }
+ let!(:credential) do
+ LmsCredential.create!(
+ user: user,
+ lms_name: 'ExampleLMS',
+ username: 'testuser',
+ password: 'testpassword',
+ token: 'sensitive_token',
+ refresh_token: 'sensitive_refresh_token'
+ )
+ end
+
+ it 'encrypts the token and refresh_token' do
+ raw_token = ActiveRecord::Base.connection.execute(
+ "SELECT token FROM lms_credentials WHERE id = #{credential.id}"
+ ).first['token']
+ raw_refresh_token = ActiveRecord::Base.connection.execute(
+ "SELECT refresh_token FROM lms_credentials WHERE id = #{credential.id}"
+ ).first['refresh_token']
+
+ expect(raw_token).not_to eq 'sensitive_token'
+ expect(raw_refresh_token).not_to eq 'sensitive_refresh_token'
+ expect(credential.token).to eq 'sensitive_token'
+ expect(credential.refresh_token).to eq 'sensitive_refresh_token'
+ end
+
+ it 'decrypts the token and refresh_token for use' do
+ expect(credential.token).to eq('sensitive_token')
+ expect(credential.refresh_token).to eq('sensitive_refresh_token')
+
+ # Simulate a call to get a service object
+ expect(MockCanvas.mock_get_service(credential.token, credential.refresh_token)).to eq('service_object')
+ end
+ end
+end
diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb
new file mode 100644
index 0000000..a15455f
--- /dev/null
+++ b/spec/rails_helper.rb
@@ -0,0 +1,65 @@
+# This file is copied to spec/ when you run 'rails generate rspec:install'
+require 'spec_helper'
+ENV['RAILS_ENV'] ||= 'test'
+require_relative '../config/environment'
+# Prevent database truncation if the environment is production
+abort("The Rails environment is running in production mode!") if Rails.env.production?
+require 'rspec/rails'
+# Add additional requires below this line. Rails is not loaded until this point!
+
+# 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
+# in _spec.rb will both be required and run as specs, causing the specs to be
+# run twice. It is recommended that you do not name files matching this glob to
+# end with _spec.rb. You can configure this pattern with the --pattern
+# option on the command line or in ~/.rspec, .rspec or `.rspec-local`.
+#
+# The following line is provided for convenience purposes. It has the downside
+# of increasing the boot-up time by auto-requiring all files in the support
+# directory. Alternatively, in the individual `*_spec.rb` files, manually
+# require only the support files necessary.
+#
+# Rails.root.glob('spec/support/**/*.rb').sort.each { |f| require f }
+
+# Checks for pending migrations and applies them before tests are run.
+# If you are not using ActiveRecord, you can remove these lines.
+begin
+ ActiveRecord::Migration.maintain_test_schema!
+rescue ActiveRecord::PendingMigrationError => e
+ abort e.to_s.strip
+end
+RSpec.configure do |config|
+ # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures
+ config.fixture_paths = [
+ 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.
+ config.use_transactional_fixtures = true
+
+ # You can uncomment this line to turn off ActiveRecord support entirely.
+ # config.use_active_record = false
+
+ # RSpec Rails can automatically mix in different behaviours to your tests
+ # based on their file location, for example enabling you to call `get` and
+ # `post` in specs under `spec/controllers`.
+ #
+ # You can disable this behaviour by removing the line below, and instead
+ # explicitly tag your specs with their type, e.g.:
+ #
+ # RSpec.describe UsersController, type: :controller do
+ # # ...
+ # end
+ #
+ # The different available types are documented in the features, such as in
+ # https://rspec.info/features/6-0/rspec-rails
+ config.infer_spec_type_from_file_location!
+
+ # Filter lines from Rails gems in backtraces.
+ config.filter_rails_from_backtrace!
+ # arbitrary gems may also be filtered via:
+ # config.filter_gems_from_backtrace("gem name")
+end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
new file mode 100644
index 0000000..5864934
--- /dev/null
+++ b/spec/spec_helper.rb
@@ -0,0 +1,105 @@
+# This file was generated by the `rails generate rspec:install` command. Conventionally, all
+# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
+# The generated `.rspec` file contains `--require spec_helper` which will cause
+# this file to always be loaded, without a need to explicitly require it in any
+# files.
+#
+# Given that it is always loaded, you are encouraged to keep this file as
+# light-weight as possible. Requiring heavyweight dependencies from this file
+# will add to the boot time of your test suite on EVERY test run, even for an
+# individual file that may not need all of that loaded. Instead, consider making
+# a separate helper file that requires the additional dependencies and performs
+# the additional setup, and require it from the spec files that actually need
+# it.
+#
+require 'webmock/rspec'
+# See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
+
+require 'codeclimate-test-reporter'
+require 'simplecov'
+
+SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter[
+ SimpleCov::Formatter::HTMLFormatter,
+ CodeClimate::TestReporter::Formatter
+]
+SimpleCov.start 'rails'
+
+RSpec.configure do |config|
+ # rspec-expectations config goes here. You can use an alternate
+ # assertion/expectation library such as wrong or the stdlib/minitest
+ # assertions if you prefer.
+ config.expect_with :rspec do |expectations|
+ # This option will default to `true` in RSpec 4. It makes the `description`
+ # and `failure_message` of custom matchers include text for helper methods
+ # defined using `chain`, e.g.:
+ # be_bigger_than(2).and_smaller_than(4).description
+ # # => "be bigger than 2 and smaller than 4"
+ # ...rather than:
+ # # => "be bigger than 2"
+ expectations.include_chain_clauses_in_custom_matcher_descriptions = true
+ end
+
+ # rspec-mocks config goes here. You can use an alternate test double
+ # library (such as bogus or mocha) by changing the `mock_with` option here.
+ config.mock_with :rspec do |mocks|
+ # Prevents you from mocking or stubbing a method that does not exist on
+ # a real object. This is generally recommended, and will default to
+ # `true` in RSpec 4.
+ mocks.verify_partial_doubles = true
+ end
+
+ # This option will default to `:apply_to_host_groups` in RSpec 4 (and will
+ # have no way to turn it off -- the option exists only for backwards
+ # compatibility in RSpec 3). It causes shared context metadata to be
+ # inherited by the metadata hash of host groups and examples, rather than
+ # triggering implicit auto-inclusion in groups with matching metadata.
+ config.shared_context_metadata_behavior = :apply_to_host_groups
+
+# The settings below are suggested to provide a good initial experience
+# with RSpec, but feel free to customize to your heart's content.
+=begin
+ # This allows you to limit a spec run to individual examples or groups
+ # you care about by tagging them with `:focus` metadata. When nothing
+ # 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
+
+ # Allows RSpec to persist some state between runs in order to support
+ # the `--only-failures` and `--next-failure` CLI options. We recommend
+ # you configure your source control system to ignore this file.
+ config.example_status_persistence_file_path = "spec/examples.txt"
+
+ # Limits the available syntax to the non-monkey patched syntax that is
+ # recommended. For more details, see:
+ # https://rspec.info/features/3-12/rspec-core/configuration/zero-monkey-patching-mode/
+ config.disable_monkey_patching!
+
+ # Many RSpec users commonly either run the entire suite or an individual
+ # file, and it's useful to allow more verbose output when running an
+ # individual spec file.
+ if config.files_to_run.one?
+ # Use the documentation formatter for detailed output,
+ # unless a formatter has already been configured
+ # (e.g. via a command-line flag).
+ config.default_formatter = "doc"
+ end
+
+ # Print the 10 slowest examples and example groups at the
+ # end of the spec run, to help surface which specs are running
+ # particularly slow.
+ config.profile_examples = 10
+
+ # Run specs in random order to surface order dependencies. If you find an
+ # order dependency and want to debug it, you can fix the order by providing
+ # the seed, which is printed after each run.
+ # --seed 1234
+ config.order = :random
+
+ # Seed global randomization in this process using the `--seed` CLI option.
+ # Setting this allows you to use `--seed` to deterministically reproduce
+ # test failures related to randomization by passing the same `--seed` value
+ # as the one that triggered the failure.
+ Kernel.srand config.seed
+=end
+end
diff --git a/storage/.keep b/storage/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/tmp/.keep b/tmp/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/vendor/.keep b/vendor/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/vendor/javascript/.keep b/vendor/javascript/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/yarn.lock b/yarn.lock
new file mode 100644
index 0000000..fb57ccd
--- /dev/null
+++ b/yarn.lock
@@ -0,0 +1,4 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+