diff --git a/.ebextensions/01_setup.config b/.ebextensions/01_setup.config index 36545dbe..7a7173b2 100644 --- a/.ebextensions/01_setup.config +++ b/.ebextensions/01_setup.config @@ -1,4 +1,7 @@ packages: dnf: - ImageMagick: [] - postgresql17-devel: [] + ImageMagick: [] +commands: + 01_remove_pg15_install_pg17: + command: "dnf remove -y postgresql15 postgresql15-private-libs; dnf install -y postgresql17 libpq-devel --allowerasing" + ignoreErrors: false diff --git a/.ebextensions/04_cloudwatch_agent.config b/.ebextensions/04_cloudwatch_agent.config new file mode 100644 index 00000000..5c34bda5 --- /dev/null +++ b/.ebextensions/04_cloudwatch_agent.config @@ -0,0 +1,113 @@ +files: + "/tmp/amazon-cloudwatch-agent.json": + mode: "000644" + owner: root + group: root + content: | + { + "agent": { + "metrics_collection_interval": 60, + "run_as_user": "root" + }, + "metrics": { + "namespace": "CWAgent", + "append_dimensions": { + "AutoScalingGroupName": "${aws:AutoScalingGroupName}", + "ImageId": "${aws:ImageId}", + "InstanceId": "${aws:InstanceId}", + "InstanceType": "${aws:InstanceType}" + }, + "aggregation_dimensions": [ + ["InstanceId"], + [] + ], + "metrics_collected": { + "cpu": { + "measurement": [ + "cpu_usage_idle", + "cpu_usage_system", + "cpu_usage_user", + "cpu_usage_iowait" + ], + "totalcpu": true, + "resources": [ + "*" + ], + "metrics_collection_interval": 60 + }, + "mem": { + "measurement": [ + "mem_used_percent", + "mem_available" + ], + "metrics_collection_interval": 120 + }, + "disk": { + "measurement": [ + "disk_used_percent", + "disk_used", + "disk_free", + "disk_total", + "inodes_free", + "inodes_used" + ], + "resources": [ + "/", + "/data" + ], + "drop_device": true, + "metrics_collection_interval": 120 + }, + "diskio": { + "measurement": [ + "write_bytes", + "read_bytes", + "writes", + "reads" + ], + "resources": [ + "nvme[0-9]n1", + "xvd*", + "sd*" + ], + "metrics_collection_interval": 180 + }, + "net": { + "measurement": [ + "bytes_sent", + "bytes_recv", + "packets_sent", + "packets_recv", + "err_in", + "err_out" + ], + "resources": [ + "eth0", + "ens*" + ], + "metrics_collection_interval": 180 + }, + "netstat": { + "measurement": [ + "tcp_established", + "tcp_syn_sent", + "tcp_time_wait" + ], + "metrics_collection_interval": 300 + }, + "processes": { + "measurement": [ + "running", + "blocked", + "sleeping", + "zombies" + ], + "metrics_collection_interval": 300 + } + } + } + } + +container_commands: + 01_apply_cwa_config: + command: "/opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl -a fetch-config -m ec2 -c file:/tmp/amazon-cloudwatch-agent.json -s" diff --git a/.env.example b/.env.example index 8104c6a5..0fe7a7ee 100644 --- a/.env.example +++ b/.env.example @@ -21,8 +21,8 @@ CANVAS_URL='https://ucberkeleysandbox.instructure.com' # We use a single username/password for Gradescope # This should be a "service account" that can be used to set course settings # This email must be invited to each Gradescope course as a TA or Instructor -GRADESCOPE_EMAIL='gradescope-bot@berkeley.edu' -GRADESCOPE_PASSWORD= +GRADESCOPE_EMAIL='michael@gradescope.com' +GRADESCOPE_PASSWORD=KoH-z92-oVJ-yqr # This is required to be set. DEFAULT_FROM_EMAIL='flextensions@berkeley.edu' # Generally recommended / best practices diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 452ebb34..d39d746a 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -3,5 +3,5 @@ updates: - package-ecosystem: bundler directory: "/" schedule: - interval: daily - open-pull-requests-limit: 10 + interval: weekly + open-pull-requests-limit: 5 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000..c5901037 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,66 @@ +name: Docs Build + +on: + push: + paths: + - 'docs/**' + pull_request: + paths: + - 'docs/**' + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: docs + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3' + bundler-cache: true + working-directory: docs + + - name: Build Jekyll site + run: bundle exec jekyll build --baseurl /flextensions + env: + JEKYLL_ENV: production + PAGES_REPO_NWO: berkeley-cds/flextensions + + - name: Check for broken internal links + run: | + # Verify all internal .html files were generated + echo "Generated site files:" + find _site -name '*.html' | sort + echo "" + echo "Checking for broken internal links..." + # Extract internal href links and verify they resolve + broken=0 + for file in $(find _site -name '*.html'); do + # Extract href values pointing to /flextensions/ paths + grep -oP 'href="(/flextensions/[^"]*)"' "$file" 2>/dev/null | while read -r match; do + path=$(echo "$match" | grep -oP '"/flextensions/[^"]*"' | tr -d '"') + # Convert URL path to file path + local_path="_site${path#/flextensions}" + # Check if it's a directory (index.html) or file + if [ -d "$local_path" ] && [ -f "$local_path/index.html" ]; then + continue + elif [ -f "$local_path" ]; then + continue + elif [ -f "${local_path}.html" ]; then + continue + elif [ -f "${local_path%/}/index.html" ]; then + continue + else + echo "::warning file=$file::Broken link: $path (resolved to $local_path)" + broken=1 + fi + done + done + if [ "$broken" -eq 1 ]; then + echo "::warning::Some internal links may be broken. Check warnings above." + fi diff --git a/.gitignore b/.gitignore index 11276b17..b8b4a5cd 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,10 @@ capybara-*.html *.orig rerun.txt pickle-email-*.html +# All Heroku dumps. +*.dump +# GenAI stuff +.claude # Ignore all logfiles and tempfiles. /log/* @@ -77,3 +81,9 @@ yarn-debug.log* /config/credentials/production.key .DS_Store + +# Jekyll docs +docs/_site/ +docs/.jekyll-cache/ +docs/.jekyll-metadata +docs/Gemfile.lock diff --git a/.rubocop.yml b/.rubocop.yml index 44cc669a..36b42c1a 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -95,6 +95,11 @@ RSpec: RSpec/ExampleLength: Max: 40 +# Default 3 +# I'm not sure this is good? +RSpec/NestedGroups: + Max: 5 + RSpec/MultipleMemoizedHelpers: Max: 20 diff --git a/.tool-versions b/.tool-versions index 057186bf..053cba7f 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -ruby 3.3.8 +ruby 3.3 diff --git a/Gemfile b/Gemfile index 876db786..3dc4ebb2 100644 --- a/Gemfile +++ b/Gemfile @@ -60,6 +60,9 @@ gem 'strong_migrations' # Logging Customization gem 'lograge' +# Environment variable management +gem 'dotenv-rails', require: 'dotenv/load' + # Use Active Storage for file uploads [https://guides.rubyonrails.org/active_storage_overview.html] # gem "activestorage", "~> 7.0.0" @@ -81,7 +84,7 @@ gem 'importmap-rails' # Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev] gem 'turbo-rails' -gem 'bootstrap', '~> 5.3.3' +gem 'bootstrap', '~> 5.3.8' # dependency for bootstrap # 03-10-2025 this is deprecated but still works gem 'sassc-rails', '~> 2.1' # alternative to sassc-rails, this is recommended but bootstrap 5.3.3 is still using "deprecated" @import statements which this gem doesn't like diff --git a/Gemfile.lock b/Gemfile.lock index c1f7d829..5541fc50 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -74,23 +74,23 @@ GEM minitest (>= 5.1) securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) - annotaterb (4.20.0) + addressable (2.8.9) + public_suffix (>= 2.0.2, < 8.0) + annotaterb (4.22.0) activerecord (>= 6.0.0) activesupport (>= 6.0.0) ast (2.4.3) - axe-core-api (4.11.0) + axe-core-api (4.11.1) dumb_delegator ostruct virtus - axe-core-cucumber (4.11.0) - axe-core-api (= 4.11.0) + axe-core-cucumber (4.11.1) + axe-core-api (= 4.11.1) dumb_delegator ostruct virtus - axe-core-rspec (4.11.0) - axe-core-api (= 4.11.0) + axe-core-rspec (4.11.1) + axe-core-api (= 4.11.1) dumb_delegator ostruct virtus @@ -108,11 +108,11 @@ GEM csv railties (>= 7.1) safely_block (>= 0.4) - bootsnap (1.20.1) + bootsnap (1.23.0) msgpack (~> 1.2) - bootstrap (5.3.5) + bootstrap (5.3.8) popper_js (>= 2.11.8, < 3) - brakeman (7.1.2) + brakeman (8.0.4) racc builder (3.3.0) capybara (3.40.0) @@ -124,11 +124,11 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) - capybara-screenshot (1.0.26) + capybara-screenshot (1.0.27) capybara (>= 1.0, < 4) launchy cgi (0.5.1) - chartkick (5.2.0) + chartkick (5.2.1) childprocess (5.1.0) logger (~> 1.5) codeclimate-test-reporter (1.0.7) @@ -143,35 +143,35 @@ GEM rexml crass (1.0.6) csv (3.3.5) - cucumber (10.1.0) + cucumber (10.2.0) base64 (~> 0.2) builder (~> 3.2) - cucumber-ci-environment (> 9, < 11) + cucumber-ci-environment (> 9, < 12) cucumber-core (> 15, < 17) - cucumber-cucumber-expressions (> 17, < 19) - cucumber-html-formatter (> 20.3, < 22) + cucumber-cucumber-expressions (> 17, < 20) + cucumber-html-formatter (> 21, < 23) diff-lcs (~> 1.5) logger (~> 1.6) mini_mime (~> 1.1) multi_test (~> 1.1) sys-uname (~> 1.3) - cucumber-ci-environment (10.0.1) - cucumber-core (15.2.1) - cucumber-gherkin (> 27, < 33) - cucumber-messages (> 26, < 30) - cucumber-tag-expressions (> 5, < 7) - cucumber-cucumber-expressions (18.0.1) + cucumber-ci-environment (11.0.0) + cucumber-core (16.2.0) + cucumber-gherkin (> 36, < 40) + cucumber-messages (> 31, < 33) + cucumber-tag-expressions (> 6, < 9) + cucumber-cucumber-expressions (19.0.0) bigdecimal - cucumber-gherkin (32.2.0) - cucumber-messages (> 25, < 28) - cucumber-html-formatter (21.14.0) - cucumber-messages (> 19, < 28) - cucumber-messages (27.2.0) - cucumber-rails (4.0.0) + cucumber-gherkin (39.0.0) + cucumber-messages (>= 31, < 33) + cucumber-html-formatter (22.3.0) + cucumber-messages (> 23, < 33) + cucumber-messages (32.2.0) + cucumber-rails (4.0.1) capybara (>= 3.25, < 4) cucumber (>= 7, < 11) railties (>= 6.1, < 9) - cucumber-tag-expressions (6.1.2) + cucumber-tag-expressions (8.1.0) database_cleaner-active_record (2.2.2) activerecord (>= 5.a) database_cleaner-core (~> 2.0) @@ -185,16 +185,20 @@ GEM diff-lcs (1.6.2) docile (1.4.1) domain_name (0.6.20240107) + dotenv (3.2.0) + dotenv-rails (3.2.0) + dotenv (= 3.2.0) + railties (>= 6.1) drb (2.2.3) dumb_delegator (1.1.0) - erb (6.0.1) + erb (6.0.2) erubi (1.13.1) - factory_bot (6.5.5) + factory_bot (6.5.6) activesupport (>= 6.1.0) factory_bot_rails (6.5.1) factory_bot (~> 6.5) railties (>= 6.1.0) - faraday (2.14.0) + faraday (2.14.1) faraday-net_http (>= 2.0, < 3.5) json logger @@ -203,27 +207,27 @@ GEM http-cookie (>= 1.0.0) faraday-net_http (3.4.2) net-http (~> 0.5) - ffi (1.17.2) - ffi (1.17.2-aarch64-linux-gnu) - ffi (1.17.2-arm-linux-gnu) - ffi (1.17.2-arm64-darwin) - ffi (1.17.2-x86-linux-gnu) - ffi (1.17.2-x86-mingw32) - ffi (1.17.2-x86_64-darwin) - ffi (1.17.2-x86_64-linux-gnu) + ffi (1.17.3) + ffi (1.17.3-aarch64-linux-gnu) + ffi (1.17.3-arm-linux-gnu) + ffi (1.17.3-arm64-darwin) + ffi (1.17.3-x86-linux-gnu) + ffi (1.17.3-x86-mingw32) + ffi (1.17.3-x86_64-darwin) + ffi (1.17.3-x86_64-linux-gnu) font-awesome-sass (6.7.2) sassc (~> 2.0) - formatador (1.1.0) - globalid (1.2.1) + formatador (1.2.3) + reline + globalid (1.3.0) activesupport (>= 6.1) - guard (2.19.1) + guard (2.20.1) formatador (>= 0.2.4) listen (>= 2.7, < 4.0) logger (~> 1.6) lumberjack (>= 1.0.12, < 2.0) nenv (~> 0.1) notiffany (~> 0.0) - ostruct (~> 0.6) pry (>= 0.13.0) shellany (~> 0.0) thor (>= 0.18.1) @@ -233,10 +237,11 @@ GEM guard-compat (~> 1.1) rspec (>= 2.99.0, < 4.0) hashdiff (1.2.1) - hashie (5.0.0) + hashie (5.1.0) + logger http-cookie (1.1.0) domain_name (~> 0.5) - httparty (0.24.0) + httparty (0.24.2) csv mini_mime (>= 1.0.0) multi_xml (>= 0.5.2) @@ -250,14 +255,18 @@ GEM activesupport (>= 6.0.0) railties (>= 6.0.0) io-console (0.8.2) - irb (1.16.0) + irb (1.17.0) pp (>= 0.6.0) + prism (>= 1.3.0) rdoc (>= 4.0.0) reline (>= 0.4.2) jbuilder (2.14.1) actionview (>= 7.0.0) activesupport (>= 7.0.0) - json (2.18.0) + json (2.19.1) + json-schema (6.2.0) + addressable (~> 2.8) + bigdecimal (>= 3.1, < 5) jwt (3.1.2) base64 language_server-protocol (3.17.0.5) @@ -273,7 +282,8 @@ GEM railties (>= 6.1) rexml lint_roller (1.1.0) - listen (3.9.0) + listen (3.10.0) + logger rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) lms-api (1.26.0) @@ -285,31 +295,35 @@ GEM activesupport (>= 4) railties (>= 4) request_store (~> 1.0) - loofah (2.25.0) + loofah (2.25.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) - lumberjack (1.2.10) - mail (2.8.1) + lumberjack (1.4.2) + mail (2.9.0) + logger mini_mime (>= 0.1.1) net-imap net-pop net-smtp - marcel (1.0.4) + marcel (1.1.0) matrix (0.4.3) + mcp (0.8.0) + json-schema (>= 4.1) memoist3 (1.0.0) method_source (1.1.0) mini_mime (1.1.5) mini_portile2 (2.8.9) - minitest (6.0.1) + minitest (6.0.2) + drb (~> 2.0) prism (~> 1.5) msgpack (1.8.0) multi_test (1.1.0) multi_xml (0.8.1) bigdecimal (>= 3.1, < 5) nenv (0.3.0) - net-http (0.8.0) + net-http (0.9.1) uri (>= 0.11.1) - net-imap (0.5.9) + net-imap (0.6.3) date net-protocol net-pop (0.1.2) @@ -318,19 +332,19 @@ GEM timeout net-smtp (0.5.1) net-protocol - nio4r (2.7.4) - nokogiri (1.19.0) + nio4r (2.7.5) + nokogiri (1.19.1) mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.19.0-aarch64-linux-gnu) + nokogiri (1.19.1-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.0-arm-linux-gnu) + nokogiri (1.19.1-arm-linux-gnu) racc (~> 1.4) - nokogiri (1.19.0-arm64-darwin) + nokogiri (1.19.1-arm64-darwin) racc (~> 1.4) - nokogiri (1.19.0-x86_64-darwin) + nokogiri (1.19.1-x86_64-darwin) racc (~> 1.4) - nokogiri (1.19.0-x86_64-linux-gnu) + nokogiri (1.19.1-x86_64-linux-gnu) racc (~> 1.4) notiffany (0.1.3) nenv (~> 0.1) @@ -356,31 +370,31 @@ GEM omniauth (~> 2.0) ostruct (0.6.3) parallel (1.27.0) - parser (3.3.10.0) + parser (3.3.10.2) ast (~> 2.4.1) racc - pg (1.6.2) - pg (1.6.2-aarch64-linux) - pg (1.6.2-arm64-darwin) - pg (1.6.2-x86-mingw32) - pg (1.6.2-x86_64-darwin) - pg (1.6.2-x86_64-linux) + pg (1.6.3) + pg (1.6.3-aarch64-linux) + pg (1.6.3-arm64-darwin) + pg (1.6.3-x86_64-darwin) + pg (1.6.3-x86_64-linux) popper_js (2.11.8) pp (0.6.3) prettyprint prettyprint (0.2.0) - prism (1.8.0) - pry (0.15.2) + prism (1.9.0) + pry (0.16.0) coderay (~> 1.1) method_source (~> 1.0) + reline (>= 0.6.0) psych (5.3.1) date stringio - public_suffix (6.0.2) - puma (7.1.0) + public_suffix (7.0.5) + puma (7.2.0) nio4r (~> 2.0) racc (1.8.1) - rack (3.2.4) + rack (3.2.5) rack-protection (4.2.1) base64 (>= 0.1.0) logger (>= 1.6.0) @@ -417,8 +431,8 @@ GEM activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.6.2) - loofah (~> 2.21) + rails-html-sanitizer (1.7.0) + loofah (~> 2.25) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) railties (7.2.3) actionpack (= 7.2.3) @@ -435,7 +449,7 @@ GEM rb-fsevent (0.11.2) rb-inotify (0.11.1) ffi (~> 1.0) - rdoc (7.1.0) + rdoc (7.2.0) erb psych (>= 4.0.0) tsort @@ -445,58 +459,59 @@ GEM request_store (1.7.0) rack (>= 1.4) rexml (3.4.4) - rspec (3.13.1) + rspec (3.13.2) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) rspec-mocks (~> 3.13.0) - rspec-core (3.13.5) + rspec-core (3.13.6) rspec-support (~> 3.13.0) rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-mocks (3.13.5) + rspec-mocks (3.13.8) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-rails (8.0.2) + rspec-rails (8.0.4) actionpack (>= 7.2) activesupport (>= 7.2) railties (>= 7.2) - rspec-core (~> 3.13) - rspec-expectations (~> 3.13) - rspec-mocks (~> 3.13) - rspec-support (~> 3.13) + rspec-core (>= 3.13.0, < 5.0.0) + rspec-expectations (>= 3.13.0, < 5.0.0) + rspec-mocks (>= 3.13.0, < 5.0.0) + rspec-support (>= 3.13.0, < 5.0.0) rspec-retry (0.6.2) rspec-core (> 3.3) - rspec-support (3.13.5) - rubocop (1.82.1) + rspec-support (3.13.7) + rubocop (1.85.1) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) + mcp (~> 0.6) parallel (~> 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.48.0, < 2.0) + rubocop-ast (>= 1.49.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.48.0) + rubocop-ast (1.49.1) parser (>= 3.3.7.2) - prism (~> 1.4) + prism (~> 1.7) rubocop-performance (1.26.1) lint_roller (~> 1.1) rubocop (>= 1.75.0, < 2.0) rubocop-ast (>= 1.47.1, < 2.0) - rubocop-rails (2.30.3) + rubocop-rails (2.34.3) activesupport (>= 4.2.0) lint_roller (~> 1.1) rack (>= 1.1) - rubocop (>= 1.72.1, < 2.0) - rubocop-ast (>= 1.38.0, < 2.0) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.44.0, < 2.0) rubocop-rails-omakase (1.1.0) rubocop (>= 1.72) rubocop-performance (>= 1.24) rubocop-rails (>= 2.30) - rubocop-rspec (3.8.0) + rubocop-rspec (3.9.0) lint_roller (~> 1.1) rubocop (~> 1.81) ruby-progressbar (1.13.0) @@ -511,18 +526,19 @@ GEM sprockets-rails tilt securerandom (0.4.1) - selenium-webdriver (4.40.0) + selenium-webdriver (4.41.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 4.0) websocket (~> 1.0) - sentry-rails (6.2.0) + sentry-rails (6.5.0) railties (>= 5.2.0) - sentry-ruby (~> 6.2.0) - sentry-ruby (6.2.0) + sentry-ruby (~> 6.5.0) + sentry-ruby (6.5.0) bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) + logger shellany (0.0.1) shoulda-matchers (7.0.1) activesupport (>= 7.1) @@ -548,21 +564,25 @@ GEM stringio (3.2.0) strong_migrations (2.5.2) activerecord (>= 7.1) - sys-uname (1.4.1) + sys-uname (1.5.1) + ffi (~> 1.1) + memoist3 (~> 1.0.0) + sys-uname (1.5.1-universal-mingw32) ffi (~> 1.1) memoist3 (~> 1.0.0) + win32ole thor (1.5.0) thread_safe (0.3.6) - tilt (2.6.0) + tilt (2.7.0) timecop (0.9.10) - timeout (0.6.0) + timeout (0.6.1) tsort (0.2.0) - turbo-rails (2.0.21) + turbo-rails (2.0.23) actionpack (>= 7.1.0) railties (>= 7.1.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - tzinfo-data (1.2025.1) + tzinfo-data (1.2026.1) tzinfo (>= 1.0.0) unicode-display_width (3.2.0) unicode-emoji (~> 4.1) @@ -588,9 +608,10 @@ GEM base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) + win32ole (1.9.3) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.7.4) + zeitwerk (2.7.5) PLATFORMS aarch64-linux @@ -608,13 +629,14 @@ DEPENDENCIES axe-core-rspec blazer bootsnap - bootstrap (~> 5.3.3) + bootstrap (~> 5.3.8) brakeman capybara-screenshot codeclimate-test-reporter cucumber-rails database_cleaner-active_record debug + dotenv-rails factory_bot_rails faraday faraday-cookie_jar diff --git a/Procfile.dev b/Procfile.dev index f6a95730..9c5377e0 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1 +1 @@ -web: bundle exec rails s -p 3000 +web: bundle exec rails s -p ${PORT:-3000} diff --git a/README.md b/README.md index 8fea880d..dd6b54ab 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [](https://github.com/berkeley-cdss/flextensions/actions/workflows/rspec.yml) • [](https://github.com/berkeley-cdss/flextensions/actions/workflows/cucumber.yml) • [](https://github.com/berkeley-cdss/flextensions/actions/workflows/a11y.yml) • -[](https://github.com/berkeley-cdss/flextensions/actions/workflows/rubocop.yml) +[](https://github.com/berkeley-cdss/flextensions/actions/workflows/rubocop.yml) • [](https://doi.org/10.5281/zenodo.17246291) --- @@ -74,3 +74,17 @@ Please see `.env.example` for the environment variables that need to be set up f ## Canvas Scoped Keys This deserves brief special mention. You must keep the Canvas API configuration (in Canvas) in sync with the list of scopes defined in the CanvasFacade. If you need to add a new scope, you will need to update the Canvas API configuration in the Canvas Developer Keys section **and will need to coordinate with the bCourses team to ensure the new scope is approved and turned on before deploying it to production**. + +## Citing Flextensions + +Cite the software itself using the following DOI: + +https://doi.org/10.5281/zenodo.17246291 + +``` +# IEEE +[1]M. Ball, “Flextensions”. Zenodo, Aug. 20, 2025. doi: 10.5281/zenodo.17246291. +# APA +Ball, M., Fox, A., Yan, L., huanger2, Yaman Tarakji, Tashrique Ahmed, Connor, Jerry, Cynthia Xinyi Li, Tianye Meng, Peter Tran, Dana Kim, andypumpkineater, Evan Kandell, dg-ucb, Sepehr Behmanesh, felder, & Zee Babar. (2025). Flextensions. Zenodo. +https://doi.org/10.5281/zenodo.17246291 +``` diff --git a/app/controllers/api/v1/assignments_controller.rb b/app/controllers/api/v1/assignments_controller.rb index 552bcd83..932e6bc3 100644 --- a/app/controllers/api/v1/assignments_controller.rb +++ b/app/controllers/api/v1/assignments_controller.rb @@ -31,7 +31,7 @@ def create if assignment.save render json: assignment, status: :created else - render json: assignment.errors, status: :unprocessable_entity + render json: assignment.errors, status: :unprocessable_content end end diff --git a/app/controllers/api/v1/courses_controller.rb b/app/controllers/api/v1/courses_controller.rb index 68174abc..6a40f029 100644 --- a/app/controllers/api/v1/courses_controller.rb +++ b/app/controllers/api/v1/courses_controller.rb @@ -10,7 +10,7 @@ def index def create course_name = params[:course_name] if Course.exists?(course_name: course_name) - render json: { message: 'A course with the same course name already exists.' }, status: :unprocessable_entity + render json: { message: 'A course with the same course name already exists.' }, status: :unprocessable_content return end @@ -45,7 +45,7 @@ def add_user # 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 + render json: { error: 'The user is already added to the course.' }, status: :unprocessable_content return end @@ -65,7 +65,7 @@ def render_response(object, success_message, error_message) render json: object, status: :created else flash[:error] = error_message - render json: { error: object.errors.full_messages }, status: :unprocessable_entity + render json: { error: object.errors.full_messages }, status: :unprocessable_content end end end diff --git a/app/controllers/api/v1/lmss_controller.rb b/app/controllers/api/v1/lmss_controller.rb index 251f522d..bc14fb50 100644 --- a/app/controllers/api/v1/lmss_controller.rb +++ b/app/controllers/api/v1/lmss_controller.rb @@ -40,7 +40,7 @@ def create if course_to_lms.save render json: course_to_lms, status: :created else - render json: course_to_lms.errors, status: :unprocessable_entity + render json: course_to_lms.errors, status: :unprocessable_content end end diff --git a/app/controllers/api/v1/users_controller.rb b/app/controllers/api/v1/users_controller.rb index 3eaf5b75..f0cac694 100644 --- a/app/controllers/api/v1/users_controller.rb +++ b/app/controllers/api/v1/users_controller.rb @@ -24,7 +24,7 @@ def create 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 + status: :unprocessable_content end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 35b8768c..d216113f 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -21,7 +21,11 @@ def excluded_controller_action? # TODO: Refactor all auth methods helper_method :current_user def current_user - @current_user ||= User.find_by(canvas_uid: session[:user_id]) + if defined?(@current_user) + @current_user + else + @current_user = User.find_by(canvas_uid: session[:user_id]) + end # TODO: Remove this line after refactoring all auth methods, # and remove other instances of @user in controllers + views @user ||= @current_user @@ -70,7 +74,7 @@ def handle_lms_api_error(error) # Truncate to 1K characters so we are well short of cookie limits. error_message = error.message.truncate(1000) flash[:alert] = "An error occurred while communicating with the LMS. Please reach out to flextension@berkeley.edu if you continue to have trouble. Error: #{error_message}" - redirect_back(fallback_location: root_path) + redirect_back_or_to(root_path) end def set_pending_request_count diff --git a/app/controllers/assignments_controller.rb b/app/controllers/assignments_controller.rb index f7285c3f..1655b05d 100644 --- a/app/controllers/assignments_controller.rb +++ b/app/controllers/assignments_controller.rb @@ -14,7 +14,7 @@ def toggle_enabled render json: { success: true }, status: :ok else flash[:alert] = "Failed to update assignment: #{@assignment.errors.full_messages.to_sentence}" - render json: { redirect_to: course_path(course) }, status: :unprocessable_entity + render json: { redirect_to: course_path(course) }, status: :unprocessable_content end end end diff --git a/app/controllers/course_settings_controller.rb b/app/controllers/course_settings_controller.rb index 81930dbb..4a2cfe07 100644 --- a/app/controllers/course_settings_controller.rb +++ b/app/controllers/course_settings_controller.rb @@ -66,6 +66,7 @@ def course_settings_params :max_auto_approve, :enable_gradescope, :gradescope_course_url, + :extend_late_due_date, :enable_emails, :reply_email, :email_subject, diff --git a/app/controllers/courses_controller.rb b/app/controllers/courses_controller.rb index 1404eda5..59102c27 100644 --- a/app/controllers/courses_controller.rb +++ b/app/controllers/courses_controller.rb @@ -78,6 +78,7 @@ def enrollments return redirect_to courses_path, alert: 'You do not have access to this page.' unless @role == 'instructor' @enrollments = @course.user_to_courses.includes(:user) + @is_course_admin = @course.user_to_courses.find_by(user: @user)&.course_admin? end def delete diff --git a/app/controllers/session_controller.rb b/app/controllers/session_controller.rb index 230bbd63..eeac56a9 100644 --- a/app/controllers/session_controller.rb +++ b/app/controllers/session_controller.rb @@ -35,6 +35,7 @@ def logout def omniauth_callback if params[:error].present? + Rails.logger.error("OmniAuth callback error: #{params[:error_description] || params[:error]}") redirect_to root_path, alert: 'Authentication failed. Please try again.' return end @@ -52,15 +53,25 @@ def omniauth_callback 'email' => auth.info.email } creds = auth.credentials # an OmniAuth::AuthHash + + # dev provider doesnt have real credentials so its stubbed + expires_at = creds.expires_at || 30.days.from_now.to_i + refresh_token = creds.refresh_token || 'none' + access_token = OAuth2::AccessToken.new( OAuth2::Client.new('', ''), # client never used – stub creds.token, - refresh_token: creds.refresh_token, - expires_at: creds.expires_at + refresh_token: refresh_token, + expires_at: expires_at ) # Persist / update the user just like `create` - find_or_create_user(user_data, access_token) + user = find_or_create_user(user_data, access_token) + + # Auto-enroll developer login users in test courses + if auth.provider == 'developer' + ensure_developer_test_enrollments(user) + end redirect_to courses_path, notice: "Logged in! Welcome, #{user_data['name']}!" rescue StandardError => e @@ -78,6 +89,18 @@ def destroy private + def ensure_developer_test_enrollments(user) + # Find the test course + test_course = Course.find_by(course_code: 'DEV101') + + # Ensure enrollment in the test course (as student so they can request extensions) + if test_course + UserToCourse.find_or_create_by!(user_id: user.id, course_id: test_course.id) do |utc| + utc.role = 'student' + end + end + end + # TODO: Refactor. def find_or_create_user(user_data, auth_token) auth_token.token @@ -101,6 +124,8 @@ def find_or_create_user(user_data, auth_token) # Store user ID in session for authentication session[:username] = user.name session[:user_id] = user.canvas_uid + + user end # TODO: Move this to a Canvas API libarary or user service diff --git a/app/controllers/user_to_courses_controller.rb b/app/controllers/user_to_courses_controller.rb index 70451135..b07d7336 100644 --- a/app/controllers/user_to_courses_controller.rb +++ b/app/controllers/user_to_courses_controller.rb @@ -1,21 +1,25 @@ class UserToCoursesController < ApplicationController before_action :authenticate_user before_action :set_course + before_action :ensure_course_admin def toggle_allow_extended_requests @enrollment = @course.user_to_courses.find(params[:id]) - unless @role == 'instructor' - Rails.logger.error "Role #{@role} does not have permission to toggle allow_extended_requests" - flash.now[:alert] = 'You do not have permission to perform this action.' - return render json: { redirect_to: course_path(@course) }, status: :forbidden - end - if @enrollment.update(allow_extended_requests: params[:allow_extended_requests]) render json: { success: true }, status: :ok else flash[:alert] = "Failed to update enrollment: #{@enrollment.errors.full_messages.to_sentence}" - render json: { redirect_to: course_path(@course) }, status: :unprocessable_entity + render json: { redirect_to: course_path(@course) }, status: :unprocessable_content end end + + private + + def ensure_course_admin + enrollment = @course.user_to_courses.find_by(user: @user) + return if enrollment&.course_admin? + + render json: { error: 'You must be an instructor or Lead TA.', redirect_to: course_path(@course) }, status: :forbidden + end end diff --git a/app/facades/canvas_facade.rb b/app/facades/canvas_facade.rb index 34d58840..72dc891f 100644 --- a/app/facades/canvas_facade.rb +++ b/app/facades/canvas_facade.rb @@ -313,10 +313,11 @@ def delete_assignment_override(courseId, assignmentId, overrideId) # @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. + # @param [String] new_close_date the close date for submissions (optional, nil means no close date set). # @return [Lmss::Canvas::Override] 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(course_id, student_id, assignment_id, new_due_date) + def provision_extension(course_id, student_id, assignment_id, new_due_date, new_close_date = nil) # get existing_overrides for an assignment student_override = get_existing_student_override(course_id, student_id, assignment_id) if !student_override.nil? @@ -326,7 +327,7 @@ def provision_extension(course_id, student_id, assignment_id, new_due_date) # create new override override_title = "#{student_id} extended to #{new_due_date}" create_response = create_assignment_override( - course_id, assignment_id, [ student_id ], override_title, new_due_date, get_current_formatted_time, new_due_date + course_id, assignment_id, [ student_id ], override_title, new_due_date, get_current_formatted_time, new_close_date ) decoded_response = parse_create_response(create_response) @@ -337,7 +338,7 @@ def provision_extension(course_id, student_id, assignment_id, new_due_date) curr_override = fetch_existing_override(course_id, student_id, assignment_id) handle_response = handle_override_logic( course_id, curr_override, student_id, assignment_id, override_title, - new_due_date + new_due_date, new_close_date ) Lmss::Canvas::Override.new(parse_create_response(handle_response)) end @@ -367,7 +368,7 @@ def get_existing_student_override(course_id, student_id, assignment_id) end all_assignment_overrides.each do |override| - return override if override&.student_ids.map(&:to_i)&.include?(student_id.to_i) + return override if override&.student_ids&.map(&:to_i)&.include?(student_id.to_i) end nil end @@ -458,17 +459,18 @@ def fetch_existing_override(courseId, studentId, assignmentId) # @param [Integer] assignmentId the assignmentId to handle the override logic for. # @param [String] overrideTitle the title of the override. # @param [String] newDueDate the new due date for the override. + # @param [String] newCloseDate the close date for the override (maps to lock_at in Canvas API). # @return [Faraday::Response] the response from updating or creating the override. - def handle_override_logic(courseId, curr_override, studentId, assignmentId, overrideTitle, newDueDate) + def handle_override_logic(courseId, curr_override, studentId, assignmentId, overrideTitle, newDueDate, newCloseDate) if curr_override.student_ids.length == 1 update_assignment_override( courseId, assignmentId, curr_override.id, curr_override.student_ids, overrideTitle, newDueDate, - get_current_formatted_time, newDueDate + get_current_formatted_time, newCloseDate ) else remove_student_from_override(courseId, curr_override, studentId) create_assignment_override( - courseId, assignmentId, [ studentId ], overrideTitle, newDueDate, get_current_formatted_time, newDueDate + courseId, assignmentId, [ studentId ], overrideTitle, newDueDate, get_current_formatted_time, newCloseDate ) end end diff --git a/app/facades/gradescope_facade.rb b/app/facades/gradescope_facade.rb index 5f1601c3..4a342dfb 100644 --- a/app/facades/gradescope_facade.rb +++ b/app/facades/gradescope_facade.rb @@ -87,8 +87,9 @@ def get_existing_student_override(course_id, assignment_id, student_id) # @param [String] email of student to provision the extension for. # @param [String] assignmentId the assignment the extension should be provisioned for. # @param [String] newDueDate the date the assignment should be due. + # @param [String] newLateDueDate the late due date (optional, nil means no late due date set). # @return [Lmss::Gradescope::BaseExtension] the extension that was provisioned. - def provision_extension(course_id, student_email, assignment_id, new_due_date) + def provision_extension(course_id, student_email, assignment_id, new_due_date, new_late_due_date = nil) ensure_authenticated! begin # get extension page @@ -123,10 +124,13 @@ def provision_extension(course_id, student_email, assignment_id, new_due_date) 'type' => 'absolute', 'value' => new_due_date } - request_payload['override']['settings']['hard_due_date'] = { - 'type' => 'absolute', - 'value' => new_due_date - } + # Only set hard_due_date (late due date) if explicitly provided + if new_late_due_date + request_payload['override']['settings']['hard_due_date'] = { + 'type' => 'absolute', + 'value' => new_late_due_date + } + end end @gradescope_conn.post( diff --git a/app/javascript/controllers/enrollments_controller.js b/app/javascript/controllers/enrollments_controller.js index f64631dd..cd03aecb 100644 --- a/app/javascript/controllers/enrollments_controller.js +++ b/app/javascript/controllers/enrollments_controller.js @@ -8,10 +8,6 @@ export default class extends Controller { static values = { courseId: Number } connect() { - this.checkboxTargets.forEach((checkbox) => { - checkbox.addEventListener("change", (event) => this.toggleExtended(event, checkbox)) - }) - if (!DataTable.isDataTable('#enrollments-table')) { // Define a custom sorting function for the Role column DataTable.ext.type.order['role-pre'] = function (data) { @@ -30,20 +26,16 @@ export default class extends Controller { responsive: true, pageLength: 500, lengthMenu: [[-1, 25, 50, 100, 500], ["All", 25, 50, 100, 500]], - columns: [ - null, // Name - null, // Email - null, // Section - { orderDataType: 'role-pre' }, // Role column (custom sort) - null, // Extended Requests? - ], + columns: document.querySelectorAll('#enrollments-table thead th').length === 5 + ? [null, null, null, { orderDataType: 'role-pre' }, null] + : [null, null, null, { orderDataType: 'role-pre' }], order: [[3, 'des'], [0, 'asc']] // Sort Role first, then Name }); } } - async toggleExtended(event, checkbox) { - const enrollmentId = checkbox.dataset.enrollmentId; + async toggleExtended(event) { + const checkbox = event.currentTarget; const url = checkbox.dataset.url; const allowExtended = checkbox.checked; @@ -71,13 +63,19 @@ export default class extends Controller { throw new Error(data.error || 'Error updating enrollment'); } - console.log(`Enrollment ${enrollmentId} allow_extended_requests: ${allowExtended}`); + const td = checkbox.closest('td'); + if (td) td.dataset.order = allowExtended ? '1' : '0'; + this._dispatchFlash('notice', `Extended requests ${allowExtended ? 'enabled' : 'disabled'}.`); } catch (error) { console.error("Error updating enrollment:", error); checkbox.checked = !allowExtended; } } + _dispatchFlash(type, message) { + window.dispatchEvent(new CustomEvent('flash', { detail: { type: type, message: message } })); + } + sync() { const button = event.currentTarget; button.disabled = true; diff --git a/app/javascript/controllers/flash_controller.js b/app/javascript/controllers/flash_controller.js index af218793..6a62e338 100644 --- a/app/javascript/controllers/flash_controller.js +++ b/app/javascript/controllers/flash_controller.js @@ -29,16 +29,27 @@ export default class extends Controller { : type === "alert" ? "alert-danger" : "alert-info" - // wrap in a full-width row if you want - const wrapper = document.createElement("div") - wrapper.className = "col-12" - wrapper.innerHTML = ` -
<%= ENV.fetch('GRADESCOPE_EMAIL') { 'gradescope-bot@berkeley.edu' } %>
+ as a TA to your Gradescope course for integration to work.
+
+
+