From 6022821d1c89b0067a855ba52cbcc472e6c43afe Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Tue, 12 May 2026 16:31:30 -0300 Subject: [PATCH 001/125] first commit: rails basic structure + rspec + cucumber + capybara + simplecov all seted (Gemfile + generates) --- .dockerignore | 55 ++ .gitattributes | 9 + .github/dependabot.yml | 12 + .github/workflows/ci.yml | 97 +++ .gitignore | 38 + .rspec | 1 + .rubocop.yml | 8 + .ruby-version | 1 + Dockerfile | 77 ++ Gemfile | 73 ++ Gemfile.lock | 754 ++++++++++++++++++ Rakefile | 6 + app/assets/images/.keep | 0 app/assets/stylesheets/application.css | 10 + app/controllers/application_controller.rb | 7 + app/controllers/concerns/.keep | 0 app/helpers/application_helper.rb | 2 + app/javascript/application.js | 3 + app/javascript/controllers/application.js | 9 + .../controllers/hello_controller.js | 7 + app/javascript/controllers/index.js | 4 + app/jobs/application_job.rb | 7 + app/mailers/application_mailer.rb | 4 + app/models/application_record.rb | 3 + app/models/concerns/.keep | 0 app/views/layouts/application.html.erb | 29 + app/views/layouts/mailer.html.erb | 13 + app/views/layouts/mailer.text.erb | 1 + app/views/pwa/manifest.json.erb | 22 + app/views/pwa/service-worker.js | 26 + bin/brakeman | 7 + bin/bundler-audit | 6 + bin/ci | 6 + bin/cucumber | 11 + bin/dev | 2 + bin/docker-entrypoint | 8 + bin/importmap | 4 + bin/jobs | 6 + bin/kamal | 16 + bin/rails | 4 + bin/rake | 4 + bin/rubocop | 8 + bin/setup | 35 + bin/thrust | 5 + config.ru | 6 + config/application.rb | 27 + config/boot.rb | 4 + config/bundler-audit.yml | 5 + config/cable.yml | 17 + config/cache.yml | 16 + config/ci.rb | 25 + config/credentials.yml.enc | 1 + config/cucumber.yml | 8 + config/database.yml | 40 + config/deploy.yml | 119 +++ config/environment.rb | 5 + config/environments/development.rb | 82 ++ config/environments/production.rb | 90 +++ config/environments/test.rb | 57 ++ config/importmap.rb | 7 + config/initializers/assets.rb | 7 + .../initializers/content_security_policy.rb | 29 + .../initializers/filter_parameter_logging.rb | 8 + config/initializers/inflections.rb | 16 + config/locales/en.yml | 31 + config/puma.rb | 42 + config/queue.yml | 18 + config/recurring.yml | 15 + config/routes.rb | 14 + config/storage.yml | 27 + db/cable_schema.rb | 11 + db/cache_schema.rb | 12 + db/queue_schema.rb | 129 +++ db/schema.rb | 14 + db/seeds.rb | 9 + features/step_definitions/.keep | 0 features/support/env.rb | 58 ++ lib/tasks/.keep | 0 lib/tasks/cucumber.rake | 68 ++ log/.keep | 0 public/400.html | 135 ++++ public/404.html | 135 ++++ public/406-unsupported-browser.html | 135 ++++ public/422.html | 135 ++++ public/500.html | 135 ++++ public/icon.png | Bin 0 -> 4166 bytes public/icon.svg | 3 + public/robots.txt | 1 + script/.keep | 0 spec/rails_helper.rb | 72 ++ spec/spec_helper.rb | 99 +++ storage/.keep | 0 test/controllers/.keep | 0 test/fixtures/files/.keep | 0 test/helpers/.keep | 0 test/integration/.keep | 0 test/mailers/.keep | 0 test/models/.keep | 0 test/test_helper.rb | 15 + tmp/.keep | 0 tmp/pids/.keep | 0 tmp/storage/.keep | 0 vendor/.keep | 0 vendor/javascript/.keep | 0 104 files changed, 3282 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitattributes create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .rspec create mode 100644 .rubocop.yml create mode 100644 .ruby-version create mode 100644 Dockerfile create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 Rakefile create mode 100644 app/assets/images/.keep create mode 100644 app/assets/stylesheets/application.css create mode 100644 app/controllers/application_controller.rb create mode 100644 app/controllers/concerns/.keep create mode 100644 app/helpers/application_helper.rb create mode 100644 app/javascript/application.js create mode 100644 app/javascript/controllers/application.js create mode 100644 app/javascript/controllers/hello_controller.js create mode 100644 app/javascript/controllers/index.js create mode 100644 app/jobs/application_job.rb create mode 100644 app/mailers/application_mailer.rb create mode 100644 app/models/application_record.rb create mode 100644 app/models/concerns/.keep create mode 100644 app/views/layouts/application.html.erb create mode 100644 app/views/layouts/mailer.html.erb create mode 100644 app/views/layouts/mailer.text.erb create mode 100644 app/views/pwa/manifest.json.erb create mode 100644 app/views/pwa/service-worker.js create mode 100755 bin/brakeman create mode 100755 bin/bundler-audit create mode 100755 bin/ci create mode 100755 bin/cucumber create mode 100755 bin/dev create mode 100755 bin/docker-entrypoint create mode 100755 bin/importmap create mode 100755 bin/jobs create mode 100755 bin/kamal create mode 100755 bin/rails create mode 100755 bin/rake create mode 100755 bin/rubocop create mode 100755 bin/setup create mode 100755 bin/thrust create mode 100644 config.ru create mode 100644 config/application.rb create mode 100644 config/boot.rb create mode 100644 config/bundler-audit.yml create mode 100644 config/cable.yml create mode 100644 config/cache.yml create mode 100644 config/ci.rb create mode 100644 config/credentials.yml.enc create mode 100644 config/cucumber.yml create mode 100644 config/database.yml create mode 100644 config/deploy.yml create mode 100644 config/environment.rb create mode 100644 config/environments/development.rb create mode 100644 config/environments/production.rb create mode 100644 config/environments/test.rb create mode 100644 config/importmap.rb create mode 100644 config/initializers/assets.rb create mode 100644 config/initializers/content_security_policy.rb create mode 100644 config/initializers/filter_parameter_logging.rb create mode 100644 config/initializers/inflections.rb create mode 100644 config/locales/en.yml create mode 100644 config/puma.rb create mode 100644 config/queue.yml create mode 100644 config/recurring.yml create mode 100644 config/routes.rb create mode 100644 config/storage.yml create mode 100644 db/cable_schema.rb create mode 100644 db/cache_schema.rb create mode 100644 db/queue_schema.rb create mode 100644 db/schema.rb create mode 100644 db/seeds.rb create mode 100644 features/step_definitions/.keep create mode 100644 features/support/env.rb create mode 100644 lib/tasks/.keep create mode 100644 lib/tasks/cucumber.rake create mode 100644 log/.keep create mode 100644 public/400.html create mode 100644 public/404.html create mode 100644 public/406-unsupported-browser.html create mode 100644 public/422.html create mode 100644 public/500.html create mode 100644 public/icon.png create mode 100644 public/icon.svg create mode 100644 public/robots.txt create mode 100644 script/.keep create mode 100644 spec/rails_helper.rb create mode 100644 spec/spec_helper.rb create mode 100644 storage/.keep create mode 100644 test/controllers/.keep create mode 100644 test/fixtures/files/.keep create mode 100644 test/helpers/.keep create mode 100644 test/integration/.keep create mode 100644 test/mailers/.keep create mode 100644 test/models/.keep create mode 100644 test/test_helper.rb create mode 100644 tmp/.keep create mode 100644 tmp/pids/.keep create mode 100644 tmp/storage/.keep create mode 100644 vendor/.keep create mode 100644 vendor/javascript/.keep diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..f934afb0da --- /dev/null +++ b/.dockerignore @@ -0,0 +1,55 @@ +# See https://docs.docker.com/engine/reference/builder/#dockerignore-file for more about ignoring files. + +# Ignore git directory. +/.git/ +/.gitignore + +# Ignore bundler config. +/.bundle + +# Ignore all environment files. +/.env* + +# Ignore all default key files. +/config/master.key +/config/credentials/*.key + +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep + +# Ignore pidfiles, but keep the directory. +/tmp/pids/* +!/tmp/pids/.keep + +# Ignore storage (uploaded files in development and any SQLite databases). +/storage/* +!/storage/.keep +/tmp/storage/* +!/tmp/storage/.keep + +# Ignore assets. +/node_modules/ +/app/assets/builds/* +!/app/assets/builds/.keep +/public/assets + +# Ignore CI service files. +/.github + +# Ignore Kamal files. +/config/deploy*.yml +/.kamal + +# Ignore development files +/.devcontainer + +# Ignore Docker-related files +/.dockerignore +/Dockerfile* + +/.ruby-lsp/ +classes.json +class_members.json \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..8dc4323435 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# See https://git-scm.com/docs/gitattributes for more about git attribute files. + +# Mark the database schema as having been generated. +db/schema.rb linguist-generated + +# Mark any vendored files as having been vendored. +vendor/* linguist-vendored +config/credentials/*.yml.enc diff=rails_credentials +config/credentials.yml.enc diff=rails_credentials diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..83610cfa4c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: +- package-ecosystem: bundler + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 10 +- package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 10 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000..178bb45aee --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,97 @@ +name: CI + +on: + pull_request: + push: + branches: [ main ] + +jobs: + scan_ruby: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Scan for common Rails security vulnerabilities using static analysis + run: bin/brakeman --no-pager + + - name: Scan for known security vulnerabilities in gems used + run: bin/bundler-audit + + scan_js: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Scan for security vulnerabilities in JavaScript dependencies + run: bin/importmap audit + + lint: + runs-on: ubuntu-latest + env: + RUBOCOP_CACHE_ROOT: tmp/rubocop + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Prepare RuboCop cache + uses: actions/cache@v4 + env: + DEPENDENCIES_HASH: ${{ hashFiles('.ruby-version', '**/.rubocop.yml', '**/.rubocop_todo.yml', 'Gemfile.lock') }} + with: + path: ${{ env.RUBOCOP_CACHE_ROOT }} + key: rubocop-${{ runner.os }}-${{ env.DEPENDENCIES_HASH }}-${{ github.ref_name == github.event.repository.default_branch && github.run_id || 'default' }} + restore-keys: | + rubocop-${{ runner.os }}-${{ env.DEPENDENCIES_HASH }}- + + - name: Lint code for consistent style + run: bin/rubocop -f github + + test: + runs-on: ubuntu-latest + + # services: + # redis: + # image: valkey/valkey:8 + # ports: + # - 6379:6379 + # options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 + steps: + - name: Install packages + run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libvips + + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Run tests + env: + RAILS_ENV: test + # RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }} + # REDIS_URL: redis://localhost:6379/0 + run: | + bin/rails db:test:prepare + bundle exec rspec + bundle exec cucumber diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..bd7a0da827 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# See https://help.github.com/articles/ignoring-files for more about ignoring files. +# +# Temporary files generated by your text editor or operating system +# belong in git's global ignore instead: +# `$XDG_CONFIG_HOME/git/ignore` or `~/.config/git/ignore` + +# Ignore bundler config. +/.bundle + +# Ignore all environment files. +/.env* + +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep + +# Ignore pidfiles, but keep the directory. +/tmp/pids/* +!/tmp/pids/ +!/tmp/pids/.keep + +# Ignore storage (uploaded files in development and any SQLite databases). +/storage/* +!/storage/.keep +/tmp/storage/* +!/tmp/storage/ +!/tmp/storage/.keep + +/public/assets + +/.kamal/ +/config/credentials/*.key +/config/*.key + +/coverage/ +/doc/ \ No newline at end of file diff --git a/.rspec b/.rspec new file mode 100644 index 0000000000..c99d2e7396 --- /dev/null +++ b/.rspec @@ -0,0 +1 @@ +--require spec_helper diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000000..f9d86d4a54 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,8 @@ +# Omakase Ruby styling for Rails +inherit_gem: { rubocop-rails-omakase: rubocop.yml } + +# Overwrite or add rules to create your own house style +# +# # Use `[a, [b, c]]` not `[ a, [ b, c ] ]` +# Layout/SpaceInsideArrayLiteralBrackets: +# Enabled: false diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000000..61a52c9149 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +ruby-3.4.9 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000..57caf12d9f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,77 @@ +# syntax=docker/dockerfile:1 +# check=error=true + +# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand: +# docker build -t camaar . +# docker run -d -p 80:80 -e RAILS_MASTER_KEY= --name camaar camaar + +# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html + +# Make sure RUBY_VERSION matches the Ruby version in .ruby-version +ARG RUBY_VERSION=3.4.9 +FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base + +# Rails app lives here +WORKDIR /rails + +# Install base packages +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \ + ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Set production environment variables and enable jemalloc for reduced memory usage and latency. +ENV RAILS_ENV="production" \ + BUNDLE_DEPLOYMENT="1" \ + BUNDLE_PATH="/usr/local/bundle" \ + BUNDLE_WITHOUT="development" \ + LD_PRELOAD="/usr/local/lib/libjemalloc.so" + +# 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 libyaml-dev pkg-config && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Install application gems +COPY vendor/* ./vendor/ +COPY Gemfile Gemfile.lock ./ + +RUN bundle install && \ + rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \ + # -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495 + bundle exec bootsnap precompile -j 1 --gemfile + +# Copy application code +COPY . . + +# Precompile bootsnap code for faster boot times. +# -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495 +RUN bundle exec bootsnap precompile -j 1 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 + +# Run and own only the runtime files as a non-root user for security +RUN groupadd --system --gid 1000 rails && \ + useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash +USER 1000:1000 + +# Copy built artifacts: gems, application +COPY --chown=rails:rails --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" +COPY --chown=rails:rails --from=build /rails /rails + +# Entrypoint prepares the database. +ENTRYPOINT ["/rails/bin/docker-entrypoint"] + +# Start server via Thruster by default, this can be overwritten at runtime +EXPOSE 80 +CMD ["./bin/thrust", "./bin/rails", "server"] diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000000..a7cf68ece5 --- /dev/null +++ b/Gemfile @@ -0,0 +1,73 @@ +source "https://rubygems.org" + +# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" +gem "rails", "~> 8.1.3" +# The modern asset pipeline for Rails [https://github.com/rails/propshaft] +gem "propshaft" +# Use sqlite3 as the database for Active Record +gem "sqlite3", ">= 2.1" +# 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 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 ] + +# Use the database-backed adapters for Rails.cache, Active Job, and Action Cable +gem "solid_cache" +gem "solid_queue" +gem "solid_cable" + +# Reduces boot times through caching; required in config/boot.rb +gem "bootsnap", require: false + +# Deploy this application anywhere as a Docker container [https://kamal-deploy.org] +gem "kamal", require: false + +# Add HTTP asset caching/compression and X-Sendfile acceleration to Puma [https://github.com/basecamp/thruster/] +gem "thruster", require: false + +# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] +gem "image_processing", "~> 1.2" + +group :development, :test do + # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem + gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" + + # Audits gems for known security defects (use config/bundler-audit.yml to ignore issues) + gem "bundler-audit", require: false + + # Static analysis for security vulnerabilities [https://brakemanscanner.org/] + gem "brakeman", require: false + + # Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/] + gem "rubocop-rails-omakase", require: false + + gem "rspec-rails", require: false +end + +group :development do + # Use console on exceptions pages [https://github.com/rails/web-console] + gem "web-console" + gem "rubycritic", require: false + gem "rdoc", require: false +end + +group :test do + # Use system testing [https://guides.rubyonrails.org/testing.html#system-testing] + gem "capybara" + gem "selenium-webdriver" + gem "cucumber-rails", require: false + gem "simplecov", require: false + gem "database_cleaner-active_record" +end diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000000..71e9211154 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,754 @@ +GEM + remote: https://rubygems.org/ + specs: + action_text-trix (2.1.19) + railties + actioncable (8.1.3) + actionpack (= 8.1.3) + activesupport (= 8.1.3) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + zeitwerk (~> 2.6) + actionmailbox (8.1.3) + actionpack (= 8.1.3) + activejob (= 8.1.3) + activerecord (= 8.1.3) + activestorage (= 8.1.3) + activesupport (= 8.1.3) + mail (>= 2.8.0) + actionmailer (8.1.3) + actionpack (= 8.1.3) + actionview (= 8.1.3) + activejob (= 8.1.3) + activesupport (= 8.1.3) + mail (>= 2.8.0) + rails-dom-testing (~> 2.2) + actionpack (8.1.3) + actionview (= 8.1.3) + activesupport (= 8.1.3) + nokogiri (>= 1.8.5) + rack (>= 2.2.4) + rack-session (>= 1.0.1) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + useragent (~> 0.16) + actiontext (8.1.3) + action_text-trix (~> 2.1.15) + actionpack (= 8.1.3) + activerecord (= 8.1.3) + activestorage (= 8.1.3) + activesupport (= 8.1.3) + globalid (>= 0.6.0) + nokogiri (>= 1.8.5) + actionview (8.1.3) + activesupport (= 8.1.3) + builder (~> 3.1) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activejob (8.1.3) + activesupport (= 8.1.3) + globalid (>= 0.3.6) + activemodel (8.1.3) + activesupport (= 8.1.3) + activerecord (8.1.3) + activemodel (= 8.1.3) + activesupport (= 8.1.3) + timeout (>= 0.4.0) + activestorage (8.1.3) + actionpack (= 8.1.3) + activejob (= 8.1.3) + activerecord (= 8.1.3) + activesupport (= 8.1.3) + marcel (~> 1.0) + activesupport (8.1.3) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + json + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) + ast (2.4.3) + axiom-types (0.1.1) + descendants_tracker (~> 0.0.4) + ice_nine (~> 0.11.0) + thread_safe (~> 0.3, >= 0.3.1) + base64 (0.3.0) + bcrypt_pbkdf (1.1.2) + bigdecimal (4.1.2) + bindex (0.8.1) + bootsnap (1.24.3) + msgpack (~> 1.2) + brakeman (8.0.4) + racc + builder (3.3.0) + bundler-audit (0.9.3) + bundler (>= 1.2.0) + thor (~> 1.0) + capybara (3.40.0) + addressable + matrix + mini_mime (>= 0.1.3) + nokogiri (~> 1.11) + rack (>= 1.6.0) + rack-test (>= 0.6.3) + regexp_parser (>= 1.5, < 3.0) + xpath (~> 3.2) + childprocess (5.1.0) + logger (~> 1.5) + coercible (1.0.0) + descendants_tracker (~> 0.0.1) + concurrent-ruby (1.3.6) + connection_pool (3.0.2) + crass (1.0.6) + cucumber (10.2.0) + base64 (~> 0.2) + builder (~> 3.2) + cucumber-ci-environment (> 9, < 12) + cucumber-core (> 15, < 17) + 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 (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 (39.1.0) + cucumber-messages (>= 31, < 33) + cucumber-html-formatter (22.3.0) + cucumber-messages (> 23, < 33) + cucumber-messages (32.3.1) + cucumber-rails (4.0.1) + capybara (>= 3.25, < 4) + cucumber (>= 7, < 11) + railties (>= 6.1, < 9) + cucumber-tag-expressions (8.1.0) + database_cleaner-active_record (2.2.2) + activerecord (>= 5.a) + database_cleaner-core (~> 2.0) + database_cleaner-core (2.0.1) + date (3.5.1) + debug (1.11.1) + irb (~> 1.10) + reline (>= 0.3.8) + descendants_tracker (0.0.4) + thread_safe (~> 0.3, >= 0.3.1) + diff-lcs (1.6.2) + docile (1.4.1) + dotenv (3.2.0) + drb (2.2.3) + dry-configurable (1.3.0) + dry-core (~> 1.1) + zeitwerk (~> 2.6) + dry-core (1.2.0) + concurrent-ruby (~> 1.0) + logger + zeitwerk (~> 2.6) + dry-inflector (1.3.1) + dry-initializer (3.2.0) + dry-logic (1.6.0) + bigdecimal + concurrent-ruby (~> 1.0) + dry-core (~> 1.1) + zeitwerk (~> 2.6) + dry-schema (1.16.0) + concurrent-ruby (~> 1.0) + dry-configurable (~> 1.0, >= 1.0.1) + dry-core (~> 1.1) + dry-initializer (~> 3.2) + dry-logic (~> 1.6) + dry-types (~> 1.9, >= 1.9.1) + zeitwerk (~> 2.6) + dry-types (1.9.1) + bigdecimal (>= 3.0) + concurrent-ruby (~> 1.0) + dry-core (~> 1.0) + dry-inflector (~> 1.0) + dry-logic (~> 1.4) + zeitwerk (~> 2.6) + ed25519 (1.4.0) + erb (6.0.4) + erubi (1.13.1) + et-orbi (1.4.0) + tzinfo + ffi (1.17.4-aarch64-linux-gnu) + ffi (1.17.4-aarch64-linux-musl) + ffi (1.17.4-arm-linux-gnu) + ffi (1.17.4-arm-linux-musl) + ffi (1.17.4-x86_64-linux-gnu) + ffi (1.17.4-x86_64-linux-musl) + flay (2.14.4) + erubi (~> 1.10) + path_expander (~> 2.0) + prism (~> 1.7) + sexp_processor (~> 4.0) + flog (4.9.4) + path_expander (~> 2.0) + prism (~> 1.7) + sexp_processor (~> 4.8) + fugit (1.12.1) + et-orbi (~> 1.4) + raabro (~> 1.4) + globalid (1.3.0) + activesupport (>= 6.1) + i18n (1.14.8) + concurrent-ruby (~> 1.0) + ice_nine (0.11.2) + image_processing (1.14.0) + mini_magick (>= 4.9.5, < 6) + ruby-vips (>= 2.0.17, < 3) + importmap-rails (2.2.3) + actionpack (>= 6.0.0) + activesupport (>= 6.0.0) + railties (>= 6.0.0) + io-console (0.8.2) + irb (1.18.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.19.5) + kamal (2.11.0) + activesupport (>= 7.0) + base64 (~> 0.2) + bcrypt_pbkdf (~> 1.0) + concurrent-ruby (~> 1.2) + dotenv (~> 3.1) + ed25519 (~> 1.4) + net-ssh (~> 7.3) + sshkit (>= 1.23.0, < 2.0) + thor (~> 1.3) + zeitwerk (>= 2.6.18, < 3.0) + language_server-protocol (3.17.0.5) + launchy (3.1.1) + addressable (~> 2.8) + childprocess (~> 5.0) + logger (~> 1.6) + lint_roller (1.1.0) + logger (1.7.0) + loofah (2.25.1) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) + mail (2.9.0) + logger + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp + marcel (1.1.0) + matrix (0.4.3) + memoist3 (1.0.0) + mini_magick (5.3.1) + logger + mini_mime (1.1.5) + minitest (6.0.6) + drb (~> 2.0) + prism (~> 1.5) + msgpack (1.8.0) + multi_test (1.1.0) + net-imap (0.6.4) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.2.2) + timeout + net-scp (4.1.0) + net-ssh (>= 2.6.5, < 8.0.0) + net-sftp (4.0.0) + net-ssh (>= 5.0.0, < 8.0.0) + net-smtp (0.5.1) + net-protocol + net-ssh (7.3.2) + nio4r (2.7.5) + nokogiri (1.19.3-aarch64-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.3-aarch64-linux-musl) + racc (~> 1.4) + nokogiri (1.19.3-arm-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.3-arm-linux-musl) + racc (~> 1.4) + nokogiri (1.19.3-x86_64-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.3-x86_64-linux-musl) + racc (~> 1.4) + ostruct (0.6.3) + parallel (2.1.0) + parser (3.3.11.1) + ast (~> 2.4.1) + racc + path_expander (2.0.1) + pp (0.6.3) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) + propshaft (1.3.2) + actionpack (>= 7.0.0) + activesupport (>= 7.0.0) + rack + psych (5.3.1) + date + stringio + public_suffix (7.0.5) + puma (8.0.1) + nio4r (~> 2.0) + raabro (1.4.0) + racc (1.8.1) + rack (3.2.6) + rack-session (2.1.2) + base64 (>= 0.1.0) + rack (>= 3.0.0) + rack-test (2.2.0) + rack (>= 1.3) + rackup (2.3.1) + rack (>= 3) + rails (8.1.3) + actioncable (= 8.1.3) + actionmailbox (= 8.1.3) + actionmailer (= 8.1.3) + actionpack (= 8.1.3) + actiontext (= 8.1.3) + actionview (= 8.1.3) + activejob (= 8.1.3) + activemodel (= 8.1.3) + activerecord (= 8.1.3) + activestorage (= 8.1.3) + activesupport (= 8.1.3) + bundler (>= 1.15.0) + railties (= 8.1.3) + rails-dom-testing (2.3.0) + activesupport (>= 5.0.0) + minitest + nokogiri (>= 1.6) + 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 (8.1.3) + actionpack (= 8.1.3) + activesupport (= 8.1.3) + irb (~> 1.13) + rackup (>= 1.0.0) + rake (>= 12.2) + thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) + zeitwerk (~> 2.6) + rainbow (3.1.1) + rake (13.4.2) + rdoc (7.2.0) + erb + psych (>= 4.0.0) + tsort + reek (6.5.0) + dry-schema (~> 1.13) + logger (~> 1.6) + parser (~> 3.3.0) + rainbow (>= 2.0, < 4.0) + rexml (~> 3.1) + regexp_parser (2.12.0) + reline (0.6.3) + io-console (~> 0.5) + rexml (3.4.4) + 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.8) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-rails (8.0.4) + actionpack (>= 7.2) + activesupport (>= 7.2) + railties (>= 7.2) + 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-support (3.13.7) + rubocop (1.86.1) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (>= 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.49.1) + parser (>= 3.3.7.2) + 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.35.0) + activesupport (>= 4.2.0) + lint_roller (~> 1.1) + rack (>= 1.1) + 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) + ruby-progressbar (1.13.0) + ruby-vips (2.3.0) + ffi (~> 1.12) + logger + ruby_parser (3.22.0) + racc (~> 1.5) + sexp_processor (~> 4.16) + rubycritic (5.0.0) + flay (~> 2.13) + flog (~> 4.7) + launchy (>= 2.5.2) + ostruct + parser (>= 3.3.0.5) + prism (>= 1.6.0) + rainbow (~> 3.1.1) + reek (~> 6.5.0, < 7.0) + rexml + ruby_parser (~> 3.21) + simplecov (>= 0.22.0) + tty-which (~> 0.5.0) + virtus (~> 2.0) + rubyzip (3.3.0) + securerandom (0.4.1) + selenium-webdriver (4.43.0) + base64 (~> 0.2) + logger (~> 1.4) + rexml (~> 3.2, >= 3.2.5) + rubyzip (>= 1.2.2, < 4.0) + websocket (~> 1.0) + sexp_processor (4.17.5) + simplecov (0.22.0) + docile (~> 1.1) + simplecov-html (~> 0.11) + simplecov_json_formatter (~> 0.1) + simplecov-html (0.13.2) + simplecov_json_formatter (0.1.4) + solid_cable (3.0.12) + actioncable (>= 7.2) + activejob (>= 7.2) + activerecord (>= 7.2) + railties (>= 7.2) + solid_cache (1.0.10) + activejob (>= 7.2) + activerecord (>= 7.2) + railties (>= 7.2) + solid_queue (1.4.0) + activejob (>= 7.1) + activerecord (>= 7.1) + concurrent-ruby (>= 1.3.1) + fugit (~> 1.11) + railties (>= 7.1) + thor (>= 1.3.1) + sqlite3 (2.9.4-aarch64-linux-gnu) + sqlite3 (2.9.4-aarch64-linux-musl) + sqlite3 (2.9.4-arm-linux-gnu) + sqlite3 (2.9.4-arm-linux-musl) + sqlite3 (2.9.4-x86_64-linux-gnu) + sqlite3 (2.9.4-x86_64-linux-musl) + sshkit (1.25.0) + base64 + logger + net-scp (>= 1.1.2) + net-sftp (>= 2.1.2) + net-ssh (>= 2.8.0) + ostruct + stimulus-rails (1.3.4) + railties (>= 6.0.0) + stringio (3.2.0) + sys-uname (1.5.1) + ffi (~> 1.1) + memoist3 (~> 1.0.0) + thor (1.5.0) + thread_safe (0.3.6) + thruster (0.1.20) + thruster (0.1.20-aarch64-linux) + thruster (0.1.20-x86_64-linux) + timeout (0.6.1) + tsort (0.2.0) + tty-which (0.5.0) + turbo-rails (2.0.23) + actionpack (>= 7.1.0) + railties (>= 7.1.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + uri (1.1.1) + useragent (0.16.11) + virtus (2.0.0) + axiom-types (~> 0.1) + coercible (~> 1.0) + descendants_tracker (~> 0.0, >= 0.0.3) + web-console (4.3.0) + actionview (>= 8.0.0) + bindex (>= 0.4.0) + railties (>= 8.0.0) + websocket (1.2.11) + websocket-driver (0.8.0) + base64 + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + xpath (3.2.0) + nokogiri (~> 1.8) + zeitwerk (2.7.5) + +PLATFORMS + aarch64-linux + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + x86_64-linux + x86_64-linux-gnu + x86_64-linux-musl + +DEPENDENCIES + bootsnap + brakeman + bundler-audit + capybara + cucumber-rails + database_cleaner-active_record + debug + image_processing (~> 1.2) + importmap-rails + jbuilder + kamal + propshaft + puma (>= 5.0) + rails (~> 8.1.3) + rdoc + rspec-rails + rubocop-rails-omakase + rubycritic + selenium-webdriver + simplecov + solid_cable + solid_cache + solid_queue + sqlite3 (>= 2.1) + stimulus-rails + thruster + turbo-rails + tzinfo-data + web-console + +CHECKSUMS + action_text-trix (2.1.19) sha256=7012f59421009cf284aa651294896414d653a61a2417c9b8714c8476d2f74009 + actioncable (8.1.3) sha256=e5bc7f75e44e6a22de29c4f43176927c3a9ce4824464b74ed18d8226e75a80f0 + actionmailbox (8.1.3) sha256=df7da474eaa0e70df4ed5a6fef66eb3b3b0f2dbf7f14518deee8d77f1b4aae59 + actionmailer (8.1.3) sha256=831f724891bb70d0aaa4d76581a6321124b6a752cb655c9346aae5479318448d + actionpack (8.1.3) sha256=af998cae4d47c5d581a2cc363b5c77eb718b7c4b45748d81b1887b25621c29a3 + actiontext (8.1.3) sha256=d291019c00e1ea9e6463011fa214f6081a56d7b9a1d224e7d3f6384c1dafc7d2 + actionview (8.1.3) sha256=1347c88c7f3edb38100c5ce0e9fb5e62d7755f3edc1b61cce2eb0b2c6ea2fd5d + activejob (8.1.3) sha256=a149b1766aa8204c3c3da7309e4becd40fcd5529c348cffbf6c9b16b565fe8d3 + activemodel (8.1.3) sha256=90c05cbe4cef3649b8f79f13016191ea94c4525ce4a5c0fb7ef909c4b91c8219 + activerecord (8.1.3) sha256=8003be7b2466ba0a2a670e603eeb0a61dd66058fccecfc49901e775260ac70ab + activestorage (8.1.3) sha256=0564ce9309143951a67615e1bb4e090ee54b8befed417133cae614479b46384d + activesupport (8.1.3) sha256=21a5e0dfbd4c3ddd9e1317ec6a4d782fa226e7867dc70b0743acda81a1dca20e + addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af + ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 + axiom-types (0.1.1) sha256=c1ff113f3de516fa195b2db7e0a9a95fd1b08475a502ff660d04507a09980383 + base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + bcrypt_pbkdf (1.1.2) sha256=c2414c23ce66869b3eb9f643d6a3374d8322dfb5078125c82792304c10b94cf6 + bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd + bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e + bootsnap (1.24.3) sha256=f7fa3d20597e2f0aa52b0a1aba83fb54d4f79e9c2e210ec4fa1e8895514dcad8 + brakeman (8.0.4) sha256=7bf921fa9638544835df9aa7b3e720a9a72c0267f34f92135955edd80d4dcf6f + builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f + bundler-audit (0.9.3) sha256=81c8766c71e47d0d28a0f98c7eed028539f21a6ea3cd8f685eb6f42333c9b4e9 + capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef + childprocess (5.1.0) sha256=9a8d484be2fd4096a0e90a0cd3e449a05bc3aa33f8ac9e4d6dcef6ac1455b6ec + coercible (1.0.0) sha256=5081ad24352cc8435ce5472bc2faa30260c7ea7f2102cc6a9f167c4d9bffaadc + concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab + connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a + crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d + cucumber (10.2.0) sha256=fdedbd31ecf40858b60f04853f2aa15c44f5c30bbac29c6a227fa1e7005a8158 + cucumber-ci-environment (11.0.0) sha256=0df79a9e1d0b015b3d9def680f989200d96fef206f4d19ccf86a338c4f71d1e2 + cucumber-core (16.2.0) sha256=592b58a95cf42feef8e5a349f68e363784ba3b6568ffbcf6776e38e136cf970b + cucumber-cucumber-expressions (19.0.0) sha256=33208ff204732ac9bed42b46993a0a243054f71ece08579d57e53df6a1c9d93a + cucumber-gherkin (39.1.0) sha256=aed12a0c955d8563d80a012633c1a72075525f4d64d4cc983001df2181b379ed + cucumber-html-formatter (22.3.0) sha256=f9768ed05588dbd73a5f3824c2cc648bd86b00206e6972d743af8051281d0729 + cucumber-messages (32.3.1) sha256=ddc88e4c1cf7afb96c06005b92a4a6f221a2fa435a8b4ca04677d215fd82771c + cucumber-rails (4.0.1) sha256=bd3513ec47dc06188cc05703648cbc3560fb115f3f5cfb8b616065b4d6e8024d + cucumber-tag-expressions (8.1.0) sha256=9bd8c4b6654f8e5bf2a9c99329b6f32136a75e50cd39d4cfb3927d0fa9f52e21 + database_cleaner-active_record (2.2.2) sha256=88296b9f3088c31f7c0d4fcec10f68e4b71c96698043916de59b04debec10388 + database_cleaner-core (2.0.1) sha256=8646574c32162e59ed7b5258a97a208d3c44551b854e510994f24683865d846c + date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 + debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6 + descendants_tracker (0.0.4) sha256=e9c41dd4cfbb85829a9301ea7e7c48c2a03b26f09319db230e6479ccdc780897 + diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 + docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e + dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d + drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 + dry-configurable (1.3.0) sha256=882d862858567fc1210d2549d4c090f34370fc1bb7c5c1933de3fe792e18afa8 + dry-core (1.2.0) sha256=0cc5a7da88df397f153947eeeae42e876e999c1e30900f3c536fb173854e96a1 + dry-inflector (1.3.1) sha256=7fb0c2bb04f67638f25c52e7ba39ab435d922a3a5c3cd196120f63accb682dcc + dry-initializer (3.2.0) sha256=37d59798f912dc0a1efe14a4db4a9306989007b302dcd5f25d0a2a20c166c4e3 + dry-logic (1.6.0) sha256=da6fedbc0f90fc41f9b0cc7e6f05f5d529d1efaef6c8dcc8e0733f685745cea2 + dry-schema (1.16.0) sha256=cd3aaeabc0f1af66ec82a29096d4c4fb92a0a58b9dae29a22b1bbceb78985727 + dry-types (1.9.1) sha256=baebeecdb9f8395d6c9d227b62011279440943e3ef2468fe8ccc1ba11467f178 + ed25519 (1.4.0) sha256=16e97f5198689a154247169f3453ef4cfd3f7a47481fde0ae33206cdfdcac506 + erb (6.0.4) sha256=38e3803694be357fe2bfe312487c74beaf9fb4e5beb3e22498952fe1645b95d9 + erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 + et-orbi (1.4.0) sha256=6c7e3c90779821f9e3b324c5e96fda9767f72995d6ae435b96678a4f3e2de8bc + ffi (1.17.4-aarch64-linux-gnu) sha256=b208f06f91ffd8f5e1193da3cae3d2ccfc27fc36fba577baf698d26d91c080df + ffi (1.17.4-aarch64-linux-musl) sha256=9286b7a615f2676245283aef0a0a3b475ae3aae2bb5448baace630bb77b91f39 + ffi (1.17.4-arm-linux-gnu) sha256=d6dbddf7cb77bf955411af5f187a65b8cd378cb003c15c05697f5feee1cb1564 + ffi (1.17.4-arm-linux-musl) sha256=9d4838ded0465bef6e2426935f6bcc93134b6616785a84ffd2a3d82bc3cf6f95 + ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d + ffi (1.17.4-x86_64-linux-musl) sha256=3fdf9888483de005f8ef8d1cf2d3b20d86626af206cbf780f6a6a12439a9c49e + flay (2.14.4) sha256=a62d96a51d1da185aa41ba95b696966df9f7d1d91a457709277f24515895de77 + flog (4.9.4) sha256=12cc054fab7a2cbd2a906514397c4d7788954d530564782d6f14939dc2dfbcbb + fugit (1.12.1) sha256=5898f478ede9b415f0804e42b8f3fd53f814bd85eebffceebdbc34e1107aaf68 + globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11 + i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5 + ice_nine (0.11.2) sha256=5d506a7d2723d5592dc121b9928e4931742730131f22a1a37649df1c1e2e63db + image_processing (1.14.0) sha256=754cc169c9c262980889bec6bfd325ed1dafad34f85242b5a07b60af004742fb + importmap-rails (2.2.3) sha256=7101be2a4dc97cf1558fb8f573a718404c5f6bcfe94f304bf1f39e444feeb16a + io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc + irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 + jbuilder (2.14.1) sha256=4eb26376ff60ef100cb4fd6fd7533cd271f9998327e86adf20fd8c0e69fabb42 + json (2.19.5) sha256=218a18553e4801d579ca7e0f5bc72bafd776d7397238a1fb4e74db5b0a812c59 + kamal (2.11.0) sha256=1408864425e0dec7e0a14d712a3b13f614e9f3a425b7661d3f9d287a51d7dd75 + language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc + launchy (3.1.1) sha256=72b847b5cc961589dde2c395af0108c86ff0119f42d4648d25b5440ebb10059e + lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + loofah (2.25.1) sha256=d436c73dbd0c1147b16c4a41db097942d217303e1f7728704b37e4df9f6d2e04 + mail (2.9.0) sha256=6fa6673ecd71c60c2d996260f9ee3dd387d4673b8169b502134659ece6d34941 + marcel (1.1.0) sha256=fdcfcfa33cc52e93c4308d40e4090a5d4ea279e160a7f6af988260fa970e0bee + matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b + memoist3 (1.0.0) sha256=686e42402cf150a362050c23143dc57b0ef88f8c344943ff8b7845792b50d56f + mini_magick (5.3.1) sha256=29395dfd76badcabb6403ee5aff6f681e867074f8f28ce08d78661e9e4a351c4 + mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef + minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1 + msgpack (1.8.0) sha256=e64ce0212000d016809f5048b48eb3a65ffb169db22238fb4b72472fecb2d732 + multi_test (1.1.0) sha256=e9e550cdd863fb72becfe344aefdcd4cbd26ebf307847f4a6c039a4082324d10 + net-imap (0.6.4) sha256=9a5598c67a3022c284d98430ef1d4948e7dbdb62596f61081ea8ca933270a02b + net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 + net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8 + net-scp (4.1.0) sha256=a99b0b92a1e5d360b0de4ffbf2dc0c91531502d3d4f56c28b0139a7c093d1a5d + net-sftp (4.0.0) sha256=65bb91c859c2f93b09826757af11b69af931a3a9155050f50d1b06d384526364 + net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736 + net-ssh (7.3.2) sha256=65029e213c380e20e5fd92ece663934ab0a0fe888e0cd7cc6a5b664074362dd4 + nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 + nokogiri (1.19.3-aarch64-linux-gnu) sha256=46b89e5d7b9e844c2ee360794240c6ea2a4e6fa0c5892a4ed487db621224b639 + nokogiri (1.19.3-aarch64-linux-musl) sha256=8392dfdcd21be7a94dbbe9ccc138dea01b97b24cb2dc02a114ca98bfb1d9a0b7 + nokogiri (1.19.3-arm-linux-gnu) sha256=3919d5ffc334ad778a4a9eb88fda7dcb8b1fb58c8a52ac640c6dcd2f038e774f + nokogiri (1.19.3-arm-linux-musl) sha256=9ce1cb6346bb9c67b1550eb537aa183ead91e4b6eadb2f36ade02d8dd2a79fb6 + nokogiri (1.19.3-x86_64-linux-gnu) sha256=2f5078620fe12e83669b5b17311b32532a8153d02eee7ad06948b926d6080976 + nokogiri (1.19.3-x86_64-linux-musl) sha256=248c906d2166eca5efb56d52fdee5f9a1f51d69a72e2b64fdac647b4ce39ea3f + ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 + parallel (2.1.0) sha256=b35258865c2e31134c5ecb708beaaf6772adf9d5efae28e93e99260877b09356 + parser (3.3.11.1) sha256=d17ace7aabe3e72c3cc94043714be27cc6f852f104d81aa284c2281aecc65d54 + path_expander (2.0.1) sha256=2de201164bff4719cc4d0b3767286e9977cc832a59c4d70abab571ec86cb41e4 + pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6 + prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 + propshaft (1.3.2) sha256=1d56a3e56a92c21bfc29caf07406b5386b00d4c47ddf357cf989a5a234b1389e + psych (5.3.1) sha256=eb7a57cef10c9d70173ff74e739d843ac3b2c019a003de48447b2963d81b1974 + public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 + puma (8.0.1) sha256=7b94e50c07655718c1fb8ae41a11fc06c7d61293208b3aa608ff71a46d3ad37c + raabro (1.4.0) sha256=d4fa9ff5172391edb92b242eed8be802d1934b1464061ae5e70d80962c5da882 + racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f + rack (3.2.6) sha256=5ed78e1f73b2e25679bec7d45ee2d4483cc4146eb1be0264fc4d94cb5ef212c2 + rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8 + rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463 + rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868 + rails (8.1.3) sha256=6d017ba5348c98fc909753a8169b21d44de14d2a0b92d140d1a966834c3c9cd3 + rails-dom-testing (2.3.0) sha256=8acc7953a7b911ca44588bf08737bc16719f431a1cc3091a292bca7317925c1d + rails-html-sanitizer (1.7.0) sha256=28b145cceaf9cc214a9874feaa183c3acba036c9592b19886e0e45efc62b1e89 + railties (8.1.3) sha256=913eb0e0cb520aac687ffd74916bd726d48fa21f47833c6292576ef6a286de22 + rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a + rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 + rdoc (7.2.0) sha256=8650f76cd4009c3b54955eb5d7e3a075c60a57276766ebf36f9085e8c9f23192 + reek (6.5.0) sha256=d26d3a492773b2bbc228888067a21afe33ac07954a17dbd64cdeae42c4c69be1 + regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb + reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835 + rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 + rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d + rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 + rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47 + rspec-rails (8.0.4) sha256=06235692fc0892683d3d34977e081db867434b3a24ae0dd0c6f3516bad4e22df + rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c + rubocop (1.86.1) sha256=44415f3f01d01a21e01132248d2fd0867572475b566ca188a0a42133a08d4531 + rubocop-ast (1.49.1) sha256=4412f3ee70f6fe4546cc489548e0f6fcf76cafcfa80fa03af67098ffed755035 + rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834 + rubocop-rails (2.35.0) sha256=a5d9f0f6c6d9b73d9ddd181c4c0b6d2e00dd17107480828d31c7b369ebfcd49c + rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d + ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 + ruby-vips (2.3.0) sha256=e685ec02c13969912debbd98019e50492e12989282da5f37d05f5471442f5374 + ruby_parser (3.22.0) sha256=1eb4937cd9eb220aa2d194e352a24dba90aef00751e24c8dfffdb14000f15d23 + rubycritic (5.0.0) sha256=7f3877556d7f52bf0980496b7c907a827a9592ba690b80a593403a60bd6cfb42 + rubyzip (3.3.0) sha256=a372fc67892a4f8c0bc8ec906b720353d8e48807a64b2e63adf99b1e3583a034 + securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 + selenium-webdriver (4.43.0) sha256=a634377b964b701c6ac0a009ce3a08fa34ec1e1e7fe9a6d57e3088d14529a65c + sexp_processor (4.17.5) sha256=ae2b48ba98353d5d465ce8759836b7a05f2e12c5879fcd14d7815b026de32f0e + simplecov (0.22.0) sha256=fe2622c7834ff23b98066bb0a854284b2729a569ac659f82621fc22ef36213a5 + simplecov-html (0.13.2) sha256=bd0b8e54e7c2d7685927e8d6286466359b6f16b18cb0df47b508e8d73c777246 + simplecov_json_formatter (0.1.4) sha256=529418fbe8de1713ac2b2d612aa3daa56d316975d307244399fa4838c601b428 + solid_cable (3.0.12) sha256=a168a54731a455d5627af48d8441ea3b554b8c1f6e6cd6074109de493e6b0460 + solid_cache (1.0.10) sha256=bc05a2fb3ac78a6f43cbb5946679cf9db67dd30d22939ededc385cb93e120d41 + solid_queue (1.4.0) sha256=e6a18d196f0b27cb6e3c77c5b31258b05fb634f8ed64fb1866ed164047216c2a + sqlite3 (2.9.4-aarch64-linux-gnu) sha256=ecabed721e6eaad54601d2685f09029d90025efc8d931040dc89cb3f8a2080ec + sqlite3 (2.9.4-aarch64-linux-musl) sha256=ffb4255947fb54c8c3eeca97460c9702b40de91ce390455ef7367ca6a3929a31 + sqlite3 (2.9.4-arm-linux-gnu) sha256=9ee2008b9fbec984c3c165b0d7eedd2bd2a415100b761bfa3a4c6fbec9208bf6 + sqlite3 (2.9.4-arm-linux-musl) sha256=8dc1fe4da6977992cd62decf4a93ccf6cc2e124a5e6a340160d52092f70e837a + sqlite3 (2.9.4-x86_64-linux-gnu) sha256=537a3eda71b1df1336d0055cbebe55a7317c34870c192c7b6b9d8d0be6871847 + sqlite3 (2.9.4-x86_64-linux-musl) sha256=3fc5e865b4be9a85d998203ef8d0c0fdcb92f20acf34a254346ff8a19088efec + sshkit (1.25.0) sha256=c8c6543cdb60f91f1d277306d585dd11b6a064cb44eab0972827e4311ff96744 + stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06 + stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1 + sys-uname (1.5.1) sha256=784d7e6491b0393c25cbbe5ac38324ac7be9fda083a6094832648af669386d7b + thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 + thread_safe (0.3.6) sha256=9ed7072821b51c57e8d6b7011a8e282e25aeea3a4065eab326e43f66f063b05a + thruster (0.1.20) sha256=c05f2fbcae527bbe093a6e6d84fb12d9d680617e7c162325d9b97e8e9d4b5201 + thruster (0.1.20-aarch64-linux) sha256=754f1701061235235165dde31e7a3bc87ec88066a02981ff4241fcda0d76d397 + thruster (0.1.20-x86_64-linux) sha256=d579f252bf67aee6ba6d957e48f566b72e019d7657ba2f267a5db1e4d91d2479 + timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb + tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f + tty-which (0.5.0) sha256=5824055f0d6744c97e7c4426544f01d519c40d1806ef2ef47d9854477993f466 + turbo-rails (2.0.23) sha256=ee0d90733aafff056cf51ff11e803d65e43cae258cc55f6492020ec1f9f9315f + tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b + unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 + unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f + uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 + useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844 + virtus (2.0.0) sha256=8841dae4eb7fcc097320ba5ea516bf1839e5d056c61ee27138aa4bddd6e3d1c2 + web-console (4.3.0) sha256=e13b71301cdfc2093f155b5aa3a622db80b4672d1f2f713119cc7ec7ac6a6da4 + websocket (1.2.11) sha256=b7e7a74e2410b5e85c25858b26b3322f29161e300935f70a0e0d3c35e0462737 + websocket-driver (0.8.0) sha256=ed0dba4b943c22f17f9a734817e808bc84cdce6a7e22045f5315aa57676d4962 + websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241 + xpath (3.2.0) sha256=6dfda79d91bb3b949b947ecc5919f042ef2f399b904013eb3ef6d20dd3a4082e + zeitwerk (2.7.5) sha256=d8da92128c09ea6ec62c949011b00ed4a20242b255293dd66bf41545398f73dd + +BUNDLED WITH + 4.0.9 diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000000..9a5ea7383a --- /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/assets/images/.keep b/app/assets/images/.keep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css new file mode 100644 index 0000000000..fe93333c0f --- /dev/null +++ b/app/assets/stylesheets/application.css @@ -0,0 +1,10 @@ +/* + * This is a manifest file that'll be compiled into application.css. + * + * With Propshaft, assets are served efficiently without preprocessing steps. You can still include + * application-wide styles in this file, but keep in mind that CSS precedence will follow the standard + * cascading order, meaning styles declared later in the document or manifest will override earlier ones, + * depending on specificity. + * + * Consider organizing styles into separate files for maintainability. + */ diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb new file mode 100644 index 0000000000..c3537563da --- /dev/null +++ b/app/controllers/application_controller.rb @@ -0,0 +1,7 @@ +class ApplicationController < ActionController::Base + # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. + allow_browser versions: :modern + + # Changes to the importmap will invalidate the etag for HTML responses + stale_when_importmap_changes +end diff --git a/app/controllers/concerns/.keep b/app/controllers/concerns/.keep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb new file mode 100644 index 0000000000..de6be7945c --- /dev/null +++ b/app/helpers/application_helper.rb @@ -0,0 +1,2 @@ +module ApplicationHelper +end diff --git a/app/javascript/application.js b/app/javascript/application.js new file mode 100644 index 0000000000..0d7b49404c --- /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 0000000000..1213e85c7a --- /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 0000000000..5975c0789d --- /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 0000000000..1156bf8362 --- /dev/null +++ b/app/javascript/controllers/index.js @@ -0,0 +1,4 @@ +// Import and register all your controllers from the importmap via controllers/**/*_controller +import { application } from "controllers/application" +import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading" +eagerLoadControllersFrom("controllers", application) diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb new file mode 100644 index 0000000000..d394c3d106 --- /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/mailers/application_mailer.rb b/app/mailers/application_mailer.rb new file mode 100644 index 0000000000..3c34c8148f --- /dev/null +++ b/app/mailers/application_mailer.rb @@ -0,0 +1,4 @@ +class ApplicationMailer < ActionMailer::Base + default from: "from@example.com" + layout "mailer" +end diff --git a/app/models/application_record.rb b/app/models/application_record.rb new file mode 100644 index 0000000000..b63caeb8a5 --- /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/concerns/.keep b/app/models/concerns/.keep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb new file mode 100644 index 0000000000..9e51e3817f --- /dev/null +++ b/app/views/layouts/application.html.erb @@ -0,0 +1,29 @@ + + + + <%= content_for(:title) || "Camaar" %> + + + + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + <%= yield :head %> + + <%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %> + <%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %> + + + + + + <%# Includes all stylesheet files in app/assets/stylesheets %> + <%= stylesheet_link_tag :app, "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 0000000000..3aac9002ed --- /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 0000000000..37f0bddbd7 --- /dev/null +++ b/app/views/layouts/mailer.text.erb @@ -0,0 +1 @@ +<%= yield %> diff --git a/app/views/pwa/manifest.json.erb b/app/views/pwa/manifest.json.erb new file mode 100644 index 0000000000..fca522bbe4 --- /dev/null +++ b/app/views/pwa/manifest.json.erb @@ -0,0 +1,22 @@ +{ + "name": "Camaar", + "icons": [ + { + "src": "/icon.png", + "type": "image/png", + "sizes": "512x512" + }, + { + "src": "/icon.png", + "type": "image/png", + "sizes": "512x512", + "purpose": "maskable" + } + ], + "start_url": "/", + "display": "standalone", + "scope": "/", + "description": "Camaar.", + "theme_color": "red", + "background_color": "red" +} diff --git a/app/views/pwa/service-worker.js b/app/views/pwa/service-worker.js new file mode 100644 index 0000000000..b3a13fb7bb --- /dev/null +++ b/app/views/pwa/service-worker.js @@ -0,0 +1,26 @@ +// Add a service worker for processing Web Push notifications: +// +// self.addEventListener("push", async (event) => { +// const { title, options } = await event.data.json() +// event.waitUntil(self.registration.showNotification(title, options)) +// }) +// +// self.addEventListener("notificationclick", function(event) { +// event.notification.close() +// event.waitUntil( +// clients.matchAll({ type: "window" }).then((clientList) => { +// for (let i = 0; i < clientList.length; i++) { +// let client = clientList[i] +// let clientPath = (new URL(client.url)).pathname +// +// if (clientPath == event.notification.data.path && "focus" in client) { +// return client.focus() +// } +// } +// +// if (clients.openWindow) { +// return clients.openWindow(event.notification.data.path) +// } +// }) +// ) +// }) diff --git a/bin/brakeman b/bin/brakeman new file mode 100755 index 0000000000..ace1c9ba08 --- /dev/null +++ b/bin/brakeman @@ -0,0 +1,7 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +ARGV.unshift("--ensure-latest") + +load Gem.bin_path("brakeman", "brakeman") diff --git a/bin/bundler-audit b/bin/bundler-audit new file mode 100755 index 0000000000..e2ef22690c --- /dev/null +++ b/bin/bundler-audit @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "bundler/audit/cli" + +ARGV.concat %w[ --config config/bundler-audit.yml ] if ARGV.empty? || ARGV.include?("check") +Bundler::Audit::CLI.start diff --git a/bin/ci b/bin/ci new file mode 100755 index 0000000000..4137ad5bb0 --- /dev/null +++ b/bin/ci @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "active_support/continuous_integration" + +CI = ActiveSupport::ContinuousIntegration +require_relative "../config/ci.rb" diff --git a/bin/cucumber b/bin/cucumber new file mode 100755 index 0000000000..eb5e962e86 --- /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/dev b/bin/dev new file mode 100755 index 0000000000..5f91c20545 --- /dev/null +++ b/bin/dev @@ -0,0 +1,2 @@ +#!/usr/bin/env ruby +exec "./bin/rails", "server", *ARGV diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint new file mode 100755 index 0000000000..ed31659f40 --- /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 [ "${@: -2:1}" == "./bin/rails" ] && [ "${@: -1:1}" == "server" ]; then + ./bin/rails db:prepare +fi + +exec "${@}" diff --git a/bin/importmap b/bin/importmap new file mode 100755 index 0000000000..36502ab16c --- /dev/null +++ b/bin/importmap @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby + +require_relative "../config/application" +require "importmap/commands" diff --git a/bin/jobs b/bin/jobs new file mode 100755 index 0000000000..dcf59f309a --- /dev/null +++ b/bin/jobs @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby + +require_relative "../config/environment" +require "solid_queue/cli" + +SolidQueue::Cli.start(ARGV) diff --git a/bin/kamal b/bin/kamal new file mode 100755 index 0000000000..d9ba276702 --- /dev/null +++ b/bin/kamal @@ -0,0 +1,16 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'kamal' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("kamal", "kamal") diff --git a/bin/rails b/bin/rails new file mode 100755 index 0000000000..efc0377492 --- /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 0000000000..4fbf10b960 --- /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/rubocop b/bin/rubocop new file mode 100755 index 0000000000..5a20504716 --- /dev/null +++ b/bin/rubocop @@ -0,0 +1,8 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +# Explicit RuboCop config increases performance slightly while avoiding config confusion. +ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__)) + +load Gem.bin_path("rubocop", "rubocop") diff --git a/bin/setup b/bin/setup new file mode 100755 index 0000000000..81be011e87 --- /dev/null +++ b/bin/setup @@ -0,0 +1,35 @@ +#!/usr/bin/env ruby +require "fileutils" + +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("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" + system! "bin/rails db:reset" if ARGV.include?("--reset") + + puts "\n== Removing old logs and tempfiles ==" + system! "bin/rails log:clear tmp:clear" + + unless ARGV.include?("--skip-server") + puts "\n== Starting development server ==" + STDOUT.flush # flush the output before exec(2) so that it displays + exec "bin/dev" + end +end diff --git a/bin/thrust b/bin/thrust new file mode 100755 index 0000000000..36bde2d832 --- /dev/null +++ b/bin/thrust @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("thruster", "thrust") diff --git a/config.ru b/config.ru new file mode 100644 index 0000000000..4a3c09a688 --- /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 0000000000..b4848d838b --- /dev/null +++ b/config/application.rb @@ -0,0 +1,27 @@ +require_relative "boot" + +require "rails/all" + +# Require the gems listed in Gemfile, including any gems +# you've limited to :test, :development, or :production. +Bundler.require(*Rails.groups) + +module Camaar + class Application < Rails::Application + # Initialize configuration defaults for originally generated Rails version. + config.load_defaults 8.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") + end +end diff --git a/config/boot.rb b/config/boot.rb new file mode 100644 index 0000000000..988a5ddc46 --- /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/bundler-audit.yml b/config/bundler-audit.yml new file mode 100644 index 0000000000..e74b3af949 --- /dev/null +++ b/config/bundler-audit.yml @@ -0,0 +1,5 @@ +# Audit all gems listed in the Gemfile for known security problems by running bin/bundler-audit. +# CVEs that are not relevant to the application can be enumerated on the ignore list below. + +ignore: + - CVE-THAT-DOES-NOT-APPLY diff --git a/config/cable.yml b/config/cable.yml new file mode 100644 index 0000000000..b9adc5aa3a --- /dev/null +++ b/config/cable.yml @@ -0,0 +1,17 @@ +# Async adapter only works within the same process, so for manually triggering cable updates from a console, +# and seeing results in the browser, you must do so from the web console (running inside the dev process), +# not a terminal started via bin/rails console! Add "console" to any action or any ERB template view +# to make the web console appear. +development: + adapter: async + +test: + adapter: test + +production: + adapter: solid_cable + connects_to: + database: + writing: cable + polling_interval: 0.1.seconds + message_retention: 1.day diff --git a/config/cache.yml b/config/cache.yml new file mode 100644 index 0000000000..19d490843b --- /dev/null +++ b/config/cache.yml @@ -0,0 +1,16 @@ +default: &default + store_options: + # Cap age of oldest cache entry to fulfill retention policies + # max_age: <%= 60.days.to_i %> + max_size: <%= 256.megabytes %> + namespace: <%= Rails.env %> + +development: + <<: *default + +test: + <<: *default + +production: + database: cache + <<: *default diff --git a/config/ci.rb b/config/ci.rb new file mode 100644 index 0000000000..ba88f6e85e --- /dev/null +++ b/config/ci.rb @@ -0,0 +1,25 @@ +# Run using bin/ci + +CI.run do + step "Setup", "bin/setup --skip-server" + + step "Style: Ruby", "bin/rubocop" + + step "Security: Gem audit", "bin/bundler-audit" + step "Security: Importmap vulnerability audit", "bin/importmap audit" + step "Security: Brakeman code analysis", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error" + step "Tests: RSpec", "bundle exec rspec" + step "Tests: Cucumber", "bundle exec cucumber" + step "Tests: Seeds", "env RAILS_ENV=test bin/rails db:seed:replant" + + # Optional: Run system tests + # step "Tests: System", "bin/rails test:system" + + # Optional: set a green GitHub commit status to unblock PR merge. + # Requires the `gh` CLI and `gh extension install basecamp/gh-signoff`. + # if success? + # step "Signoff: All systems go. Ready for merge and deploy.", "gh signoff" + # else + # failure "Signoff: CI failed. Do not merge or deploy.", "Fix the issues and try again." + # end +end diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc new file mode 100644 index 0000000000..7736823c11 --- /dev/null +++ b/config/credentials.yml.enc @@ -0,0 +1 @@ +J0+xI3F3hngy8cETtC1GWSdJaIhe4vHI/HcMk6/P7+30mKWrqlNDR3gNnkSEqnQi3nwk3Ak7EqBg6MlwYuVP8c63IlcNpIjrGnH6to76XuKlWsqYT1v7jUf4/dcXxPOwdFMfZD1Qm10VOXbGAt/RusQJw36g75MX7Bm4bCBPe7c/EVEZfBvm7yDPigohTOCj3DnU0MOaQ4RlbZ2fbjrXh6bDK+yDMSh2RHCIWG3MbrRJBQZQ1ggaPrmqXZstFolP65YgcQU0NgPIZbzzAp8vPV78RaI9r/FizVY+1GWWCYYmQ22tseQF2ndRwYgNMh1zTpaFNlqHEVZJuvgGAw6qPH8kmzV671laVZj8tVBLRl5HSHeXUD7OqxFiZmHblziObrPZM4ufP0rASJVubozraXDzrGz2NFSFm4uCqg+7LLGQ0pGA+cqDlmpylwNoB6QbeQQyflU093o8bmUbAzvx3LvKvBexkSpI0EPikyNkTX15HjhXGgl9hce8--P5ubxdSC+XwtYFVW--NaGj0ZG7v0RYOUujLsm5mw== \ No newline at end of file diff --git a/config/cucumber.yml b/config/cucumber.yml new file mode 100644 index 0000000000..47a4663ae2 --- /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 0000000000..302d638c96 --- /dev/null +++ b/config/database.yml @@ -0,0 +1,40 @@ +# SQLite. Versions 3.8.0 and up are supported. +# gem install sqlite3 +# +# Ensure the SQLite 3 gem is defined in your Gemfile +# gem "sqlite3" +# +default: &default + adapter: sqlite3 + max_connections: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + timeout: 5000 + +development: + <<: *default + database: storage/development.sqlite3 + +# 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: storage/test.sqlite3 + +# Store production database in the storage/ directory, which by default +# is mounted as a persistent Docker volume in config/deploy.yml. +production: + primary: + <<: *default + database: storage/production.sqlite3 + cache: + <<: *default + database: storage/production_cache.sqlite3 + migrations_paths: db/cache_migrate + queue: + <<: *default + database: storage/production_queue.sqlite3 + migrations_paths: db/queue_migrate + cable: + <<: *default + database: storage/production_cable.sqlite3 + migrations_paths: db/cable_migrate diff --git a/config/deploy.yml b/config/deploy.yml new file mode 100644 index 0000000000..b7360ea943 --- /dev/null +++ b/config/deploy.yml @@ -0,0 +1,119 @@ +# Name of your application. Used to uniquely configure containers. +service: camaar + +# Name of the container image (use your-user/app-name on external registries). +image: camaar + +# Deploy to these servers. +servers: + web: + - 192.168.0.1 + # job: + # hosts: + # - 192.168.0.1 + # cmd: bin/jobs + +# Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server. +# If used with Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption. +# +# Using an SSL proxy like this requires turning on config.assume_ssl and config.force_ssl in production.rb! +# +# Don't use this when deploying to multiple web servers (then you have to terminate SSL at your load balancer). +# +# proxy: +# ssl: true +# host: app.example.com + +# Where you keep your container images. +registry: + # Alternatives: hub.docker.com / registry.digitalocean.com / ghcr.io / ... + server: localhost:5555 + + # Needed for authenticated registries. + # username: your-user + + # Always use an access token rather than real password when possible. + # password: + # - KAMAL_REGISTRY_PASSWORD + +# Inject ENV variables into containers (secrets come from .kamal/secrets). +env: + secret: + - RAILS_MASTER_KEY + clear: + # Run the Solid Queue Supervisor inside the web server's Puma process to do jobs. + # When you start using multiple servers, you should split out job processing to a dedicated machine. + SOLID_QUEUE_IN_PUMA: true + + # Set number of processes dedicated to Solid Queue (default: 1) + # JOB_CONCURRENCY: 3 + + # Set number of cores available to the application on each server (default: 1). + # WEB_CONCURRENCY: 2 + + # Match this to any external database server to configure Active Record correctly + # Use camaar-db for a db accessory server on same machine via local kamal docker network. + # DB_HOST: 192.168.0.2 + + # Log everything from Rails + # RAILS_LOG_LEVEL: debug + +# Aliases are triggered with "bin/kamal ". You can overwrite arguments on invocation: +# "bin/kamal logs -r job" will tail logs from the first server in the job section. +aliases: + console: app exec --interactive --reuse "bin/rails console" + shell: app exec --interactive --reuse "bash" + logs: app logs -f + dbc: app exec --interactive --reuse "bin/rails dbconsole --include-password" + +# Use a persistent storage volume for sqlite database files and local Active Storage files. +# Recommended to change this to a mounted volume path that is backed up off server. +volumes: + - "camaar_storage:/rails/storage" + +# Bridge fingerprinted assets, like JS and CSS, between versions to avoid +# hitting 404 on in-flight requests. Combines all files from new and old +# version inside the asset_path. +asset_path: /rails/public/assets + +# Configure the image builder. +builder: + arch: amd64 + + # # Build image via remote server (useful for faster amd64 builds on arm64 computers) + # remote: ssh://docker@docker-builder-server + # + # # Pass arguments and secrets to the Docker build process + # args: + # RUBY_VERSION: ruby-3.4.9 + # secrets: + # - GITHUB_TOKEN + # - RAILS_MASTER_KEY + +# Use a different ssh user than root +# ssh: +# user: app + +# Use accessory services (secrets come from .kamal/secrets). +# accessories: +# db: +# image: mysql:8.0 +# host: 192.168.0.2 +# # Change to 3306 to expose port to the world instead of just local network. +# port: "127.0.0.1:3306:3306" +# env: +# clear: +# MYSQL_ROOT_HOST: '%' +# secret: +# - MYSQL_ROOT_PASSWORD +# files: +# - config/mysql/production.cnf:/etc/mysql/my.cnf +# - db/production.sql:/docker-entrypoint-initdb.d/setup.sql +# directories: +# - data:/var/lib/mysql +# redis: +# image: valkey/valkey:8 +# host: 192.168.0.2 +# port: 6379 +# directories: +# - data:/data diff --git a/config/environment.rb b/config/environment.rb new file mode 100644 index 0000000000..cac5315775 --- /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 0000000000..4a6ea19c9c --- /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. + + # Make code changes take effect immediately without server restart. + 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 Action Controller caching. By default Action Controller caching is disabled. + # Run rails dev:cache to toggle Action Controller 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.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" } + else + config.action_controller.perform_caching = false + end + + # Change to :null_store to avoid any caching. + config.cache_store = :memory_store + + # 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 + + # Make template changes take effect immediately. + config.action_mailer.perform_caching = false + + # Set localhost to be used by links generated in mailer templates. + config.action_mailer.default_url_options = { host: "localhost", port: 3000 } + + # Print deprecation notices to the Rails logger. + config.active_support.deprecation = :log + + # 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 + + # Append comments with runtime information tags to SQL queries in logs. + config.active_record.query_log_tags_enabled = true + + # Highlight code that enqueued background job in logs. + config.active_job.verbose_enqueue_logs = true + + # Highlight code that triggered redirect in logs. + config.action_dispatch.verbose_redirect_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 + + # Apply autocorrection by RuboCop to files generated by `bin/rails generate`. + # config.generators.apply_rubocop_autocorrect_after_generate! +end diff --git a/config/environments/production.rb b/config/environments/production.rb new file mode 100644 index 0000000000..f5763e04e5 --- /dev/null +++ b/config/environments/production.rb @@ -0,0 +1,90 @@ +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 for better performance and memory savings (ignored by Rake tasks). + config.eager_load = true + + # Full error reports are disabled. + config.consider_all_requests_local = false + + # Turn on fragment caching in view templates. + config.action_controller.perform_caching = true + + # Cache assets for far-future expiry since they are all digest stamped. + config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" } + + # Enable serving of images, stylesheets, and JavaScripts from an asset server. + # config.asset_host = "http://assets.example.com" + + # Store uploaded files on the local file system (see config/storage.yml for options). + config.active_storage.service = :local + + # Assume all access to the app is happening through a SSL-terminating reverse proxy. + # config.assume_ssl = true + + # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. + # config.force_ssl = true + + # Skip http-to-https redirect for the default health check endpoint. + # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } + + # Log to STDOUT with the current request id as a default log tag. + config.log_tags = [ :request_id ] + config.logger = ActiveSupport::TaggedLogging.logger(STDOUT) + + # Change to "debug" to log everything (including potentially personally-identifiable information!). + config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") + + # Prevent health checks from clogging up the logs. + config.silence_healthcheck_path = "/up" + + # Don't log any deprecations. + config.active_support.report_deprecations = false + + # Replace the default in-process memory cache store with a durable alternative. + config.cache_store = :solid_cache_store + + # Replace the default in-process and non-durable queuing backend for Active Job. + config.active_job.queue_adapter = :solid_queue + config.solid_queue.connects_to = { database: { writing: :queue } } + + # 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 + + # Set host to be used by links generated in mailer templates. + config.action_mailer.default_url_options = { host: "example.com" } + + # Specify outgoing SMTP server. Remember to add smtp/* credentials via bin/rails credentials:edit. + # config.action_mailer.smtp_settings = { + # user_name: Rails.application.credentials.dig(:smtp, :user_name), + # password: Rails.application.credentials.dig(:smtp, :password), + # address: "smtp.example.com", + # port: 587, + # authentication: :plain + # } + + # 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 + + # Do not dump schema after migrations. + config.active_record.dump_schema_after_migration = false + + # Only use :id for inspections in production. + config.active_record.attributes_for_inspect = [ :id ] + + # Enable DNS rebinding protection and other `Host` header attacks. + # config.hosts = [ + # "example.com", # Allow requests from example.com + # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` + # ] + # + # Skip DNS rebinding protection for the default health check endpoint. + # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } +end diff --git a/config/environments/test.rb b/config/environments/test.rb new file mode 100644 index 0000000000..29d195b837 --- /dev/null +++ b/config/environments/test.rb @@ -0,0 +1,57 @@ +# 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.headers = { "cache-control" => "public, max-age=3600" } + + # Show full error reports. + config.consider_all_requests_local = true + 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 + + # 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 + + # Set host to be used by links generated in mailer templates. + config.action_mailer.default_url_options = { host: "example.com" } + + # Print deprecation notices to the stderr. + config.active_support.deprecation = :stderr + + # 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 0000000000..909dfc542d --- /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 0000000000..487324424f --- /dev/null +++ b/config/initializers/assets.rb @@ -0,0 +1,7 @@ +# 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 diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb new file mode 100644 index 0000000000..d51d713979 --- /dev/null +++ b/config/initializers/content_security_policy.rb @@ -0,0 +1,29 @@ +# 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) +# +# # Automatically add `nonce` to `javascript_tag`, `javascript_include_tag`, and `stylesheet_link_tag` +# # if the corresponding directives are specified in `content_security_policy_nonce_directives`. +# # config.content_security_policy_nonce_auto = true +# +# # 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 0000000000..c0b717f7ec --- /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, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc +] diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb new file mode 100644 index 0000000000..3860f659ea --- /dev/null +++ b/config/initializers/inflections.rb @@ -0,0 +1,16 @@ +# 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 + +# These inflection rules are supported but not enabled by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.acronym "RESTful" +# end diff --git a/config/locales/en.yml b/config/locales/en.yml new file mode 100644 index 0000000000..6c349ae5e3 --- /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 0000000000..38c4b86596 --- /dev/null +++ b/config/puma.rb @@ -0,0 +1,42 @@ +# 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 starts a configurable number of processes (workers) and each process +# serves each request in a thread from an internal thread pool. +# +# You can control the number of workers using ENV["WEB_CONCURRENCY"]. You +# should only set this value when you want to run 2 or more workers. The +# default is already 1. You can set it to `auto` to automatically start a worker +# for each available processor. +# +# The ideal number of threads per worker depends both on how much time the +# application spends waiting for IO operations and on how much you wish to +# prioritize throughput over latency. +# +# As a rule of thumb, increasing the number of threads will increase how much +# traffic a given process can handle (throughput), but due to CRuby's +# Global VM Lock (GVL) it has diminishing returns and will degrade the +# response time (latency) of the application. +# +# The default is set to 3 threads as it's deemed a decent compromise between +# throughput and latency for the average Rails application. +# +# Any libraries that use a connection pool or another resource pool should +# be configured to provide at least as many connections as the number of +# threads. This includes Active Record's `pool` parameter in `database.yml`. +threads_count = ENV.fetch("RAILS_MAX_THREADS", 3) +threads threads_count, threads_count + +# Specifies the `port` that Puma will listen on to receive requests; default is 3000. +port ENV.fetch("PORT", 3000) + +# Allow puma to be restarted by `bin/rails restart` command. +plugin :tmp_restart + +# Run the Solid Queue supervisor inside of Puma for single-server deployments. +plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"] + +# Specify the PID file. Defaults to tmp/pids/server.pid in development. +# In other environments, only set the PID file if requested. +pidfile ENV["PIDFILE"] if ENV["PIDFILE"] diff --git a/config/queue.yml b/config/queue.yml new file mode 100644 index 0000000000..9eace59c41 --- /dev/null +++ b/config/queue.yml @@ -0,0 +1,18 @@ +default: &default + dispatchers: + - polling_interval: 1 + batch_size: 500 + workers: + - queues: "*" + threads: 3 + processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %> + polling_interval: 0.1 + +development: + <<: *default + +test: + <<: *default + +production: + <<: *default diff --git a/config/recurring.yml b/config/recurring.yml new file mode 100644 index 0000000000..b4207f9b07 --- /dev/null +++ b/config/recurring.yml @@ -0,0 +1,15 @@ +# examples: +# periodic_cleanup: +# class: CleanSoftDeletedRecordsJob +# queue: background +# args: [ 1000, { batch_size: 500 } ] +# schedule: every hour +# periodic_cleanup_with_command: +# command: "SoftDeletedRecord.due.delete_all" +# priority: 2 +# schedule: at 5am every day + +production: + clear_solid_queue_finished_jobs: + command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)" + schedule: every hour at minute 12 diff --git a/config/routes.rb b/config/routes.rb new file mode 100644 index 0000000000..48254e88ed --- /dev/null +++ b/config/routes.rb @@ -0,0 +1,14 @@ +Rails.application.routes.draw do + # 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 + + # Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb) + # get "manifest" => "rails/pwa#manifest", as: :pwa_manifest + # get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker + + # Defines the root path route ("/") + # root "posts#index" +end diff --git a/config/storage.yml b/config/storage.yml new file mode 100644 index 0000000000..927dc537c8 --- /dev/null +++ b/config/storage.yml @@ -0,0 +1,27 @@ +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 %> + +# mirror: +# service: Mirror +# primary: local +# mirrors: [ amazon, google, microsoft ] diff --git a/db/cable_schema.rb b/db/cable_schema.rb new file mode 100644 index 0000000000..23666604a5 --- /dev/null +++ b/db/cable_schema.rb @@ -0,0 +1,11 @@ +ActiveRecord::Schema[7.1].define(version: 1) do + create_table "solid_cable_messages", force: :cascade do |t| + t.binary "channel", limit: 1024, null: false + t.binary "payload", limit: 536870912, null: false + t.datetime "created_at", null: false + t.integer "channel_hash", limit: 8, null: false + t.index ["channel"], name: "index_solid_cable_messages_on_channel" + t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash" + t.index ["created_at"], name: "index_solid_cable_messages_on_created_at" + end +end diff --git a/db/cache_schema.rb b/db/cache_schema.rb new file mode 100644 index 0000000000..81a410d188 --- /dev/null +++ b/db/cache_schema.rb @@ -0,0 +1,12 @@ +ActiveRecord::Schema[7.2].define(version: 1) do + create_table "solid_cache_entries", force: :cascade do |t| + t.binary "key", limit: 1024, null: false + t.binary "value", limit: 536870912, null: false + t.datetime "created_at", null: false + t.integer "key_hash", limit: 8, null: false + t.integer "byte_size", limit: 4, null: false + t.index ["byte_size"], name: "index_solid_cache_entries_on_byte_size" + t.index ["key_hash", "byte_size"], name: "index_solid_cache_entries_on_key_hash_and_byte_size" + t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true + end +end diff --git a/db/queue_schema.rb b/db/queue_schema.rb new file mode 100644 index 0000000000..85194b6a88 --- /dev/null +++ b/db/queue_schema.rb @@ -0,0 +1,129 @@ +ActiveRecord::Schema[7.1].define(version: 1) do + create_table "solid_queue_blocked_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.string "concurrency_key", null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.index [ "concurrency_key", "priority", "job_id" ], name: "index_solid_queue_blocked_executions_for_release" + t.index [ "expires_at", "concurrency_key" ], name: "index_solid_queue_blocked_executions_for_maintenance" + t.index [ "job_id" ], name: "index_solid_queue_blocked_executions_on_job_id", unique: true + end + + create_table "solid_queue_claimed_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.bigint "process_id" + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_claimed_executions_on_job_id", unique: true + t.index [ "process_id", "job_id" ], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id" + end + + create_table "solid_queue_failed_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.text "error" + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_failed_executions_on_job_id", unique: true + end + + create_table "solid_queue_jobs", force: :cascade do |t| + t.string "queue_name", null: false + t.string "class_name", null: false + t.text "arguments" + t.integer "priority", default: 0, null: false + t.string "active_job_id" + t.datetime "scheduled_at" + t.datetime "finished_at" + t.string "concurrency_key" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "active_job_id" ], name: "index_solid_queue_jobs_on_active_job_id" + t.index [ "class_name" ], name: "index_solid_queue_jobs_on_class_name" + t.index [ "finished_at" ], name: "index_solid_queue_jobs_on_finished_at" + t.index [ "queue_name", "finished_at" ], name: "index_solid_queue_jobs_for_filtering" + t.index [ "scheduled_at", "finished_at" ], name: "index_solid_queue_jobs_for_alerting" + end + + create_table "solid_queue_pauses", force: :cascade do |t| + t.string "queue_name", null: false + t.datetime "created_at", null: false + t.index [ "queue_name" ], name: "index_solid_queue_pauses_on_queue_name", unique: true + end + + create_table "solid_queue_processes", force: :cascade do |t| + t.string "kind", null: false + t.datetime "last_heartbeat_at", null: false + t.bigint "supervisor_id" + t.integer "pid", null: false + t.string "hostname" + t.text "metadata" + t.datetime "created_at", null: false + t.string "name", null: false + t.index [ "last_heartbeat_at" ], name: "index_solid_queue_processes_on_last_heartbeat_at" + t.index [ "name", "supervisor_id" ], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true + t.index [ "supervisor_id" ], name: "index_solid_queue_processes_on_supervisor_id" + end + + create_table "solid_queue_ready_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_ready_executions_on_job_id", unique: true + t.index [ "priority", "job_id" ], name: "index_solid_queue_poll_all" + t.index [ "queue_name", "priority", "job_id" ], name: "index_solid_queue_poll_by_queue" + end + + create_table "solid_queue_recurring_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "task_key", null: false + t.datetime "run_at", null: false + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_recurring_executions_on_job_id", unique: true + t.index [ "task_key", "run_at" ], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true + end + + create_table "solid_queue_recurring_tasks", force: :cascade do |t| + t.string "key", null: false + t.string "schedule", null: false + t.string "command", limit: 2048 + t.string "class_name" + t.text "arguments" + t.string "queue_name" + t.integer "priority", default: 0 + t.boolean "static", default: true, null: false + t.text "description" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "key" ], name: "index_solid_queue_recurring_tasks_on_key", unique: true + t.index [ "static" ], name: "index_solid_queue_recurring_tasks_on_static" + end + + create_table "solid_queue_scheduled_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.datetime "scheduled_at", null: false + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true + t.index [ "scheduled_at", "priority", "job_id" ], name: "index_solid_queue_dispatch_all" + end + + create_table "solid_queue_semaphores", force: :cascade do |t| + t.string "key", null: false + t.integer "value", default: 1, null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "expires_at" ], name: "index_solid_queue_semaphores_on_expires_at" + t.index [ "key", "value" ], name: "index_solid_queue_semaphores_on_key_and_value" + t.index [ "key" ], name: "index_solid_queue_semaphores_on_key", unique: true + end + + add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade +end diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 0000000000..03e73681a8 --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,14 @@ +# 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[8.1].define(version: 0) do +end diff --git a/db/seeds.rb b/db/seeds.rb new file mode 100644 index 0000000000..4fbd6ed970 --- /dev/null +++ b/db/seeds.rb @@ -0,0 +1,9 @@ +# 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 diff --git a/features/step_definitions/.keep b/features/step_definitions/.keep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/features/support/env.rb b/features/support/env.rb new file mode 100644 index 0000000000..3d9cf223bc --- /dev/null +++ b/features/support/env.rb @@ -0,0 +1,58 @@ +require "simplecov" + +SimpleCov.command_name "cucumber" +SimpleCov.start "rails" + +# 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/lib/tasks/.keep b/lib/tasks/.keep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lib/tasks/cucumber.rake b/lib/tasks/cucumber.rake new file mode 100644 index 0000000000..b790c4d483 --- /dev/null +++ b/lib/tasks/cucumber.rake @@ -0,0 +1,68 @@ +# 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 0000000000..e69de29bb2 diff --git a/public/400.html b/public/400.html new file mode 100644 index 0000000000..640de03397 --- /dev/null +++ b/public/400.html @@ -0,0 +1,135 @@ + + + + + + + The server cannot process the request due to a client error (400 Bad Request) + + + + + + + + + + + + + +
+
+ +
+
+

The server cannot process the request due to a client error. Please check the request and try again. If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/public/404.html b/public/404.html new file mode 100644 index 0000000000..d7f0f14222 --- /dev/null +++ b/public/404.html @@ -0,0 +1,135 @@ + + + + + + + The page you were looking for doesn't exist (404 Not found) + + + + + + + + + + + + + +
+
+ +
+
+

The page you were looking for doesn't exist. You may have mistyped the address or the page may have moved. If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/public/406-unsupported-browser.html b/public/406-unsupported-browser.html new file mode 100644 index 0000000000..43d2811e8c --- /dev/null +++ b/public/406-unsupported-browser.html @@ -0,0 +1,135 @@ + + + + + + + Your browser is not supported (406 Not Acceptable) + + + + + + + + + + + + + +
+
+ +
+
+

Your browser is not supported.
Please upgrade your browser to continue.

+
+
+ + + + diff --git a/public/422.html b/public/422.html new file mode 100644 index 0000000000..f12fb4aa17 --- /dev/null +++ b/public/422.html @@ -0,0 +1,135 @@ + + + + + + + The change you wanted was rejected (422 Unprocessable Entity) + + + + + + + + + + + + + +
+
+ +
+
+

The change you wanted was rejected. Maybe you tried to change something you didn't have access to. If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/public/500.html b/public/500.html new file mode 100644 index 0000000000..e4eb18a759 --- /dev/null +++ b/public/500.html @@ -0,0 +1,135 @@ + + + + + + + We're sorry, but something went wrong (500 Internal Server Error) + + + + + + + + + + + + + +
+
+ +
+
+

We're sorry, but something went wrong.
If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/public/icon.png b/public/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c4c9dbfbbd2f7c1421ffd5727188146213abbcef GIT binary patch literal 4166 zcmd6qU;WFw?|v@m)Sk^&NvB8tcujdV-r1b=i(NJxn&7{KTb zX$3(M+3TP2o^#KAo{#tIjl&t~(8D-k004kqPglzn0HFG(Q~(I*AKsD#M*g7!XK0T7 zN6P7j>HcT8rZgKl$v!xr806dyN19Bd4C0x_R*I-a?#zsTvb_89cyhuC&T**i|Rc zq5b8M;+{8KvoJ~uj9`u~d_f6`V&3+&ZX9x5pc8s)d175;@pjm(?dapmBcm0&vl9+W zx1ZD2o^nuyUHWj|^A8r>lUorO`wFF;>9XL-Jy!P}UXC{(z!FO%SH~8k`#|9;Q|eue zqWL0^Bp(fg_+Pkm!fDKRSY;+^@BF?AJE zCUWpXPst~hi_~u)SzYBDZroR+Z4xeHIlm_3Yc_9nZ(o_gg!jDgVa=E}Y8uDgem9`b zf=mfJ_@(BXSkW53B)F2s!&?_R4ptb1fYXlF++@vPhd=marQgEGRZS@B4g1Mu?euknL= z67P~tZ?*>-Hmi7GwlisNHHJDku-dSm7g@!=a}9cSL6Pa^w^2?&?$Oi8ibrr>w)xqx zOH_EMU@m05)9kuNR>>4@H%|){U$^yvVQ(YgOlh;5oU_-vivG-p4=LrN-k7D?*?u1u zsWly%tfAzKd6Fb=`eU2un_uaTXmcT#tlOL+aRS=kZZf}A7qT8lvcTx~7j` z*b>=z)mwg7%B2_!D0!1IZ?Nq{^Y$uI4Qx*6T!E2Col&2{k?ImCO=dD~A&9f9diXy^$x{6CwkBimn|1E09 zAMSezYtiL?O6hS37KpvDM?22&d{l)7h-!F)C-d3j8Z`c@($?mfd{R82)H>Qe`h{~G z!I}(2j(|49{LR?w4Jspl_i!(4T{31|dqCOpI52r5NhxYV+cDAu(xp*4iqZ2e-$YP= zoFOPmm|u*7C?S{Fp43y+V;>~@FFR76bCl@pTtyB93vNWy5yf;HKr8^0d7&GVIslYm zo3Tgt@M!`8B6IW&lK{Xk>%zp41G%`(DR&^u z5^pwD4>E6-w<8Kl2DzJ%a@~QDE$(e87lNhy?-Qgep!$b?5f7+&EM7$e>|WrX+=zCb z=!f5P>MxFyy;mIRxjc(H*}mceXw5a*IpC0PEYJ8Y3{JdoIW)@t97{wcUB@u+$FCCO z;s2Qe(d~oJC^`m$7DE-dsha`glrtu&v&93IZadvl_yjp!c89>zo;Krk+d&DEG4?x$ zufC1n+c1XD7dolX1q|7}uelR$`pT0Z)1jun<39$Sn2V5g&|(j~Z!wOddfYiZo7)A< z!dK`aBHOOk+-E_xbWCA3VR-+o$i5eO9`rMI#p_0xQ}rjEpGW;U!&&PKnivOcG(|m9 z!C8?WC6nCXw25WVa*eew)zQ=h45k8jSIPbq&?VE{oG%?4>9rwEeB4&qe#?-y_es4c|7ufw%+H5EY#oCgv!Lzv291#-oNlX~X+Jl5(riC~r z=0M|wMOP)Tt8@hNg&%V@Z9@J|Q#K*hE>sr6@oguas9&6^-=~$*2Gs%h#GF@h)i=Im z^iKk~ipWJg1VrvKS;_2lgs3n1zvNvxb27nGM=NXE!D4C!U`f*K2B@^^&ij9y}DTLB*FI zEnBL6y{jc?JqXWbkIZd7I16hA>(f9T!iwbIxJj~bKPfrO;>%*5nk&Lf?G@c2wvGrY&41$W{7HM9+b@&XY@>NZM5s|EK_Dp zQX60CBuantx>|d#DsaZ*8MW(we|#KTYZ=vNa#d*DJQe6hr~J6{_rI#?wi@s|&O}FR zG$kfPxheXh1?IZ{bDT-CWB4FTvO-k5scW^mi8?iY5Q`f8JcnnCxiy@m@D-%lO;y0pTLhh6i6l@x52j=#^$5_U^os}OFg zzdHbo(QI`%9#o*r8GCW~T3UdV`szO#~)^&X_(VW>o~umY9-ns9-V4lf~j z`QBD~pJ4a#b`*6bJ^3RS5y?RAgF7K5$ll97Y8#WZduZ`j?IEY~H(s^doZg>7-tk*t z4_QE1%%bb^p~4F5SB$t2i1>DBG1cIo;2(xTaj*Y~hlM{tSDHojL-QPg%Mo%6^7FrpB*{ z4G0@T{-77Por4DCMF zB_5Y~Phv%EQ64W8^GS6h?x6xh;w2{z3$rhC;m+;uD&pR74j+i22P5DS-tE8ABvH(U~indEbBUTAAAXfHZg5QpB@TgV9eI<)JrAkOI z8!TSOgfAJiWAXeM&vR4Glh;VxH}WG&V$bVb`a`g}GSpwggti*&)taV1@Ak|{WrV|5 zmNYx)Ans=S{c52qv@+jmGQ&vd6>6yX6IKq9O$3r&0xUTdZ!m1!irzn`SY+F23Rl6# zFRxws&gV-kM1NX(3(gnKpGi0Q)Dxi~#?nyzOR9!en;Ij>YJZVFAL*=R%7y%Mz9hU% zs>+ZB?qRmZ)nISx7wxY)y#cd$iaC~{k0avD>BjyF1q^mNQ1QcwsxiTySe<6C&cC6P zE`vwO9^k-d`9hZ!+r@Jnr+MF*2;2l8WjZ}DrwDUHzSF{WoG zucbSWguA!3KgB3MU%HH`R;XqVv0CcaGq?+;v_A5A2kpmk5V%qZE3yzQ7R5XWhq=eR zyUezH=@V)y>L9T-M-?tW(PQYTRBKZSVb_!$^H-Pn%ea;!vS_?M<~Tm>_rWIW43sPW z=!lY&fWc1g7+r?R)0p8(%zp&vl+FK4HRkns%BW+Up&wK8!lQ2~bja|9bD12WrKn#M zK)Yl9*8$SI7MAwSK$%)dMd>o+1UD<2&aQMhyjS5R{-vV+M;Q4bzl~Z~=4HFj_#2V9 zB)Gfzx3ncy@uzx?yzi}6>d%-?WE}h7v*w)Jr_gBl!2P&F3DX>j_1#--yjpL%<;JMR z*b70Gr)MMIBWDo~#<5F^Q0$VKI;SBIRneuR7)yVsN~A9I@gZTXe)E?iVII+X5h0~H zx^c(fP&4>!*q>fb6dAOC?MI>Cz3kld#J*;uik+Ps49cwm1B4 zZc1|ZxYyTv;{Z!?qS=D)sgRKx^1AYf%;y_V&VgZglfU>d+Ufk5&LV$sKv}Hoj+s; xK3FZRYdhbXT_@RW*ff3@`D1#ps#~H)p+y&j#(J|vk^lW{fF9OJt5(B-_&*Xgn9~3N literal 0 HcmV?d00001 diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 0000000000..04b34bf83f --- /dev/null +++ b/public/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000000..c19f78ab68 --- /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/script/.keep b/script/.keep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb new file mode 100644 index 0000000000..ef75d46770 --- /dev/null +++ b/spec/rails_helper.rb @@ -0,0 +1,72 @@ +# 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? +# Uncomment the line below in case you have `--require rails_helper` in the `.rspec` file +# that will avoid rails generators crashing because migrations haven't been run yet +# return unless Rails.env.test? +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_by(&:to_s).each { |f| require f } + +# Ensures that the test database schema matches the current schema file. +# If there are pending migrations it will invoke `db:test:prepare` to +# recreate the test database by loading the schema. +# 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 uses metadata to mix in different behaviours to your tests, + # for example enabling you to call `get` and `post` in request specs. e.g.: + # + # RSpec.describe UsersController, type: :request do + # # ... + # end + # + # The different available types are documented in the features, such as in + # https://rspec.info/features/8-0/rspec-rails + # + # You can also infer these behaviours automatically by location, e.g. + # /spec/models would pull in the same behaviour as `type: :model` but this + # behaviour is considered legacy and will be removed in a future version. + # + # To enable this behaviour uncomment the line below. + # 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 0000000000..e674df7164 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,99 @@ +require "simplecov" + +SimpleCov.command_name "rspec" +SimpleCov.start "rails" + +# 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. +# +# See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration +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 0000000000..e69de29bb2 diff --git a/test/controllers/.keep b/test/controllers/.keep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/fixtures/files/.keep b/test/fixtures/files/.keep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/helpers/.keep b/test/helpers/.keep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/integration/.keep b/test/integration/.keep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/mailers/.keep b/test/mailers/.keep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/models/.keep b/test/models/.keep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000000..0c22470ec1 --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,15 @@ +ENV["RAILS_ENV"] ||= "test" +require_relative "../config/environment" +require "rails/test_help" + +module ActiveSupport + class TestCase + # Run tests in parallel with specified workers + parallelize(workers: :number_of_processors) + + # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. + fixtures :all + + # Add more helper methods to be used by all tests here... + end +end diff --git a/tmp/.keep b/tmp/.keep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tmp/pids/.keep b/tmp/pids/.keep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tmp/storage/.keep b/tmp/storage/.keep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/vendor/.keep b/vendor/.keep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/vendor/javascript/.keep b/vendor/javascript/.keep new file mode 100644 index 0000000000..e69de29bb2 From 4fe08232a078e57cf39972c212defe8c53d46563 Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Mon, 25 May 2026 17:58:10 -0300 Subject: [PATCH 002/125] =?UTF-8?q?test(templates):=20adiciona=20cen=C3=A1?= =?UTF-8?q?rios=20BDD=20para=20cria=C3=A7=C3=A3o=20de=20template?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 +- .rubocop.yml | 6 +++ features/templates/criar_template.feature | 50 +++++++++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 features/templates/criar_template.feature diff --git a/.gitignore b/.gitignore index bd7a0da827..47a1d71016 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,6 @@ /config/*.key /coverage/ -/doc/ \ No newline at end of file +/doc/ + +/.vscode/ diff --git a/.rubocop.yml b/.rubocop.yml index f9d86d4a54..53f1eb8cbf 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -6,3 +6,9 @@ inherit_gem: { rubocop-rails-omakase: rubocop.yml } # # Use `[a, [b, c]]` not `[ a, [ b, c ] ]` # Layout/SpaceInsideArrayLiteralBrackets: # Enabled: false + +Layout/IndentationWidth: + Width: 2 + +Layout/IndentationConsistency: + Enabled: true \ No newline at end of file diff --git a/features/templates/criar_template.feature b/features/templates/criar_template.feature new file mode 100644 index 0000000000..78ecefd14c --- /dev/null +++ b/features/templates/criar_template.feature @@ -0,0 +1,50 @@ +# language: pt +Funcionalidade: Criar template de formulário + Como Administrador + Quero criar um template de formulário contendo as questões do formulário + A fim de gerar formulários de avaliações para avaliar o desempenho das turmas + + Contexto: + Dado que existe um usuário administrador cadastrado no sistema + + @happy + Cenário: Administrador cria um template de formulário com sucesso + Dado que estou autenticado como administrador + Quando eu acesso a página de criação de template + E preencho o título do template com "Avaliação de Disciplina" + E preencho a descrição do template com "Template para avaliação semestral de disciplinas" + E adiciono uma questão de texto com enunciado "Descreva os pontos positivos da disciplina" + E adiciono uma questão de múltipla escolha com enunciado "Como você avalia a didática do professor?" + E adiciono as opções "Ruim", "Regular", "Boa" e "Excelente" para a questão de múltipla escolha + E confirmo a criação do template + Então devo ver uma mensagem informando que o template foi criado com sucesso + E devo ver o template "Avaliação de Disciplina" na lista de templates + + @sad + Cenário: Administrador tenta criar template sem título + Dado que estou autenticado como administrador + Quando eu acesso a página de criação de template + E deixo o título do template em branco + E preencho a descrição do template com "Template sem título" + E adiciono uma questão de texto com enunciado "Descreva os pontos positivos da disciplina" + E confirmo a criação do template + Então devo ver uma mensagem informando que o título do template é obrigatório + E o template não deve ser criado + + @sad + Cenário: Administrador tenta criar template sem questões + Dado que estou autenticado como administrador + Quando eu acesso a página de criação de template + E preencho o título do template com "Avaliação sem questões" + E preencho a descrição do template com "Template inválido sem questões" + E confirmo a criação do template + Então devo ver uma mensagem informando que o template deve possuir pelo menos uma questão + E o template não deve ser criado + + @sad + Cenário: Usuário não administrador tenta criar template + Dado que existe um usuário não administrador cadastrado no sistema + E que estou autenticado como usuário não administrador + Quando eu tento acessar a página de criação de template + Então devo ver uma mensagem informando que não tenho permissão para criar templates + E não devo ver o formulário de criação de template From 6eb50a62a70daade654cabe8eef8300f042e9277 Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Mon, 25 May 2026 17:58:25 -0300 Subject: [PATCH 003/125] =?UTF-8?q?test(templates):=20adiciona=20cen=C3=A1?= =?UTF-8?q?rios=20BDD=20para=20visualiza=C3=A7=C3=A3o=20de=20templates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../templates/visualizar_templates.feature | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 features/templates/visualizar_templates.feature diff --git a/features/templates/visualizar_templates.feature b/features/templates/visualizar_templates.feature new file mode 100644 index 0000000000..46947d74ce --- /dev/null +++ b/features/templates/visualizar_templates.feature @@ -0,0 +1,31 @@ +# language: pt +Funcionalidade: Visualizar templates criados + Como Administrador + Quero visualizar os templates criados + A fim de poder editar e/ou deletar um template que eu criei + + Contexto: + Dado que existe um usuário administrador cadastrado no sistema + + @happy + Cenário: Administrador visualiza a lista de templates criados + Dado que estou autenticado como administrador + E que existe um template chamado "Avaliação de Disciplina" criado por mim + E que existe um template chamado "Avaliação de Professor" criado por mim + Quando eu acesso a página de templates + Então devo ver o template "Avaliação de Disciplina" + E devo ver o template "Avaliação de Professor" + + @happy + Cenário: Administrador visualiza mensagem quando não possui templates criados + Dado que estou autenticado como administrador + E que não existem templates criados por mim + Quando eu acesso a página de templates + Então devo ver uma mensagem informando que nenhum template foi encontrado + + @sad + Cenário: Usuário não administrador tenta visualizar a página de templates + Dado que existe um usuário não administrador cadastrado no sistema + E que estou autenticado como usuário não administrador + Quando eu tento acessar a página de templates + Então devo ver uma mensagem informando que não tenho permissão para visualizar templates From 3f5f4106503715dcabfb4c8948eef0751e48cf5b Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Mon, 25 May 2026 17:58:41 -0300 Subject: [PATCH 004/125] =?UTF-8?q?test(templates):=20adiciona=20cen=C3=A1?= =?UTF-8?q?rios=20BDD=20para=20edi=C3=A7=C3=A3o=20e=20remo=C3=A7=C3=A3o=20?= =?UTF-8?q?de=20templates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../templates/editar_deletar_template.feature | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 features/templates/editar_deletar_template.feature diff --git a/features/templates/editar_deletar_template.feature b/features/templates/editar_deletar_template.feature new file mode 100644 index 0000000000..a6644aa91c --- /dev/null +++ b/features/templates/editar_deletar_template.feature @@ -0,0 +1,70 @@ +# language: pt +Funcionalidade: Editar e deletar template sem afetar formulários já criados + Como Administrador + Quero editar e/ou deletar um template que eu criei sem afetar os formulários já criados + A fim de organizar os templates existentes + + Contexto: + Dado que existe um usuário administrador cadastrado no sistema + + @happy + Cenário: Administrador edita um template criado por ele + Dado que estou autenticado como administrador + E que existe um template chamado "Avaliação de Disciplina" criado por mim + Quando eu acesso a página de edição do template "Avaliação de Disciplina" + E altero o título do template para "Avaliação Semestral de Disciplina" + E altero a descrição do template para "Template atualizado para avaliação semestral" + E confirmo a atualização do template + Então devo ver uma mensagem informando que o template foi atualizado com sucesso + E devo ver o template "Avaliação Semestral de Disciplina" na lista de templates + + @happy + Cenário: Administrador deleta um template criado por ele + Dado que estou autenticado como administrador + E que existe um template chamado "Avaliação de Disciplina" criado por mim + Quando eu acesso a página de templates + E solicito a exclusão do template "Avaliação de Disciplina" + E confirmo a exclusão do template + Então devo ver uma mensagem informando que o template foi removido com sucesso + E não devo ver o template "Avaliação de Disciplina" na lista de templates + + @happy + Cenário: Editar template não altera formulário já criado + Dado que estou autenticado como administrador + E que existe um template chamado "Avaliação de Disciplina" criado por mim + E que o template "Avaliação de Disciplina" possui a questão "Como você avalia a didática do professor?" + E que existe um formulário criado a partir do template "Avaliação de Disciplina" + Quando eu acesso a página de edição do template "Avaliação de Disciplina" + E altero a questão "Como você avalia a didática do professor?" para "Como você avalia a organização da disciplina?" + E confirmo a atualização do template + Então devo ver uma mensagem informando que o template foi atualizado com sucesso + E o formulário criado anteriormente deve continuar contendo a questão "Como você avalia a didática do professor?" + E o formulário criado anteriormente não deve conter a questão "Como você avalia a organização da disciplina?" + + @happy + Cenário: Deletar template não remove formulário já criado + Dado que estou autenticado como administrador + E que existe um template chamado "Avaliação de Disciplina" criado por mim + E que existe um formulário chamado "Formulário de Avaliação de Cálculo 1" criado a partir do template "Avaliação de Disciplina" + Quando eu acesso a página de templates + E solicito a exclusão do template "Avaliação de Disciplina" + E confirmo a exclusão do template + Então devo ver uma mensagem informando que o template foi removido com sucesso + E o formulário "Formulário de Avaliação de Cálculo 1" deve continuar existindo + + @sad + Cenário: Usuário não administrador tenta editar um template + Dado que existe um template chamado "Avaliação de Disciplina" + E que existe um usuário não administrador cadastrado no sistema + E que estou autenticado como usuário não administrador + Quando eu tento acessar a página de edição do template "Avaliação de Disciplina" + Então devo ver uma mensagem informando que não tenho permissão para editar templates + + @sad + Cenário: Usuário não administrador tenta deletar um template + Dado que existe um template chamado "Avaliação de Disciplina" + E que existe um usuário não administrador cadastrado no sistema + E que estou autenticado como usuário não administrador + Quando eu tento excluir o template "Avaliação de Disciplina" + Então devo ver uma mensagem informando que não tenho permissão para deletar templates + E o template "Avaliação de Disciplina" deve continuar existindo From fe28693b21ff7be81a33f1a81001dee9513fdfc8 Mon Sep 17 00:00:00 2001 From: PedroFFranca Date: Mon, 25 May 2026 21:34:42 -0300 Subject: [PATCH 005/125] =?UTF-8?q?test(sigaa):=20adiciona=20cen=C3=A1rios?= =?UTF-8?q?=20BDD=20para=20atualiza=C3=A7=C3=A3o=20da=20base=20de=20dados?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- features/sigaa/atualizacao_base.feature | 45 +++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 features/sigaa/atualizacao_base.feature diff --git a/features/sigaa/atualizacao_base.feature b/features/sigaa/atualizacao_base.feature new file mode 100644 index 0000000000..dbb4ad0259 --- /dev/null +++ b/features/sigaa/atualizacao_base.feature @@ -0,0 +1,45 @@ +# language: pt + +Funcionalidade: Atualizar base de dados com os dados do SIGAA + Eu como Administrador + Quero atualizar a base de dados já existente com os dados atuais do SIGAA + A fim de corrigir a base de dados do sistema. + + Contexto: + Dado que eu estou logado como Administrador + E estou na página "Gerenciamento" + + @happy + Cenário: Sincronizar participante que mudou de e-mail + Dado que o usuário "usuario" (190084006) já existe no sistema com o e-mail "usuario@email.com" + E a fonte de dados externa indica que o e-mail de "190084006" agora é "usuarionovo@gmail.com" + Quando eu clico no botão "Importar dados" + Então o e-mail do usuário "190084006" deve ser atualizado para "usuarionovo@gmail.com" + E nenhum usuário duplicado deve ser criado + E eu devo ver a mensagem de sucesso "Dados atualizados com sucesso!" + + @happy + Cenário: Sincronizar participante que mudou de nome + Dado que o usuário "nome genérico" (200033522) já existe no sistema com o nome "nome genérico" + E a fonte de dados externa indica que o nome de "200033522" agora é "nome não genérico" + Quando eu clico no botão "Importar dados" + Então o nome do usuário "200033522" deve ser atualizado para "nome não genérico" + E eu devo ver a mensagem de sucesso "Dados atualizados com sucesso!" + + + @happy + Cenário: Sincronizar participante com múltiplas mudanças + Dado que o usuário "usuario" (190084006) já existe no sistema com o e-mail "usuario@email.com" e o nome "usuario" + E a fonte de dados externa indica que o e-mail de "190084006" agora é "usuarioemail@gmail.com" e o nome agora é "usuario com email" + Quando eu clico no botão "Importar dados" + Então o e-mail do usuário "190084006" deve ser atualizado para "usuarioemail@gmail.com" + E o nome do usuário "190084006" deve ser atualizado para "usuario com email" + E eu devo ver a mensagem de sucesso "Dados atualizados com sucesso!" + + @sad + Cenário: Falha ao buscar os dados + Dado que o SIGAA está indisponível + Quando eu clico no botão "Importar dados" + Então eu devo ver a mensagem de erro "Não foi possível buscar os dados. Tente novamente mais tarde." + E nenhuma turma existente deve ser alterada no sistema + E nenhum usuário existente deve ser alterado no sistema From 1437b14f2db326afc9aaca83aa14f5d08dbd606c Mon Sep 17 00:00:00 2001 From: PedroFFranca Date: Mon, 25 May 2026 21:35:30 -0300 Subject: [PATCH 006/125] =?UTF-8?q?test(sigaa):=20adiciona=20cen=C3=A1rios?= =?UTF-8?q?=20BDD=20para=20importa=C3=A7=C3=A3o=20de=20dados=20do=20sigaa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- features/sigaa/importacao_dados.feature | 53 +++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 features/sigaa/importacao_dados.feature diff --git a/features/sigaa/importacao_dados.feature b/features/sigaa/importacao_dados.feature new file mode 100644 index 0000000000..41030900c6 --- /dev/null +++ b/features/sigaa/importacao_dados.feature @@ -0,0 +1,53 @@ +# language: pt + + +Funcionalidade: Importar novos dados do SIGAA + Eu como Administrador + Quero importar dados de turmas, matérias e participantes do SIGAA + A fim de alimentar a base de dados do sistema. + + Contexto: + Dado que eu estou logado como Administrador + E estou na página "Gerenciamento" + + @happy + Cenário: Importação inicial de turma e participante com sucesso + Dado que o sistema não possui nenhuma turma cadastrada + E que o sistema não possui nenhum usuário cadastrado + E que o SIGAA contém a turma "BANCOS DE DADOS" (CIC0097) + E esta turma contém o participante "usuario" (190084006) + Quando eu clico no botão "Importar dados" + Então a turma "BANCOS DE DADOS" (CIC0097) deve ser cadastrada no sistema + E o usuário "usuario" (190084006) deve ser cadastrado no sistema + E o usuário "usuario" deve estar matriculado na turma "BANCOS DE DADOS" + E eu devo ver a mensagem de sucesso "Dados importados com sucesso!" + + @happy + Cenário: Importação das turmas e matérias de classes.json com sucesso + Dado que o sistema não possui nenhuma turma cadastrada + E que o SIGAA contém as turmas "BANCOS DE DADOS" (CIC0097), "ENGENHARIA DE SOFTWARE" (CIC0105) e "PROGRAMAÇÃO CONCORRENTE" (CIC0202) + Quando eu clico no botão "Importar dados" + Então as 3 matérias devem ser cadastradas no sistema + E as 3 turmas devem ser cadastradas no sistema + E nenhuma matéria ou turma deve ser duplicada + E eu devo ver a mensagem de sucesso "Dados importados com sucesso!" + + + @happy + Cenário: Solicitar definição de senha para usuários novos importados + Dado que o sistema possui a turma "BANCOS DE DADOS" (CIC0097) cadastrada + E que o sistema não possui o usuário "usuario" (190084006) cadastrado + E que o SIGAA contém o participante "usuario" (190084006) na turma "BANCOS DE DADOS" (CIC0097) + Quando eu clico no botão "Importar dados" + Então o usuário "usuario" (190084006) deve estar pendente de definição de senha + E o usuário "usuario" (190084006) não deve possuir senha definida + E eu devo ver a mensagem de sucesso "Dados importados com sucesso!" + + @sad + Cenário: Falha ao importar dados com JSON inválido + Dado que o SIGAA retorna um arquivo JSON inválido + Quando eu clico no botão "Importar dados" + Então eu devo ver a mensagem de erro "Os dados recebidos do SIGAA são inválidos." + E nenhuma nova turma deve ser cadastrada no sistema + E nenhum novo usuário deve ser cadastrado no sistema + From 5345a52194c6875606fcb9546d5813119db13126 Mon Sep 17 00:00:00 2001 From: Marcio Vieira Date: Mon, 25 May 2026 21:52:39 -0300 Subject: [PATCH 007/125] =?UTF-8?q?test(avaliacoes):=20adiciona=20cen?= =?UTF-8?q?=C3=A1rios=20BDD=20para=20visualiza=C3=A7=C3=A3o=20de=20formul?= =?UTF-8?q?=C3=A1rios=20pendentes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../visualizar_formularios_pendentes.feature | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 features/avaliacoes/visualizar_formularios_pendentes.feature diff --git a/features/avaliacoes/visualizar_formularios_pendentes.feature b/features/avaliacoes/visualizar_formularios_pendentes.feature new file mode 100644 index 0000000000..7ae3bbd5d8 --- /dev/null +++ b/features/avaliacoes/visualizar_formularios_pendentes.feature @@ -0,0 +1,32 @@ +# language: pt +Funcionalidade: Visualizar formulários não respondidos + Como Participante de uma turma + Quero visualizar os formulários não respondidos das turmas em que estou matriculado + A fim de poder escolher qual irei responder + + Contexto: + Dado que existe um usuário participante cadastrado no sistema + + @happy + Cenário: Participante visualiza a lista de formulários pendentes + Dado que estou autenticado como participante + E que estou matriculado na turma "Cálculo 1" + E que existe um formulário pendente para a turma "Cálculo 1" + Quando eu acesso a página de avaliações pendentes + Então devo ver o formulário da turma "Cálculo 1" na lista + E o status do formulário deve ser "Não respondido" + + @happy + Cenário: Participante visualiza mensagem quando não possui formulários pendentes + Dado que estou autenticado como participante + E que não possuo formulários pendentes nas minhas turmas + Quando eu acesso a página de avaliações pendentes + Então devo ver uma mensagem informando que nenhum formulário pendente foi encontrado + + @sad + Cenário: Participante tenta visualizar formulários de turmas em que não participa + Dado que estou autenticado como participante + E que existe um formulário pendente para a turma "Estrutura de Dados" + E que não estou matriculado na turma "Estrutura de Dados" + Quando eu acesso a página de avaliações pendentes + Então não devo ver o formulário da turma "Estrutura de Dados" na lista \ No newline at end of file From de4ecadcc62a0e6b919a831e88420b3f5bf947d2 Mon Sep 17 00:00:00 2001 From: Marcio Vieira Date: Mon, 25 May 2026 21:57:52 -0300 Subject: [PATCH 008/125] =?UTF-8?q?test(avaliacoes):=20adiciona=20cen?= =?UTF-8?q?=C3=A1rios=20BDD=20para=20resposta=20de=20avalia=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../avaliacoes/responder_avaliacao.feature | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 features/avaliacoes/responder_avaliacao.feature diff --git a/features/avaliacoes/responder_avaliacao.feature b/features/avaliacoes/responder_avaliacao.feature new file mode 100644 index 0000000000..578e00d84c --- /dev/null +++ b/features/avaliacoes/responder_avaliacao.feature @@ -0,0 +1,33 @@ +# language: pt +Funcionalidade: Responder questionário da turma + Como Participante de uma turma + Quero responder o questionário sobre a turma em que estou matriculado + A fim de submeter minha avaliação da turma + + Contexto: + Dado que existe um usuário participante cadastrado no sistema + + @happy + Cenário: Participante responde todas as questões obrigatórias com sucesso + Dado que estou autenticado como participante + E que estou na página de resposta do formulário da turma "Cálculo 1" + Quando eu preencho todas as questões obrigatórias + E confirmo o envio da avaliação + Então devo ver uma mensagem informando que a avaliação foi registrada com sucesso + E o formulário da turma "Cálculo 1" não deve mais aparecer na lista de pendentes + + @sad + Cenário: Participante tenta enviar resposta incompleta + Dado que estou autenticado como participante + E que estou na página de resposta do formulário da turma "Cálculo 1" + Quando eu deixo uma questão obrigatória em branco + E confirmo o envio da avaliação + Então devo ver uma mensagem informando que todas as questões obrigatórias devem ser preenchidas + E a avaliação não deve ser registrada + + @sad + Cenário: Participante tenta responder formulário já respondido + Dado que estou autenticado como participante + E que já respondi o formulário da turma "Cálculo 1" anteriormente + Quando eu tento acessar a página de resposta do formulário da turma "Cálculo 1" + Então devo ver uma mensagem informando que esta avaliação já foi respondida \ No newline at end of file From fbe78d451cba7fcffc32e8bdbe48b5c979e9a037 Mon Sep 17 00:00:00 2001 From: Rafael-Sapienza Date: Mon, 25 May 2026 21:59:28 -0300 Subject: [PATCH 009/125] =?UTF-8?q?test(auth):=20adiciona=20cen=C3=A1rios?= =?UTF-8?q?=20BDD=20para=20login=20de=20usu=C3=A1rios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 ++- .ruby-version | 2 +- comentarios.txt | 5 +++ features/autenticacao/login.feature | 52 +++++++++++++++++++++++++++ issues.md | 56 +++++++++++++++++++++++++++++ 5 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 comentarios.txt create mode 100644 features/autenticacao/login.feature create mode 100755 issues.md diff --git a/.gitignore b/.gitignore index bd7a0da827..47a1d71016 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,6 @@ /config/*.key /coverage/ -/doc/ \ No newline at end of file +/doc/ + +/.vscode/ diff --git a/.ruby-version b/.ruby-version index 61a52c9149..7bcbb3808b 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -ruby-3.4.9 +3.4.9 diff --git a/comentarios.txt b/comentarios.txt new file mode 100644 index 0000000000..bed6749841 --- /dev/null +++ b/comentarios.txt @@ -0,0 +1,5 @@ +Para configurar onde fica db: Arquivo config/database.yml +db:create olha apenas para aquele arquivo config/database.yml e cria os arquivos vazios development.sqlite3 e test.sqlite3. +#### +docker run -it -v $(pwd):/app -w /app camaar rails db:create +docker run -it -v $(pwd):/app -w /app camaar rails generate migration CreatePeople name:string email:string diff --git a/features/autenticacao/login.feature b/features/autenticacao/login.feature new file mode 100644 index 0000000000..e9c527405e --- /dev/null +++ b/features/autenticacao/login.feature @@ -0,0 +1,52 @@ +# language: pt +Funcionalidade: Login de Usuários + Como Usuário do sistema + Quero acessar o sistema utilizando um e-mail ou matrícula e uma senha já cadastrada + A fim de responder formulários ou gerenciar o sistema + + Contexto: + Dado que a base de dados possui um usuário comum com e-mail "aluno@unb.br", matrícula "232000000" e senha "SenhaSegura123" + E possui um usuário administrador com matrícula "111222" e senha "Admin123" + + @cenario_feliz + Cenário: Login com sucesso utilizando e-mail como Usuário Comum + Dado que estou na página de login + Quando eu preencho o campo "E-mail ou Matrícula" com "aluno@unb.br" + E preencho o campo "Senha" com "SenhaSegura123" + E clico no botão "Entrar" + Então devo ser autenticado com sucesso + E não devo visualizar a opção de gerenciamento no menu lateral + + @cenario_feliz + Cenário: Login com sucesso utilizando matrícula como Administrador (Regra de Negócio) + Dado que estou na página de login + Quando eu preencho o campo "E-mail ou Matrícula" com "111222" + E preencho o campo "Senha" com "Admin123" + E clico no botão "Entrar" + Então devo ser autenticado com sucesso + E devo visualizar a opção de gerenciamento no menu lateral + + @cenario_triste + Cenário: Tentativa de login com senha incorreta + Dado que estou na página de login + Quando eu preencho o campo "E-mail ou Matrícula" com "aluno@unb.br" + E preencho o campo "Senha" com "SenhaErrada!" + E clico no botão "Entrar" + Então devo ver a mensagem de erro "E-mail/Matrícula ou senha inválidos" + E permaneço na página de login + + @cenario_triste + Cenário: Tentativa de login deixando campos vazios (Validação de campo) + Dado que estou na página de login + Quando eu deixo o campo "E-mail ou Matrícula" vazio + E deixo o campo "Senha" vazio + E clico no botão "Entrar" + Então devo ver o aviso "Campos obrigatórios não preenchidos" + + @cenario_triste + Cenário: Tentativa de login sem preencher a senha + Dado que estou na página de login + Quando eu preencho o campo "E-mail ou Matrícula" com "usuario@email.com" + E deixo o campo "Senha" vazio + E clico no botão "Entrar" + Então devo ver o aviso "O campo Senha é obrigatório" diff --git a/issues.md b/issues.md new file mode 100755 index 0000000000..c1251a832c --- /dev/null +++ b/issues.md @@ -0,0 +1,56 @@ + +--- + +### 👑 Stakeholder: Administrador (Admin) + +1. Quero escolher criar um formulário para os docentes ou os dicentes de uma turma +A fim de avaliar o desempenho de uma matéria +2. Quero editar e/ou deletar um template que eu criei sem afetar os formulários já criados +A fim de organizar os templates existentes +3. Quero visualizar os templates criados +A fim de poder editar e/ou deletar um template que eu criei +4. Quero visualizar os formulários criados +A fim de poder gerar um relatório a partir das respostas +5. Quero atualizar a base de dados já existente com os dados atuais do sigaa +A fim de corrigir a base de dados do sistema. +6. Quero gerenciar somente as turmas do departamento o qual eu pertenço +A fim de avaliar o desempenho das turmas no semestre atual +7. Quero criar um formulário baseado em um template para as turmas que eu escolher +A fim de avaliar o desempenho das turmas no semestre atual +8. Quero criar um template de formulário contendo as questões do formulário +A fim de gerar formulários de avaliações para avaliar o desempenho das turmas +9. Quero baixar um arquivo csv contendo os resultados de um formulário +A fim de avaliar o desempenho das turmas +10. Quero cadastrar participantes de turmas do SIGAA ao importar dados de usuarios novos para o sistema +A fim de que eles acessem o sistema CAMAAR +11. Quero importar dados de turmas, matérias e participantes do SIGAA (caso não existam na base de dados atual) +A fim de alimentar a base de dados do sistema. +*P.S. utilizar os JSONs presentes no repositório* --- + + + + +### ⚙️ Stakeholder: Usuário Geral + +14. Eu quero criar uma nova issue +De modo que ela apareça no meu Projects to Github. +15. Eu quero criar uma nova issue +De modo que ela apareça no meu Projects to Github. *(Nota: Esta issue estava duplicada na sua lista original)* +16. Quero redefinir uma senha para o meu usuário a partir do e-mail recebido após a solicitação da troca de senha +A fim de recuperar o meu acesso ao sistema +16. Quero definir uma senha para o meu usuário a partir do e-mail do sistema de solicitação de cadastro +A fim de acessar o sistema. +17. Quero acessar o sistema utilizando um e-mail ou matrícula e uma senha já cadastrada +A fim de responder formulários ou gerenciar o sistema +*Obs: Quando o Usuário logado for um admin, deve-se mostrar a opção de gerenciamento no menu lateral.* + + +### 👥 Stakeholder: Participantes + +12. **Eu como Participante de uma turma** Quero visualizar os formulários não respondidos das turmas em que estou matriculado +A fim de poder escolher qual irei responder +13. **Eu como Participante de uma turma** Quero responder o questionário sobre a turma em que estou matriculado +A fim de submeter minha avaliação da turma + +--- + From 497236ad6dde3d92186cbd648e5c39176019eb15 Mon Sep 17 00:00:00 2001 From: Rafael-Sapienza Date: Mon, 25 May 2026 22:09:35 -0300 Subject: [PATCH 010/125] =?UTF-8?q?test(auth):=20adiciona=20cen=C3=A1rios?= =?UTF-8?q?=20BDD=20para=20defini=C3=A7=C3=A3o=20de=20senha=20no=20primeir?= =?UTF-8?q?o=20acesso?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- features/autenticacao/definicao_senha.feature | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 features/autenticacao/definicao_senha.feature diff --git a/features/autenticacao/definicao_senha.feature b/features/autenticacao/definicao_senha.feature new file mode 100644 index 0000000000..ed28d1f9d8 --- /dev/null +++ b/features/autenticacao/definicao_senha.feature @@ -0,0 +1,35 @@ +# language: pt +Funcionalidade: Definição de Senha (Primeiro Acesso) + Como Usuário importado do SIGAA + Quero definir uma senha para o meu usuário a partir do e-mail do sistema de solicitação de cadastro + A fim de acessar o sistema + + Contexto: + Dado que fui importado do SIGAA mas ainda não possuo senha cadastrada + + @cenario_feliz + Cenário: Definição de senha com sucesso no primeiro acesso + Dado que acessei o link de ativação contido no e-mail de cadastro enviado pelo sistema + Quando eu preencho o campo "Nova Senha" com "NovaSenha123" + E preencho o campo "Confirme a Senha" com "NovaSenha123" + E clico em "Definir Senha" + Então meu usuário deve ser ativado na base de dados + E devo ver a mensagem "Cadastro ativado com sucesso!" + + @cenario_triste + Cenário: Tentar definir senha com campos divergentes + Dado que acessei o link de ativação contido no e-mail de cadastro enviado pelo sistema + Quando eu preencho o campo "Nova Senha" com "NovaSenha123" + E preencho o campo "Confirme a Senha" com "SenhaDiferente99" + E clico em "Definir Senha" + Então devo ver a mensagem de erro "A confirmação da senha não confere" + E o meu usuário deve continuar inativo + + @cenario_triste + Cenário: Tentar definir senha deixando os campos vazios (Validação de preenchimento) + Dado que acessei o link de ativação contido no e-mail de cadastro enviado pelo sistema + Quando eu deixo o campo "Nova Senha" vazio + E deixo o campo "Confirme a Senha" vazio + E clico em "Definir Senha" + Então devo ver a mensagem de erro "Os campos de senha são obrigatórios" + E o meu usuário deve continuar inativo From 3e5b1d4a167ca9478644d066724508b7d417b6cd Mon Sep 17 00:00:00 2001 From: Marcio Vieira Date: Mon, 25 May 2026 22:10:22 -0300 Subject: [PATCH 011/125] =?UTF-8?q?test(relatorios):=20adiciona=20cen?= =?UTF-8?q?=C3=A1rios=20BDD=20para=20exporta=C3=A7=C3=A3o=20de=20CSV=20e?= =?UTF-8?q?=20escopo=20departamental?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exportar_resultados_csv.feature | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 features/relatorios/exportar_resultados_csv.feature diff --git a/features/relatorios/exportar_resultados_csv.feature b/features/relatorios/exportar_resultados_csv.feature new file mode 100644 index 0000000000..6b9e47cf62 --- /dev/null +++ b/features/relatorios/exportar_resultados_csv.feature @@ -0,0 +1,56 @@ +# language: pt +Funcionalidade: Exportar resultados e controle departamental + Como Administrador + Quero gerenciar turmas do meu departamento e baixar os resultados em CSV + A fim de avaliar o desempenho das turmas + + Contexto: + Dado que existe um usuário administrador cadastrado no sistema + + @happy + Cenário: Administrador baixa arquivo CSV com respostas de um formulário do seu departamento + Dado que estou autenticado como administrador do "Departamento de Ciência da Computação" + E que existe um formulário com respostas para a turma "Engenharia de Software" + Quando eu acesso a página de relatórios do meu departamento + E solicito a exportação do formulário da turma "Engenharia de Software" + Então o download do arquivo CSV deve ser iniciado + E o CSV deve conter os dados esperados das avaliações + + @happy + Cenário: Administrador visualiza formulários restritos ao seu departamento + Dado que estou autenticado como administrador do "Departamento de Ciência da Computação" + E que existe a turma "Engenharia de Software" no meu departamento + E que existe a turma "Cálculo 1" no "Departamento de Matemática" + Quando eu acesso a página de relatórios + Então devo ver o formulário da turma "Engenharia de Software" na lista + E não devo ver o formulário da turma "Cálculo 1" + + @happy + Cenário: Administrador exporta CSV de um formulário sem respostas + Dado que estou autenticado como administrador do "Departamento de Ciência da Computação" + E que existe um formulário sem respostas para a turma "Estrutura de Dados" do meu departamento + Quando eu solicito a exportação do formulário da turma "Estrutura de Dados" + Então o download do arquivo CSV deve ser iniciado + E o arquivo CSV deve conter apenas a linha de cabeçalho + + @sad + Cenário: Administrador tenta acessar turma de outro departamento + Dado que estou autenticado como administrador do "Departamento de Ciência da Computação" + E que a turma "Cálculo 1" pertence ao "Departamento de Matemática" + Quando eu tento acessar a página de relatórios da turma "Cálculo 1" + Então devo ver uma mensagem informando que não possuo permissão para acessar turmas de outros departamentos + + @sad + Cenário: Administrador tenta exportar formulário de outro departamento sem permissão + Dado que estou autenticado como administrador do "Departamento de Ciência da Computação" + E que a turma "Cálculo 1" pertence ao "Departamento de Matemática" + Quando eu tento forçar a exportação do CSV da turma "Cálculo 1" + Então devo ver uma mensagem informando que a exportação não é permitida + E o arquivo CSV não deve ser gerado + + @sad + Cenário: Usuário não administrador tenta baixar arquivo CSV + Dado que existe um usuário participante cadastrado no sistema + E que estou autenticado como participante + Quando eu tento acessar a rota de exportação de resultados em CSV + Então devo ver uma mensagem informando que apenas administradores possuem acesso a este recurso \ No newline at end of file From 193ba6a9dbf7269d18a9f47186a38a2d90d2b3f2 Mon Sep 17 00:00:00 2001 From: Rafael-Sapienza Date: Mon, 25 May 2026 22:17:44 -0300 Subject: [PATCH 012/125] =?UTF-8?q?test(auth):=20adiciona=20cen=C3=A1rios?= =?UTF-8?q?=20BDD=20para=20redefini=C3=A7=C3=A3o=20de=20senha=20esquecida?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../autenticacao/redefinicao_senha.feature | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 features/autenticacao/redefinicao_senha.feature diff --git a/features/autenticacao/redefinicao_senha.feature b/features/autenticacao/redefinicao_senha.feature new file mode 100644 index 0000000000..1744a10a11 --- /dev/null +++ b/features/autenticacao/redefinicao_senha.feature @@ -0,0 +1,40 @@ +# language: pt +Funcionalidade: Redefinição de Senha (Recuperação) + Como Usuário cadastrado + Quero solicitar a recuperação de senha e definir uma nova credencial + A fim de recuperar o meu acesso ao sistema + + # --- ETAPA 1: SOLICITAÇÃO DO LINK DE RECUPERAÇÃO --- + @cenario_feliz + Cenário: Solicitar link de recuperação de senha com sucesso + Dado que estou na página de login + Quando eu clico em "Esqueci minha senha" + E preencho o campo "E-mail" com "usuario.valido@email.com" + E clico em "Enviar link de recuperação" + Então devo ver a mensagem "Um link de redefinição foi enviado para o seu e-mail" + + @cenario_triste + Cenário: Tentar solicitar recuperação com e-mail não cadastrado ou com erro de digitação + Dado que estou na página de login + Quando eu clico em "Esqueci minha senha" + E preencho o campo "E-mail" com "usuarrio.errado@email.com" + E clico em "Enviar link de recuperação" + Então devo ver a mensagem de erro "E-mail não encontrado no sistema" + + # --- ETAPA 2: REDEFINIÇÃO DA SENHA VIA LINK --- + @cenario_feliz + Cenário: Redefinição de senha com sucesso através de token válido + Dado que solicitei a recuperação de senha e recebi o e-mail com o link de redefinição + Quando eu acesso o link de redefinição do e-mail dentro do prazo de validade + E preencho a nova senha com "MinhaNovaSenha77" + E confirmo a nova senha com "MinhaNovaSenha77" + E clico em "Atualizar Senha" + Então minha senha deve ser atualizada no sistema + E devo ser redirecionado para a página de login + + @cenario_triste + Cenário: Tentar redefinir a senha utilizando um link expirado (Regra de Negócio) + Dado que solicitei a recuperação de senha e recebi o e-mail há mais de 24 horas + Quando eu tento acessar o link de redefinição contido no e-mail + Então eu devo ser redirecionado para a página de solicitação de recuperação + E devo ver a mensagem de erro "Este link de recuperação expirou" From b9d2528b849f9a882495cd2c2a436e82bce14e99 Mon Sep 17 00:00:00 2001 From: Henrique Morcelles <94870848+Henrique-zoo@users.noreply.github.com> Date: Tue, 26 May 2026 13:30:16 -0300 Subject: [PATCH 013/125] =?UTF-8?q?Renomea=C3=A7=C3=A3o=20das=20tags=20de?= =?UTF-8?q?=20cen=C3=A1rios=20para=20manter=20o=20padr=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PRs anteriores estabeleceram o padrão `@happy` e `@sad` para o caminho feliz e triste, respectivamente. --- features/autenticacao/definicao_senha.feature | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/features/autenticacao/definicao_senha.feature b/features/autenticacao/definicao_senha.feature index ed28d1f9d8..06d0874d2c 100644 --- a/features/autenticacao/definicao_senha.feature +++ b/features/autenticacao/definicao_senha.feature @@ -7,7 +7,7 @@ Funcionalidade: Definição de Senha (Primeiro Acesso) Contexto: Dado que fui importado do SIGAA mas ainda não possuo senha cadastrada - @cenario_feliz + @happy Cenário: Definição de senha com sucesso no primeiro acesso Dado que acessei o link de ativação contido no e-mail de cadastro enviado pelo sistema Quando eu preencho o campo "Nova Senha" com "NovaSenha123" @@ -16,7 +16,7 @@ Funcionalidade: Definição de Senha (Primeiro Acesso) Então meu usuário deve ser ativado na base de dados E devo ver a mensagem "Cadastro ativado com sucesso!" - @cenario_triste + @sad Cenário: Tentar definir senha com campos divergentes Dado que acessei o link de ativação contido no e-mail de cadastro enviado pelo sistema Quando eu preencho o campo "Nova Senha" com "NovaSenha123" @@ -25,7 +25,7 @@ Funcionalidade: Definição de Senha (Primeiro Acesso) Então devo ver a mensagem de erro "A confirmação da senha não confere" E o meu usuário deve continuar inativo - @cenario_triste + @sad Cenário: Tentar definir senha deixando os campos vazios (Validação de preenchimento) Dado que acessei o link de ativação contido no e-mail de cadastro enviado pelo sistema Quando eu deixo o campo "Nova Senha" vazio From a4d1695014649332d49d68a77d470e17673fe856 Mon Sep 17 00:00:00 2001 From: Henrique Morcelles <94870848+Henrique-zoo@users.noreply.github.com> Date: Tue, 26 May 2026 13:31:38 -0300 Subject: [PATCH 014/125] =?UTF-8?q?Padroniza=C3=A7=C3=A3o=20das=20tags=20n?= =?UTF-8?q?o=20arquivo=20`login.feature`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- features/autenticacao/login.feature | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/features/autenticacao/login.feature b/features/autenticacao/login.feature index e9c527405e..4272414157 100644 --- a/features/autenticacao/login.feature +++ b/features/autenticacao/login.feature @@ -8,7 +8,7 @@ Funcionalidade: Login de Usuários Dado que a base de dados possui um usuário comum com e-mail "aluno@unb.br", matrícula "232000000" e senha "SenhaSegura123" E possui um usuário administrador com matrícula "111222" e senha "Admin123" - @cenario_feliz + @happy Cenário: Login com sucesso utilizando e-mail como Usuário Comum Dado que estou na página de login Quando eu preencho o campo "E-mail ou Matrícula" com "aluno@unb.br" @@ -17,7 +17,7 @@ Funcionalidade: Login de Usuários Então devo ser autenticado com sucesso E não devo visualizar a opção de gerenciamento no menu lateral - @cenario_feliz + @happy Cenário: Login com sucesso utilizando matrícula como Administrador (Regra de Negócio) Dado que estou na página de login Quando eu preencho o campo "E-mail ou Matrícula" com "111222" @@ -26,7 +26,7 @@ Funcionalidade: Login de Usuários Então devo ser autenticado com sucesso E devo visualizar a opção de gerenciamento no menu lateral - @cenario_triste + @sad Cenário: Tentativa de login com senha incorreta Dado que estou na página de login Quando eu preencho o campo "E-mail ou Matrícula" com "aluno@unb.br" @@ -35,7 +35,7 @@ Funcionalidade: Login de Usuários Então devo ver a mensagem de erro "E-mail/Matrícula ou senha inválidos" E permaneço na página de login - @cenario_triste + @sad Cenário: Tentativa de login deixando campos vazios (Validação de campo) Dado que estou na página de login Quando eu deixo o campo "E-mail ou Matrícula" vazio @@ -43,7 +43,7 @@ Funcionalidade: Login de Usuários E clico no botão "Entrar" Então devo ver o aviso "Campos obrigatórios não preenchidos" - @cenario_triste + @sad Cenário: Tentativa de login sem preencher a senha Dado que estou na página de login Quando eu preencho o campo "E-mail ou Matrícula" com "usuario@email.com" From 37bec9c0d8074314e9f9986dd8d337d87f98ba31 Mon Sep 17 00:00:00 2001 From: Henrique Morcelles <94870848+Henrique-zoo@users.noreply.github.com> Date: Tue, 26 May 2026 13:34:29 -0300 Subject: [PATCH 015/125] =?UTF-8?q?Padroniza=C3=A7=C3=A3o=20das=20tags=20n?= =?UTF-8?q?o=20arquivo=20`redefinicao=5Fsenha.feature`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- features/autenticacao/redefinicao_senha.feature | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/features/autenticacao/redefinicao_senha.feature b/features/autenticacao/redefinicao_senha.feature index 1744a10a11..d55dca6696 100644 --- a/features/autenticacao/redefinicao_senha.feature +++ b/features/autenticacao/redefinicao_senha.feature @@ -5,7 +5,7 @@ Funcionalidade: Redefinição de Senha (Recuperação) A fim de recuperar o meu acesso ao sistema # --- ETAPA 1: SOLICITAÇÃO DO LINK DE RECUPERAÇÃO --- - @cenario_feliz + @happy Cenário: Solicitar link de recuperação de senha com sucesso Dado que estou na página de login Quando eu clico em "Esqueci minha senha" @@ -13,7 +13,7 @@ Funcionalidade: Redefinição de Senha (Recuperação) E clico em "Enviar link de recuperação" Então devo ver a mensagem "Um link de redefinição foi enviado para o seu e-mail" - @cenario_triste + @sad Cenário: Tentar solicitar recuperação com e-mail não cadastrado ou com erro de digitação Dado que estou na página de login Quando eu clico em "Esqueci minha senha" @@ -22,7 +22,7 @@ Funcionalidade: Redefinição de Senha (Recuperação) Então devo ver a mensagem de erro "E-mail não encontrado no sistema" # --- ETAPA 2: REDEFINIÇÃO DA SENHA VIA LINK --- - @cenario_feliz + @happy Cenário: Redefinição de senha com sucesso através de token válido Dado que solicitei a recuperação de senha e recebi o e-mail com o link de redefinição Quando eu acesso o link de redefinição do e-mail dentro do prazo de validade @@ -32,7 +32,7 @@ Funcionalidade: Redefinição de Senha (Recuperação) Então minha senha deve ser atualizada no sistema E devo ser redirecionado para a página de login - @cenario_triste + @sad Cenário: Tentar redefinir a senha utilizando um link expirado (Regra de Negócio) Dado que solicitei a recuperação de senha e recebi o e-mail há mais de 24 horas Quando eu tento acessar o link de redefinição contido no e-mail From 4805b00b0d1b318247c0b8eb370d6a73663633f6 Mon Sep 17 00:00:00 2001 From: Henrique Morcelles <94870848+Henrique-zoo@users.noreply.github.com> Date: Tue, 26 May 2026 13:48:47 -0300 Subject: [PATCH 016/125] =?UTF-8?q?fix(auth):=20dele=C3=A7=C3=A3o=20de=20a?= =?UTF-8?q?rquivo=20desnecess=C3=A1rio=20(provavelmente=20commitado=20por?= =?UTF-8?q?=20engano)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- comentarios.txt | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 comentarios.txt diff --git a/comentarios.txt b/comentarios.txt deleted file mode 100644 index bed6749841..0000000000 --- a/comentarios.txt +++ /dev/null @@ -1,5 +0,0 @@ -Para configurar onde fica db: Arquivo config/database.yml -db:create olha apenas para aquele arquivo config/database.yml e cria os arquivos vazios development.sqlite3 e test.sqlite3. -#### -docker run -it -v $(pwd):/app -w /app camaar rails db:create -docker run -it -v $(pwd):/app -w /app camaar rails generate migration CreatePeople name:string email:string From 70578c3d617527e571c58a9193031ac2664694df Mon Sep 17 00:00:00 2001 From: Henrique Morcelles <94870848+Henrique-zoo@users.noreply.github.com> Date: Tue, 26 May 2026 14:05:08 -0300 Subject: [PATCH 017/125] =?UTF-8?q?fix(auth):=20dele=C3=A7=C3=A3o=20de=20a?= =?UTF-8?q?rquivo=20desnecess=C3=A1rio=20(provavelmente=20commitado=20por?= =?UTF-8?q?=20engano)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit o arquivo deletado foi `issues.md` --- issues.md | 56 ------------------------------------------------------- 1 file changed, 56 deletions(-) delete mode 100755 issues.md diff --git a/issues.md b/issues.md deleted file mode 100755 index c1251a832c..0000000000 --- a/issues.md +++ /dev/null @@ -1,56 +0,0 @@ - ---- - -### 👑 Stakeholder: Administrador (Admin) - -1. Quero escolher criar um formulário para os docentes ou os dicentes de uma turma -A fim de avaliar o desempenho de uma matéria -2. Quero editar e/ou deletar um template que eu criei sem afetar os formulários já criados -A fim de organizar os templates existentes -3. Quero visualizar os templates criados -A fim de poder editar e/ou deletar um template que eu criei -4. Quero visualizar os formulários criados -A fim de poder gerar um relatório a partir das respostas -5. Quero atualizar a base de dados já existente com os dados atuais do sigaa -A fim de corrigir a base de dados do sistema. -6. Quero gerenciar somente as turmas do departamento o qual eu pertenço -A fim de avaliar o desempenho das turmas no semestre atual -7. Quero criar um formulário baseado em um template para as turmas que eu escolher -A fim de avaliar o desempenho das turmas no semestre atual -8. Quero criar um template de formulário contendo as questões do formulário -A fim de gerar formulários de avaliações para avaliar o desempenho das turmas -9. Quero baixar um arquivo csv contendo os resultados de um formulário -A fim de avaliar o desempenho das turmas -10. Quero cadastrar participantes de turmas do SIGAA ao importar dados de usuarios novos para o sistema -A fim de que eles acessem o sistema CAMAAR -11. Quero importar dados de turmas, matérias e participantes do SIGAA (caso não existam na base de dados atual) -A fim de alimentar a base de dados do sistema. -*P.S. utilizar os JSONs presentes no repositório* --- - - - - -### ⚙️ Stakeholder: Usuário Geral - -14. Eu quero criar uma nova issue -De modo que ela apareça no meu Projects to Github. -15. Eu quero criar uma nova issue -De modo que ela apareça no meu Projects to Github. *(Nota: Esta issue estava duplicada na sua lista original)* -16. Quero redefinir uma senha para o meu usuário a partir do e-mail recebido após a solicitação da troca de senha -A fim de recuperar o meu acesso ao sistema -16. Quero definir uma senha para o meu usuário a partir do e-mail do sistema de solicitação de cadastro -A fim de acessar o sistema. -17. Quero acessar o sistema utilizando um e-mail ou matrícula e uma senha já cadastrada -A fim de responder formulários ou gerenciar o sistema -*Obs: Quando o Usuário logado for um admin, deve-se mostrar a opção de gerenciamento no menu lateral.* - - -### 👥 Stakeholder: Participantes - -12. **Eu como Participante de uma turma** Quero visualizar os formulários não respondidos das turmas em que estou matriculado -A fim de poder escolher qual irei responder -13. **Eu como Participante de uma turma** Quero responder o questionário sobre a turma em que estou matriculado -A fim de submeter minha avaliação da turma - ---- - From bd0f5e7d4d80539b057bcdd15bbd76b1e9d3c025 Mon Sep 17 00:00:00 2001 From: Henrique Morcelles <94870848+Henrique-zoo@users.noreply.github.com> Date: Tue, 26 May 2026 14:09:17 -0300 Subject: [PATCH 018/125] =?UTF-8?q?fix(ruby=20version):=20manter=20no=20pa?= =?UTF-8?q?dr=C3=A3o=20do=20`rails=20new`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .ruby-version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ruby-version b/.ruby-version index 7bcbb3808b..61a52c9149 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.4.9 +ruby-3.4.9 From 6ac44d1945102fe13adbef1ba2f11502b4cc178d Mon Sep 17 00:00:00 2001 From: Henrique Franca <242039130@aluno.unb.br> Date: Tue, 26 May 2026 18:53:26 -0300 Subject: [PATCH 019/125] =?UTF-8?q?test(forms):=20adiciona=20cen=C3=A1rios?= =?UTF-8?q?=20BDD=20para=20cria=C3=A7=C3=A3o=20de=20formul=C3=A1rio=20a=20?= =?UTF-8?q?partir=20de=20template?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- features/forms/criar_formulario.feature | 28 +++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 features/forms/criar_formulario.feature diff --git a/features/forms/criar_formulario.feature b/features/forms/criar_formulario.feature new file mode 100644 index 0000000000..649da5793c --- /dev/null +++ b/features/forms/criar_formulario.feature @@ -0,0 +1,28 @@ +# language: pt +Funcionalidade: Criar formulário a partir de template + Como Administrador + Quero criar um formulário baseado em um template para as turmas que eu escolher + A fim de avaliar o desempenho das turmas no semestre atual + + Contexto: + Dado que estou autenticado como administrador + E que existe um template cadastrado chamado "Avaliação de Desempenho Docente" + E que existem as turmas "Métodos de Desenvolvimento de Software - Turma A" e "Interação Humano Computador - Turma B" cadastradas no semestre atual + + @happy + Cenário: Criar formulário para múltiplas turmas com sucesso + Dado que estou na página de criação de formulários + Quando eu seleciono o template "Avaliação de Desempenho Docente" + E seleciono as turmas "Métodos de Desenvolvimento de Software - Turma A" e "Interação Humano Computador - Turma B" + E clico em "Criar Formulário" + Então o formulário deve ser gerado com sucesso para ambas as turmas + E devo ver a mensagem "Formulário criado com sucesso para as turmas selecionadas" + + @sad + Cenário: Tentar criar formulário sem selecionar nenhuma turma + Dado que estou na página de criação de formulários + Quando eu seleciono o template "Avaliação de Desempenho Docente" + E não seleciono nenhuma turma + E clico em "Criar Formulário" + Então eu devo ver uma mensagem de erro dizendo "É necessário selecionar pelo menos uma turma" + E nenhum formulário deve ser gerado \ No newline at end of file From 0bb921818bc9bf1c9cdbb310805a9527a73fd2c7 Mon Sep 17 00:00:00 2001 From: Henrique Franca <242039130@aluno.unb.br> Date: Tue, 26 May 2026 18:55:26 -0300 Subject: [PATCH 020/125] =?UTF-8?q?test(forms):=20adiciona=20cen=C3=A1rios?= =?UTF-8?q?=20BDD=20para=20defini=C3=A7=C3=A3o=20de=20p=C3=BAblico-alvo=20?= =?UTF-8?q?do=20formul=C3=A1rio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- features/forms/escolher_publico_alvo.feature | 33 ++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 features/forms/escolher_publico_alvo.feature diff --git a/features/forms/escolher_publico_alvo.feature b/features/forms/escolher_publico_alvo.feature new file mode 100644 index 0000000000..d073017cca --- /dev/null +++ b/features/forms/escolher_publico_alvo.feature @@ -0,0 +1,33 @@ +# language: pt +Funcionalidade: Escolher público-alvo do formulário + Como Administrador + Quero escolher criar um formulário para os docentes ou os discentes de uma turma + A fim de avaliar o desempenho de uma matéria + + Contexto: + Dado que estou autenticado como administrador + E que selecionei o template "Avaliação Geral de Disciplina" + E selecionei a turma "Estrutura de Dados - Turma C" + + @happy + Cenário: Criar formulário direcionado estritamente para os discentes (alunos) + Dado que estou na etapa de definição de público-alvo + Quando eu seleciono a opção de público-alvo como "Discentes" + E confirmo a publicação do formulário + Então o formulário deve ficar disponível apenas para os alunos matriculados na turma "Estrutura de Dados - Turma C" + E os docentes da turma não devem ter acesso para responder a este formulário + + @happy + Cenário: Criar formulário direcionado estritamente para os docentes (professores) + Dado que estou na etapa de definição de público-alvo + Quando eu seleciono a opção de público-alvo como "Docentes" + E confirmo a publicação do formulário + Então o formulário deve ficar disponível apenas para os professores vinculados à turma "Estrutura de Dados - Turma C" + + @sad + Cenário: Tentar avançar sem definir o público-alvo do formulário + Dado que estou na etapa de definição de público-alvo + Quando eu não seleciono nem "Docentes" e nem "Discentes" + E clico em "Confirmar Publicação" + Então eu devo ver o alerta "Por favor, selecione o público-alvo do formulário" + E o formulário não deve ser publicado \ No newline at end of file From 2041726c2c44e6d9fbaba7c12df33ebc26e8aa42 Mon Sep 17 00:00:00 2001 From: Henrique Franca <242039130@aluno.unb.br> Date: Tue, 26 May 2026 18:56:41 -0300 Subject: [PATCH 021/125] =?UTF-8?q?test(forms):=20adiciona=20cen=C3=A1rios?= =?UTF-8?q?=20BDD=20para=20visualiza=C3=A7=C3=A3o=20de=20formul=C3=A1rios?= =?UTF-8?q?=20criados?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- features/forms/visualizar_formularios.feature | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 features/forms/visualizar_formularios.feature diff --git a/features/forms/visualizar_formularios.feature b/features/forms/visualizar_formularios.feature new file mode 100644 index 0000000000..682d0b774f --- /dev/null +++ b/features/forms/visualizar_formularios.feature @@ -0,0 +1,22 @@ +# language: pt +Funcionalidade: Visualizar formulários criados + Como Administrador + Quero visualizar os formulários criados + A fim de poder gerar um relatório a partir das respostas + + Contexto: + Dado que estou autenticado como administrador + + @happy + Cenário: Visualizar listagem de formulários ativos e acessar relatório de respostas + Dado que existem formulários criados para o semestre atual + Quando eu acesso o painel de gerenciamento de formulários + Então eu devo ver uma lista com todos os formulários criados, exibindo o template base, a turma e o público-alvo de cada um + E cada formulário listado deve exibir um botão "Gerar Relatório de Respostas" + + @sad + Cenário: Visualizar listagem quando nenhum formulário foi criado ainda + Dado que nenhum formulário foi gerado para o semestre vigente + Quando eu acesso o painel de gerenciamento de formulários + Então eu devo ver a listagem vazia + E a mensagem "Nenhum formulário criado para este semestre" deve ser exibida na tela \ No newline at end of file From 1d1af270e3655513f19a64be5d51aed269ff8bf9 Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Tue, 26 May 2026 19:26:41 -0300 Subject: [PATCH 022/125] =?UTF-8?q?test(relatorios):=20adiciona=20cen?= =?UTF-8?q?=C3=A1rios=20BDD=20para=20controle=20departamental=20do=20admin?= =?UTF-8?q?istrador?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../relatorios/controle_departamental.feature | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 features/relatorios/controle_departamental.feature diff --git a/features/relatorios/controle_departamental.feature b/features/relatorios/controle_departamental.feature new file mode 100644 index 0000000000..214dc8db58 --- /dev/null +++ b/features/relatorios/controle_departamental.feature @@ -0,0 +1,76 @@ +# language: pt +Funcionalidade: Controle departamental de administradores + Como Administrador + Quero gerenciar somente as turmas do departamento ao qual pertenço + A fim de avaliar o desempenho das turmas no semestre atual + + Contexto: + Dado que existe um departamento chamado "Departamento de Ciência da Computação" + E que existe um departamento chamado "Departamento de Matemática" + E que existe um administrador pertencente ao departamento "Departamento de Ciência da Computação" + E que estou autenticado como esse administrador + + @happy + Cenário: Administrador visualiza turma pertencente ao seu departamento + Dado que existe uma matéria chamada "Introdução à Ciência da Computação" pertencente ao departamento "Departamento de Ciência da Computação" + E que existe uma turma "A" da matéria "Introdução à Ciência da Computação" no semestre atual + Quando eu acesso a página de gerenciamento de turmas + Então devo ver a turma "A" da matéria "Introdução à Ciência da Computação" + + @happy + Cenário: Administrador cria formulário para turma pertencente ao seu departamento + Dado que existe uma matéria chamada "Introdução à Ciência da Computação" pertencente ao departamento "Departamento de Ciência da Computação" + E que existe uma turma "A" da matéria "Introdução à Ciência da Computação" no semestre atual + E que existe um template chamado "Avaliação de Disciplina" + Quando eu acesso a página de criação de formulário + E seleciono a turma "A" da matéria "Introdução à Ciência da Computação" + E seleciono o template "Avaliação de Disciplina" + E seleciono o público-alvo "discentes" + E confirmo a criação do formulário + Então devo ver uma mensagem informando que o formulário foi criado com sucesso + E o formulário deve estar associado à turma "A" da matéria "Introdução à Ciência da Computação" + + @happy + Cenário: Administrador visualiza formulário de turma pertencente ao seu departamento + Dado que existe uma matéria chamada "Introdução à Ciência da Computação" pertencente ao departamento "Departamento de Ciência da Computação" + E que existe uma turma "A" da matéria "Introdução à Ciência da Computação" no semestre atual + E que existe um formulário chamado "Avaliação de ICC" associado à turma "A" da matéria "Introdução à Ciência da Computação" + Quando eu acesso a página de formulários criados + Então devo ver o formulário "Avaliação de ICC" + + @sad + Cenário: Administrador não visualiza turma pertencente a outro departamento + Dado que existe uma matéria chamada "Cálculo 1" pertencente ao departamento "Departamento de Matemática" + E que existe uma turma "B" da matéria "Cálculo 1" no semestre atual + Quando eu acesso a página de gerenciamento de turmas + Então não devo ver a turma "B" da matéria "Cálculo 1" + + @sad + Cenário: Administrador não consegue criar formulário para turma de outro departamento + Dado que existe uma matéria chamada "Cálculo 1" pertencente ao departamento "Departamento de Matemática" + E que existe uma turma "B" da matéria "Cálculo 1" no semestre atual + E que existe um template chamado "Avaliação de Disciplina" + Quando eu acesso a página de criação de formulário + E seleciono a turma "B" da matéria "Cálculo 1" + E seleciono o template "Avaliação de Disciplina" + E seleciono o público-alvo "discentes" + E confirmo a criação do formulário + Então devo ver uma mensagem informando que não tenho permissão para gerenciar essa turma + E o formulário não deve ser criado + + @sad + Cenário: Administrador não consegue acessar formulário de turma de outro departamento + Dado que existe uma matéria chamada "Cálculo 1" pertencente ao departamento "Departamento de Matemática" + E que existe uma turma "B" da matéria "Cálculo 1" no semestre atual + E que existe um formulário chamado "Avaliação de Cálculo 1" associado à turma "B" da matéria "Cálculo 1" + Quando eu tento acessar o formulário "Avaliação de Cálculo 1" + Então devo ver uma mensagem informando que não tenho permissão para acessar esse formulário + + @sad + Cenário: Administrador não consegue exportar resultados de formulário de outro departamento + Dado que existe uma matéria chamada "Cálculo 1" pertencente ao departamento "Departamento de Matemática" + E que existe uma turma "B" da matéria "Cálculo 1" no semestre atual + E que existe um formulário chamado "Avaliação de Cálculo 1" associado à turma "B" da matéria "Cálculo 1" + Quando eu tento exportar os resultados do formulário "Avaliação de Cálculo 1" + Então devo ver uma mensagem informando que não tenho permissão para exportar os resultados desse formulário + E nenhum arquivo CSV deve ser baixado From 2af814a9ac704c670ad4f35bc4bfd7c085ea29c1 Mon Sep 17 00:00:00 2001 From: PedroFFranca Date: Tue, 26 May 2026 19:58:41 -0300 Subject: [PATCH 023/125] =?UTF-8?q?=20fix(sigaa):=20arrumando=20a=20format?= =?UTF-8?q?acao=20de=20importa=C3=A7=C3=A3o=20de=20dados?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- features/sigaa/importacao_dados.feature | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/features/sigaa/importacao_dados.feature b/features/sigaa/importacao_dados.feature index 41030900c6..c8c9e052de 100644 --- a/features/sigaa/importacao_dados.feature +++ b/features/sigaa/importacao_dados.feature @@ -33,18 +33,8 @@ Funcionalidade: Importar novos dados do SIGAA E eu devo ver a mensagem de sucesso "Dados importados com sucesso!" - @happy - Cenário: Solicitar definição de senha para usuários novos importados - Dado que o sistema possui a turma "BANCOS DE DADOS" (CIC0097) cadastrada - E que o sistema não possui o usuário "usuario" (190084006) cadastrado - E que o SIGAA contém o participante "usuario" (190084006) na turma "BANCOS DE DADOS" (CIC0097) - Quando eu clico no botão "Importar dados" - Então o usuário "usuario" (190084006) deve estar pendente de definição de senha - E o usuário "usuario" (190084006) não deve possuir senha definida - E eu devo ver a mensagem de sucesso "Dados importados com sucesso!" - @sad - Cenário: Falha ao importar dados com JSON inválido + Cenário: Falha ao importar dados Dado que o SIGAA retorna um arquivo JSON inválido Quando eu clico no botão "Importar dados" Então eu devo ver a mensagem de erro "Os dados recebidos do SIGAA são inválidos." From 24958b907f5a78cdefdbc881b24b60a11f782f92 Mon Sep 17 00:00:00 2001 From: PedroFFranca Date: Tue, 26 May 2026 19:59:18 -0300 Subject: [PATCH 024/125] =?UTF-8?q?test(sigaa):=20adiciona=20cen=C3=A1rios?= =?UTF-8?q?=20BDD=20para=20solicitar=20defini=C3=A7=C3=A3o=20de=20senha?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sigaa/solicitacao_definicao_senha.feature | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 features/sigaa/solicitacao_definicao_senha.feature diff --git a/features/sigaa/solicitacao_definicao_senha.feature b/features/sigaa/solicitacao_definicao_senha.feature new file mode 100644 index 0000000000..19243e6ac4 --- /dev/null +++ b/features/sigaa/solicitacao_definicao_senha.feature @@ -0,0 +1,26 @@ + # language: pt + + +Funcionalidade: Solicitar definição de senha + Eu como Administrador + Quero solicitar meus usuários a criar uma senha para novos usuário + A fim de conseguir que todos meus usuário possam logar + + @happy + Cenário: Solicitar definição de senha para usuários novos importados + Dado que o sistema possui a turma "BANCOS DE DADOS" (CIC0097) cadastrada + E que o sistema não possui o usuário "usuario" (190084006) cadastrado + E que o SIGAA contém o participante "usuario" (190084006) na turma "BANCOS DE DADOS" (CIC0097) + Quando eu clico no botão "Solicitar definição de senha" + Então o usuário "usuario" (190084006) deve estar pendente de definição de senha + E o usuário "usuario" (190084006) não deve possuir senha definida + E eu devo ver a mensagem de sucesso "Dados importados com sucesso!" + @sad + Cenário: Impedir solicitação de definição de senha para participante sem e-mail no SIGAA + Dado que o sistema possui a turma "BANCOS DE DADOS" (CIC0097) cadastrada + E que o sistema não possui o usuário "Participante Sem E-mail" (199999999) cadastrado + E que o SIGAA contém o participante "Participante Sem E-mail" (199999999) na turma "BANCOS DE DADOS" (CIC0097) sem e-mail cadastrado + Quando eu clico no botão "Importar dados" + Então o usuário "Participante Sem E-mail" (199999999) não deve ser cadastrado no sistema + E nenhuma solicitação de definição de senha deve ser enviada + E eu devo ver a mensagem de erro "Não foi possível solicitar a definição de senha: e-mail do participante não informado." From b45b9fb9a38604aa08bf9785d7722e71be2700a1 Mon Sep 17 00:00:00 2001 From: PedroFFranca Date: Tue, 26 May 2026 20:02:52 -0300 Subject: [PATCH 025/125] =?UTF-8?q?=20fix(sigaa):=20arrumando=20a=20format?= =?UTF-8?q?acao=20de=20solicitar=20defini=C3=A7=C3=A3o=20de=20senha?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sigaa/solicitacao_definicao_senha.feature | 31 +++++++++---------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/features/sigaa/solicitacao_definicao_senha.feature b/features/sigaa/solicitacao_definicao_senha.feature index 19243e6ac4..7dc2883add 100644 --- a/features/sigaa/solicitacao_definicao_senha.feature +++ b/features/sigaa/solicitacao_definicao_senha.feature @@ -1,22 +1,21 @@ # language: pt - Funcionalidade: Solicitar definição de senha - Eu como Administrador - Quero solicitar meus usuários a criar uma senha para novos usuário - A fim de conseguir que todos meus usuário possam logar - - @happy - Cenário: Solicitar definição de senha para usuários novos importados - Dado que o sistema possui a turma "BANCOS DE DADOS" (CIC0097) cadastrada - E que o sistema não possui o usuário "usuario" (190084006) cadastrado - E que o SIGAA contém o participante "usuario" (190084006) na turma "BANCOS DE DADOS" (CIC0097) - Quando eu clico no botão "Solicitar definição de senha" - Então o usuário "usuario" (190084006) deve estar pendente de definição de senha - E o usuário "usuario" (190084006) não deve possuir senha definida - E eu devo ver a mensagem de sucesso "Dados importados com sucesso!" - @sad - Cenário: Impedir solicitação de definição de senha para participante sem e-mail no SIGAA +Eu como Administrador +Quero solicitar meus usuários a criar uma senha para novos usuário +A fim de conseguir que todos meus usuário possam logar + +@happy +Cenário: Solicitar definição de senha para usuários novos importados + Dado que o sistema possui a turma "BANCOS DE DADOS" (CIC0097) cadastrada + E que o sistema não possui o usuário "usuario" (190084006) cadastrado + E que o SIGAA contém o participante "usuario" (190084006) na turma "BANCOS DE DADOS" (CIC0097) + Quando eu clico no botão "Solicitar definição de senha" + Então o usuário "usuario" (190084006) deve estar pendente de definição de senha + E o usuário "usuario" (190084006) não deve possuir senha definida + E eu devo ver a mensagem de sucesso "Dados importados com sucesso!" +@sad + Cenário: Falha ao solicitar definição de senha para participante sem e-mail no SIGAA Dado que o sistema possui a turma "BANCOS DE DADOS" (CIC0097) cadastrada E que o sistema não possui o usuário "Participante Sem E-mail" (199999999) cadastrado E que o SIGAA contém o participante "Participante Sem E-mail" (199999999) na turma "BANCOS DE DADOS" (CIC0097) sem e-mail cadastrado From 9fa82d7d9c3e6689e272f4fab110f17e92954fe7 Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Tue, 26 May 2026 20:20:23 -0300 Subject: [PATCH 026/125] =?UTF-8?q?fix(relat=C3=B3rios):=20desacopla=20con?= =?UTF-8?q?trole=20departamental=20da=20funcionalidade=20de=20exporta?= =?UTF-8?q?=C3=A7=C3=A3o=20de=20resultados?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exportar_resultados_csv.feature | 32 +++---------------- 1 file changed, 4 insertions(+), 28 deletions(-) diff --git a/features/relatorios/exportar_resultados_csv.feature b/features/relatorios/exportar_resultados_csv.feature index 6b9e47cf62..227fc2643e 100644 --- a/features/relatorios/exportar_resultados_csv.feature +++ b/features/relatorios/exportar_resultados_csv.feature @@ -1,8 +1,8 @@ # language: pt -Funcionalidade: Exportar resultados e controle departamental +Funcionalidade: Exportar resultados Como Administrador - Quero gerenciar turmas do meu departamento e baixar os resultados em CSV - A fim de avaliar o desempenho das turmas + Quero baixar um arquivo CSV contendo os resultados de um formulário + A fim de avaliar o desempenho das turmas. Contexto: Dado que existe um usuário administrador cadastrado no sistema @@ -16,15 +16,6 @@ Funcionalidade: Exportar resultados e controle departamental Então o download do arquivo CSV deve ser iniciado E o CSV deve conter os dados esperados das avaliações - @happy - Cenário: Administrador visualiza formulários restritos ao seu departamento - Dado que estou autenticado como administrador do "Departamento de Ciência da Computação" - E que existe a turma "Engenharia de Software" no meu departamento - E que existe a turma "Cálculo 1" no "Departamento de Matemática" - Quando eu acesso a página de relatórios - Então devo ver o formulário da turma "Engenharia de Software" na lista - E não devo ver o formulário da turma "Cálculo 1" - @happy Cenário: Administrador exporta CSV de um formulário sem respostas Dado que estou autenticado como administrador do "Departamento de Ciência da Computação" @@ -33,24 +24,9 @@ Funcionalidade: Exportar resultados e controle departamental Então o download do arquivo CSV deve ser iniciado E o arquivo CSV deve conter apenas a linha de cabeçalho - @sad - Cenário: Administrador tenta acessar turma de outro departamento - Dado que estou autenticado como administrador do "Departamento de Ciência da Computação" - E que a turma "Cálculo 1" pertence ao "Departamento de Matemática" - Quando eu tento acessar a página de relatórios da turma "Cálculo 1" - Então devo ver uma mensagem informando que não possuo permissão para acessar turmas de outros departamentos - - @sad - Cenário: Administrador tenta exportar formulário de outro departamento sem permissão - Dado que estou autenticado como administrador do "Departamento de Ciência da Computação" - E que a turma "Cálculo 1" pertence ao "Departamento de Matemática" - Quando eu tento forçar a exportação do CSV da turma "Cálculo 1" - Então devo ver uma mensagem informando que a exportação não é permitida - E o arquivo CSV não deve ser gerado - @sad Cenário: Usuário não administrador tenta baixar arquivo CSV Dado que existe um usuário participante cadastrado no sistema E que estou autenticado como participante Quando eu tento acessar a rota de exportação de resultados em CSV - Então devo ver uma mensagem informando que apenas administradores possuem acesso a este recurso \ No newline at end of file + Então devo ver uma mensagem informando que apenas administradores possuem acesso a este recurso From 7892d7cb7a8b76187c501ec2b5978ef37838b62e Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Tue, 26 May 2026 20:23:58 -0300 Subject: [PATCH 027/125] =?UTF-8?q?fix(relat=C3=B3rios):=20corre=C3=A7?= =?UTF-8?q?=C3=A3o=20de=20erro=20de=20sem=C3=A2ntica=20em=20`controle=5Fde?= =?UTF-8?q?partamental.feature`=20-=20"confirmo=20a=20cria=C3=A7=C3=A3o"?= =?UTF-8?q?=20dava=20a=20impress=C3=A3o=20errada?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- features/relatorios/controle_departamental.feature | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/relatorios/controle_departamental.feature b/features/relatorios/controle_departamental.feature index 214dc8db58..25955c536a 100644 --- a/features/relatorios/controle_departamental.feature +++ b/features/relatorios/controle_departamental.feature @@ -54,7 +54,7 @@ Funcionalidade: Controle departamental de administradores E seleciono a turma "B" da matéria "Cálculo 1" E seleciono o template "Avaliação de Disciplina" E seleciono o público-alvo "discentes" - E confirmo a criação do formulário + E pressiono o botão "Criar Formulário" Então devo ver uma mensagem informando que não tenho permissão para gerenciar essa turma E o formulário não deve ser criado From 81f27fc8882c7184fb27cfe45d8a17e0530d0fa5 Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Mon, 15 Jun 2026 17:59:29 -0300 Subject: [PATCH 028/125] =?UTF-8?q?chore(inflections):=20adiciona=20regras?= =?UTF-8?q?=20de=20pluraliza=C3=A7=C3=A3o=20do=20portugu=C3=AAs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/initializers/inflections.rb | 37 ++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index 3860f659ea..380b4a1749 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -10,7 +10,36 @@ # inflect.uncountable %w( fish sheep ) # end -# These inflection rules are supported but not enabled by default: -# ActiveSupport::Inflector.inflections(:en) do |inflect| -# inflect.acronym "RESTful" -# end +ActiveSupport::Inflector.inflections(:en) do |inflect| + # Palavras simples em português usadas no domínio + inflect.irregular "perfil", "perfis" + inflect.irregular "questao", "questoes" + inflect.irregular "opcao", "opcoes" + inflect.irregular "avaliacao", "avaliacoes" + inflect.irregular "participacao", "participacoes" + inflect.irregular "utilizacao", "utilizacoes" + + # Palavras que o Rails até pluralizaria com "s", + # mas ficam explícitas para evitar comportamento estranho em nomes compostos. + inflect.irregular "usuario", "usuarios" + inflect.irregular "departamento", "departamentos" + inflect.irregular "materia", "materias" + inflect.irregular "turma", "turmas" + inflect.irregular "template", "templates" + inflect.irregular "formulario", "formularios" + inflect.irregular "resposta", "respostas" + inflect.irregular "texto", "textos" + + # Nomes compostos das models do sistema + inflect.irregular "perfil_adm", "perfis_adm" + inflect.irregular "perfil_docente", "perfis_docentes" + inflect.irregular "perfil_discente", "perfis_discentes" + + inflect.irregular "participacao_turma", "participacoes_turmas" + inflect.irregular "utilizacao_questao", "utilizacoes_questoes" + inflect.irregular "opcao_escolhida", "opcoes_escolhidas" + + # Siglas úteis, caso apareçam em nomes de classes no projeto + inflect.acronym "SIGAA" + inflect.acronym "CSV" +end From 0390c41ae86f62aa227eb391e83edef871e2216d Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Mon, 15 Jun 2026 18:14:02 -0300 Subject: [PATCH 029/125] =?UTF-8?q?feat(usuarios):=20adiciona=20models=20d?= =?UTF-8?q?e=20usu=C3=A1rio=20e=20perfis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/perfil_adm.rb | 41 +++++++++ app/models/perfil_discente.rb | 24 ++++++ app/models/perfil_docente.rb | 29 +++++++ app/models/usuario.rb | 85 +++++++++++++++++++ db/migrate/20260612221554_create_usuarios.rb | 14 +++ .../20260612221556_create_perfis_adm.rb | 12 +++ .../20260612221600_create_perfis_docentes.rb | 12 +++ .../20260612221601_create_perfis_discentes.rb | 13 +++ 8 files changed, 230 insertions(+) create mode 100644 app/models/perfil_adm.rb create mode 100644 app/models/perfil_discente.rb create mode 100644 app/models/perfil_docente.rb create mode 100644 app/models/usuario.rb create mode 100644 db/migrate/20260612221554_create_usuarios.rb create mode 100644 db/migrate/20260612221556_create_perfis_adm.rb create mode 100644 db/migrate/20260612221600_create_perfis_docentes.rb create mode 100644 db/migrate/20260612221601_create_perfis_discentes.rb diff --git a/app/models/perfil_adm.rb b/app/models/perfil_adm.rb new file mode 100644 index 0000000000..f8368ffca2 --- /dev/null +++ b/app/models/perfil_adm.rb @@ -0,0 +1,41 @@ +class PerfilAdm < ApplicationRecord + self.primary_key = :id + + belongs_to :usuario, + class_name: "Usuario", + foreign_key: :id, + inverse_of: :perfil_adm + + belongs_to :departamento, + class_name: "Departamento", + foreign_key: :departamento_id, + inverse_of: :perfil_adms + + has_many :templates, + class_name: "Template", + foreign_key: :adm_id, + inverse_of: :adm, + dependent: :restrict_with_error + + has_many :formularios, + class_name: "Formulario", + foreign_key: :adm_id, + inverse_of: :adm, + dependent: :restrict_with_error + + validates :id, presence: true, uniqueness: true + + validates :departamento, presence: true + + def nome + usuario.nome + end + + def email + usuario.email + end + + def criou_template?(template) + template.present? && template.adm_id == id + end +end diff --git a/app/models/perfil_discente.rb b/app/models/perfil_discente.rb new file mode 100644 index 0000000000..ecb3034865 --- /dev/null +++ b/app/models/perfil_discente.rb @@ -0,0 +1,24 @@ +class PerfilDiscente < ApplicationRecord + self.primary_key = :id + + belongs_to :usuario, + class_name: "Usuario", + foreign_key: :id, + inverse_of: :perfil_discente + + has_many :participacoes_turma, through: :usuario, source: :participacoes_turma + + has_many :turmas, through: :participacoes_turma + + validates :id, presence: true, uniqueness: true + + validates :matricula, presence: true, uniqueness: true, length: { maximum: 50 } + + def nome + usuario.nome + end + + def email + usuario.email + end +end diff --git a/app/models/perfil_docente.rb b/app/models/perfil_docente.rb new file mode 100644 index 0000000000..38d0ffbac5 --- /dev/null +++ b/app/models/perfil_docente.rb @@ -0,0 +1,29 @@ +class PerfilDocente < ApplicationRecord + self.primary_key = :id + + belongs_to :usuario, + class_name: "Usuario", + foreign_key: :id, + inverse_of: :perfil_docente + + belongs_to :departamento, + class_name: "Departamento", + foreign_key: :departamento_id, + inverse_of: :perfil_docentes + + has_many :participacoes_turma, through: :usuario, source: :participacoes_turma + + has_many :turmas, through: :participacoes_turma + + validates :id, presence: true, uniqueness: true + + validates :departamento, presence: true + + def nome + usuario.nome + end + + def email + usuario.email + end +end diff --git a/app/models/usuario.rb b/app/models/usuario.rb new file mode 100644 index 0000000000..7507dcace5 --- /dev/null +++ b/app/models/usuario.rb @@ -0,0 +1,85 @@ +class Usuario < ApplicationRecord + has_secure_password :senha, validations: false + + enum :status, { + pendente: 0, + ativo: 1 + } + + has_one :perfil_adm, + class_name: "PerfilAdm", + foreign_key: :id, + inverse_of: :usuario, + dependent: :destroy + + has_one :perfil_docente, + class_name: "PerfilDocente", + foreign_key: :id, + inverse_of: :usuario, + dependent: :destroy + + has_one :perfil_discente, + class_name: "PerfilDiscente", + foreign_key: :id, + inverse_of: :usuario, + dependent: :destroy + + has_many :participacoes_turma, + class_name: "ParticipacaoTurma", + foreign_key: :usuario_id, + inverse_of: :usuario, + dependent: :restrict_with_error + + has_many :turmas, through: :participacoes_turma + + has_many :templates_criados, through: :perfil_adm, source: :templates + + before_validation :normalizar_email + + validates :nome, presence: true, length: { maximum: 255 } + + validates :email, + presence: true, + uniqueness: { case_sensitive: false }, + length: { maximum: 255 }, + format: { with: URI::MailTo::EMAIL_REGEXP } + + validates :status, presence: true, inclusion: { in: statuses.keys } + + validates :senha, length: { minimum: 8, maximum: 72 }, allow_blank: true + + validate :senha_obrigatoria_para_usuario_ativo + + def administrador? + perfil_adm.present? + end + + def docente? + perfil_docente.present? + end + + def discente? + perfil_discente.present? + end + + def participante? + docente? || discente? + end + + def pode_acessar_sistema? + ativo? && senha_digest.present? + end + + private + + def normalizar_email + self.email = email.to_s.strip.downcase if email.present? + end + + def senha_obrigatoria_para_usuario_ativo + return unless ativo? + return if senha_digest.present? || senha.present? + + errors.add(:senha, "deve ser definida para ativar o usuário") + end +end diff --git a/db/migrate/20260612221554_create_usuarios.rb b/db/migrate/20260612221554_create_usuarios.rb new file mode 100644 index 0000000000..c8228353e3 --- /dev/null +++ b/db/migrate/20260612221554_create_usuarios.rb @@ -0,0 +1,14 @@ +class CreateUsuarios < ActiveRecord::Migration[8.1] + def change + create_table :usuarios do |t| + t.string :nome, null: false + t.string :email, null: false + t.integer :status, null: false, default: 0 + t.string :senha_digest + + t.timestamps + end + + add_index :usuarios, :email, unique: true + end +end diff --git a/db/migrate/20260612221556_create_perfis_adm.rb b/db/migrate/20260612221556_create_perfis_adm.rb new file mode 100644 index 0000000000..1d208708be --- /dev/null +++ b/db/migrate/20260612221556_create_perfis_adm.rb @@ -0,0 +1,12 @@ +class CreatePerfisAdm < ActiveRecord::Migration[8.1] + def change + create_table :perfis_adm, id: false do |t| + t.primary_key :id + t.references :departamento, null: false, foreign_key: true + + t.timestamps + end + + add_foreign_key :perfis_adm, :usuarios, column: :id + end +end diff --git a/db/migrate/20260612221600_create_perfis_docentes.rb b/db/migrate/20260612221600_create_perfis_docentes.rb new file mode 100644 index 0000000000..bfce12c0f5 --- /dev/null +++ b/db/migrate/20260612221600_create_perfis_docentes.rb @@ -0,0 +1,12 @@ +class CreatePerfisDocentes < ActiveRecord::Migration[8.1] + def change + create_table :perfis_docentes, id: false do |t| + t.primary_key :id + t.references :departamento, null: false, foreign_key: true + + t.timestamps + end + + add_foreign_key :perfis_docentes, :usuarios, column: :id + end +end diff --git a/db/migrate/20260612221601_create_perfis_discentes.rb b/db/migrate/20260612221601_create_perfis_discentes.rb new file mode 100644 index 0000000000..4810106f63 --- /dev/null +++ b/db/migrate/20260612221601_create_perfis_discentes.rb @@ -0,0 +1,13 @@ +class CreatePerfisDiscentes < ActiveRecord::Migration[8.1] + def change + create_table :perfis_discentes, id: false do |t| + t.primary_key :id + t.string :matricula, null: false + + t.timestamps + end + + add_index :perfis_discentes, :matricula, unique: true + add_foreign_key :perfis_discentes, :usuarios, column: :id + end +end From 20f5d1e1eb40bf36a5db8a9a88f4473a5459414a Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Mon, 15 Jun 2026 18:15:39 -0300 Subject: [PATCH 030/125] =?UTF-8?q?feat(departamento):=20adiciona=20models?= =?UTF-8?q?=20de=20departamento,=20mat=C3=A9ria=20e=20turma?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/departamento.rb | 40 ++++++++++++ app/models/materia.rb | 34 ++++++++++ app/models/participacao_turma.rb | 62 ++++++++++++++++++ app/models/turma.rb | 64 +++++++++++++++++++ .../20260612221555_create_departamentos.rb | 11 ++++ db/migrate/20260612221602_create_materias.rb | 13 ++++ db/migrate/20260612221603_create_turmas.rb | 16 +++++ ...60612221604_create_participacoes_turmas.rb | 15 +++++ 8 files changed, 255 insertions(+) create mode 100644 app/models/departamento.rb create mode 100644 app/models/materia.rb create mode 100644 app/models/participacao_turma.rb create mode 100644 app/models/turma.rb create mode 100644 db/migrate/20260612221555_create_departamentos.rb create mode 100644 db/migrate/20260612221602_create_materias.rb create mode 100644 db/migrate/20260612221603_create_turmas.rb create mode 100644 db/migrate/20260612221604_create_participacoes_turmas.rb diff --git a/app/models/departamento.rb b/app/models/departamento.rb new file mode 100644 index 0000000000..f3e6a6bfd3 --- /dev/null +++ b/app/models/departamento.rb @@ -0,0 +1,40 @@ +class Departamento < ApplicationRecord + has_many :perfil_adms, + class_name: "PerfilAdm", + foreign_key: :departamento_id, + inverse_of: :departamento, + dependent: :restrict_with_error + + has_many :perfil_docentes, + class_name: "PerfilDocente", + foreign_key: :departamento_id, + inverse_of: :departamento, + dependent: :restrict_with_error + + has_many :administradores, + through: :perfil_adms, + source: :usuario + + has_many :docentes, + through: :perfil_docentes, + source: :usuario + + has_many :materias, + class_name: "Materia", + foreign_key: :departamento_id, + inverse_of: :departamento, + dependent: :restrict_with_error + + validates :nome, + presence: true, + uniqueness: { case_sensitive: false }, + length: { maximum: 255 } + + before_validation :normalizar_nome + + private + + def normalizar_nome + self.nome = nome.to_s.strip + end +end diff --git a/app/models/materia.rb b/app/models/materia.rb new file mode 100644 index 0000000000..dabc287527 --- /dev/null +++ b/app/models/materia.rb @@ -0,0 +1,34 @@ +class Materia < ApplicationRecord + belongs_to :departamento, + class_name: "Departamento", + inverse_of: :materias + + has_many :turmas, + class_name: "Turma", + dependent: :restrict_with_error, + inverse_of: :materia + + validates :codigo, + presence: true, + uniqueness: { case_sensitive: false } + + validates :nome, presence: true + validates :departamento, presence: true + + before_validation :normalizar_codigo + before_validation :normalizar_nome + + scope :do_departamento, ->(departamento) { + where(departamento_id: departamento.id) + } + + private + + def normalizar_codigo + self.codigo = codigo.to_s.strip.upcase + end + + def normalizar_nome + self.nome = nome.to_s.strip + end +end diff --git a/app/models/participacao_turma.rb b/app/models/participacao_turma.rb new file mode 100644 index 0000000000..ea04039940 --- /dev/null +++ b/app/models/participacao_turma.rb @@ -0,0 +1,62 @@ +class ParticipacaoTurma < ApplicationRecord + enum :tipo_participacao, { + docente: 0, + discente: 1 + } + + belongs_to :usuario, + class_name: "Usuario", + inverse_of: :participacoes_turma + + belongs_to :turma, + class_name: "Turma", + inverse_of: :participacoes_turma + + has_many :avaliacoes, + class_name: "Avaliacao", + dependent: :restrict_with_error, + inverse_of: :participacao_turma + + validates :tipo_participacao, presence: true + validates :usuario, presence: true + validates :turma, presence: true + + validates :usuario_id, + uniqueness: { + scope: %i[turma_id tipo_participacao], + message: "já possui essa participação nesta turma" + } + + validate :usuario_deve_ter_perfil_compativel + + scope :docentes, -> { where(tipo_participacao: :docente) } + scope :discentes, -> { where(tipo_participacao: :discente) } + + def corresponde_ao_publico?(publico_alvo) + return docente? if publico_alvo.to_s == "docentes" + return discente? if publico_alvo.to_s == "discentes" + + false + end + + private + + def usuario_deve_ter_perfil_compativel + return if usuario.blank? + + validar_perfil_docente if docente? + validar_perfil_discente if discente? + end + + def validar_perfil_docente + return if usuario.docente? + + errors.add(:usuario, "deve possuir perfil docente") + end + + def validar_perfil_discente + return if usuario.discente? + + errors.add(:usuario, "deve possuir perfil discente") + end +end diff --git a/app/models/turma.rb b/app/models/turma.rb new file mode 100644 index 0000000000..fe6e324362 --- /dev/null +++ b/app/models/turma.rb @@ -0,0 +1,64 @@ +class Turma < ApplicationRecord + enum :semestre, { + primeiro: 1, + segundo: 2, + verao: 4 + } + + belongs_to :materia, + class_name: "Materia", + inverse_of: :turmas + + has_many :participacoes_turma, + class_name: "ParticipacaoTurma", + dependent: :restrict_with_error, + inverse_of: :turma + + has_many :usuarios, + through: :participacoes_turma + + has_many :formularios, + class_name: "Formulario", + dependent: :restrict_with_error, + inverse_of: :turma + + has_many :avaliacoes, + through: :formularios + + validates :numero, + presence: true, + numericality: { only_integer: true, greater_than: 0 } + + validates :ano, + presence: true, + numericality: { only_integer: true, greater_than_or_equal_to: 2000 } + + validates :semestre, presence: true + validates :materia, presence: true + + validates :numero, + uniqueness: { + scope: %i[ano semestre materia_id], + message: "já existe para esta matéria, ano e semestre" + } + + scope :do_departamento, ->(departamento) { + joins(:materia).where(materias: { departamento_id: departamento.id }) + } + + def departamento + materia.departamento + end + + def departamento_id + materia.departamento_id + end + + def participantes_docentes + participacoes_turma.docentes + end + + def participantes_discentes + participacoes_turma.discentes + end +end diff --git a/db/migrate/20260612221555_create_departamentos.rb b/db/migrate/20260612221555_create_departamentos.rb new file mode 100644 index 0000000000..e56c9bd8c2 --- /dev/null +++ b/db/migrate/20260612221555_create_departamentos.rb @@ -0,0 +1,11 @@ +class CreateDepartamentos < ActiveRecord::Migration[8.1] + def change + create_table :departamentos do |t| + t.string :nome, null: false + + t.timestamps + end + + add_index :departamentos, :nome, unique: true + end +end diff --git a/db/migrate/20260612221602_create_materias.rb b/db/migrate/20260612221602_create_materias.rb new file mode 100644 index 0000000000..cd39392a9d --- /dev/null +++ b/db/migrate/20260612221602_create_materias.rb @@ -0,0 +1,13 @@ +class CreateMaterias < ActiveRecord::Migration[8.1] + def change + create_table :materias do |t| + t.references :departamento, null: false, foreign_key: true + t.string :codigo, null: false + t.string :nome, null: false + + t.timestamps + end + + add_index :materias, :codigo, unique: true + end +end diff --git a/db/migrate/20260612221603_create_turmas.rb b/db/migrate/20260612221603_create_turmas.rb new file mode 100644 index 0000000000..1ef8374b17 --- /dev/null +++ b/db/migrate/20260612221603_create_turmas.rb @@ -0,0 +1,16 @@ +class CreateTurmas < ActiveRecord::Migration[8.1] + def change + create_table :turmas do |t| + t.references :materia, null: false, foreign_key: true + t.integer :numero, null: false + t.integer :ano, null: false + t.integer :semestre, null: false + + t.timestamps + end + + add_index :turmas, + [ :materia_id, :ano, :semestre, :numero ], + unique: true + end +end diff --git a/db/migrate/20260612221604_create_participacoes_turmas.rb b/db/migrate/20260612221604_create_participacoes_turmas.rb new file mode 100644 index 0000000000..06ddf3e776 --- /dev/null +++ b/db/migrate/20260612221604_create_participacoes_turmas.rb @@ -0,0 +1,15 @@ +class CreateParticipacoesTurmas < ActiveRecord::Migration[8.1] + def change + create_table :participacoes_turmas do |t| + t.references :usuario, null: false, foreign_key: true + t.references :turma, null: false, foreign_key: true + t.integer :tipo_participacao, null: false + + t.timestamps + end + + add_index :participacoes_turmas, + [ :usuario_id, :turma_id, :tipo_participacao ], + unique: true + end +end From 3469b2359f54899b298becb83a10e5938738b89d Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Mon, 15 Jun 2026 18:17:21 -0300 Subject: [PATCH 031/125] =?UTF-8?q?feat(template):=20adiciona=20as=20model?= =?UTF-8?q?s=20de=20template=20e=20quest=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/opcao.rb | 40 +++++ app/models/questao.rb | 64 +++++++ app/models/resposta.rb | 86 ++++++++++ app/models/template.rb | 67 ++++++++ app/models/utilizacao_questao.rb | 84 +++++++++ db/migrate/20260612221557_create_questoes.rb | 10 ++ db/migrate/20260612221558_create_opcoes.rb | 13 ++ db/migrate/20260612221559_create_templates.rb | 14 ++ ...60612221655_create_utilizacoes_questoes.rb | 34 ++++ spec/models/opcao_spec.rb | 51 ++++++ spec/models/questao_spec.rb | 114 +++++++++++++ spec/models/template_spec.rb | 145 ++++++++++++++++ spec/models/utilizacao_questao_spec.rb | 101 +++++++++++ spec/requests/templates_spec.rb | 159 ++++++++++++++++++ 14 files changed, 982 insertions(+) create mode 100644 app/models/opcao.rb create mode 100644 app/models/questao.rb create mode 100644 app/models/resposta.rb create mode 100644 app/models/template.rb create mode 100644 app/models/utilizacao_questao.rb create mode 100644 db/migrate/20260612221557_create_questoes.rb create mode 100644 db/migrate/20260612221558_create_opcoes.rb create mode 100644 db/migrate/20260612221559_create_templates.rb create mode 100644 db/migrate/20260612221655_create_utilizacoes_questoes.rb create mode 100644 spec/models/opcao_spec.rb create mode 100644 spec/models/questao_spec.rb create mode 100644 spec/models/template_spec.rb create mode 100644 spec/models/utilizacao_questao_spec.rb create mode 100644 spec/requests/templates_spec.rb diff --git a/app/models/opcao.rb b/app/models/opcao.rb new file mode 100644 index 0000000000..5457e6aefb --- /dev/null +++ b/app/models/opcao.rb @@ -0,0 +1,40 @@ +class Opcao < ApplicationRecord + belongs_to :questao, + class_name: "Questao", + foreign_key: :questao_id, + inverse_of: :opcoes + + has_many :opcoes_escolhidas, + class_name: "OpcaoEscolhida", + foreign_key: :opcao_id, + inverse_of: :opcao, + dependent: :restrict_with_error + + before_validation :normalizar_texto + + validates :questao, presence: true + + validates :numero, + presence: true, + numericality: { only_integer: true, greater_than: 0 }, + uniqueness: { scope: :questao_id } + + validates :texto, presence: true, length: { maximum: 2_000 } + + validate :opcao_deve_pertencer_a_questao_objetiva + + scope :ordenadas, -> { order(:numero, :id) } + + private + + def normalizar_texto + self.texto = texto.to_s.strip if texto.present? + end + + def opcao_deve_pertencer_a_questao_objetiva + return if questao.blank? + return if questao.objetiva? + + errors.add(:questao, "deve ser objetiva para possuir opções") + end +end diff --git a/app/models/questao.rb b/app/models/questao.rb new file mode 100644 index 0000000000..3713fef555 --- /dev/null +++ b/app/models/questao.rb @@ -0,0 +1,64 @@ +class Questao < ApplicationRecord + enum :tipo, { + objetiva: 0, + discursiva: 1 + } + + has_many :utilizacao_questoes, + class_name: "UtilizacaoQuestao", + foreign_key: :questao_id, + inverse_of: :questao, + dependent: :restrict_with_error + + has_many :templates, through: :utilizacao_questoes, source: :template + + has_many :opcoes, + class_name: "Opcao", + foreign_key: :questao_id, + inverse_of: :questao, + dependent: :destroy + + has_many :respostas, + class_name: "Resposta", + foreign_key: :questao_id, + inverse_of: :questao, + dependent: :restrict_with_error + + accepts_nested_attributes_for :opcoes, allow_destroy: true + + before_validation :normalizar_enunciado + + validates :enunciado, presence: true, length: { maximum: 4_000 } + + validates :tipo, presence: true, inclusion: { in: tipos.keys } + + validate :questao_objetiva_deve_ter_opcoes_suficientes + validate :questao_discursiva_nao_deve_ter_opcoes + + scope :objetivas, -> { where(tipo: tipos[:objetiva]) } + scope :discursivas, -> { where(tipo: tipos[:discursiva]) } + + private + + def normalizar_enunciado + self.enunciado = enunciado.to_s.strip if enunciado.present? + end + + def opcoes_validas + opcoes.reject(&:marked_for_destruction?) + end + + def questao_objetiva_deve_ter_opcoes_suficientes + return unless objetiva? + return if opcoes_validas.size >= 2 + + errors.add(:opcoes, "devem ter pelo menos duas alternativas para questão objetiva") + end + + def questao_discursiva_nao_deve_ter_opcoes + return unless discursiva? + return if opcoes_validas.empty? + + errors.add(:opcoes, "não devem existir em questão discursiva") + end +end diff --git a/app/models/resposta.rb b/app/models/resposta.rb new file mode 100644 index 0000000000..0e2e9f4712 --- /dev/null +++ b/app/models/resposta.rb @@ -0,0 +1,86 @@ +class Resposta < ApplicationRecord + belongs_to :questao, + class_name: "Questao", + inverse_of: :respostas + + belongs_to :avaliacao, + class_name: "Avaliacao", + inverse_of: :respostas + + has_one :texto, + class_name: "Texto", + dependent: :destroy, + inverse_of: :resposta + + has_many :opcoes_escolhidas, + class_name: "OpcaoEscolhida", + dependent: :destroy, + inverse_of: :resposta + + has_many :opcoes, + through: :opcoes_escolhidas + + accepts_nested_attributes_for :texto, + allow_destroy: true + + accepts_nested_attributes_for :opcoes_escolhidas, + allow_destroy: true + + validates :questao, presence: true + validates :avaliacao, presence: true + + validates :questao_id, + uniqueness: { + scope: :avaliacao_id, + message: "já foi respondida nesta avaliação" + } + + validate :questao_deve_pertencer_ao_template_do_formulario + validate :conteudo_deve_ser_compativel_com_tipo_da_questao + + private + + def questao_deve_pertencer_ao_template_do_formulario + return if questao.blank? + return if avaliacao.blank? + return if template_do_formulario.blank? + return if template_do_formulario.questoes.exists?(id: questao_id) + + errors.add(:questao, "não pertence ao template do formulário") + end + + def conteudo_deve_ser_compativel_com_tipo_da_questao + return if questao.blank? + + validar_resposta_objetiva if questao.objetiva? + validar_resposta_discursiva if questao.discursiva? + end + + def validar_resposta_objetiva + errors.add(:opcoes_escolhidas, "devem ser informadas") unless possui_opcoes_escolhidas? + errors.add(:texto, "não deve ser informado em questão objetiva") if possui_texto? + end + + def validar_resposta_discursiva + errors.add(:texto, "deve ser informado") unless possui_texto? + + if possui_opcoes_escolhidas? + errors.add( + :opcoes_escolhidas, + "não devem ser informadas em questão discursiva" + ) + end + end + + def possui_texto? + texto&.texto.to_s.strip.present? + end + + def possui_opcoes_escolhidas? + opcoes_escolhidas.reject(&:marked_for_destruction?).any? + end + + def template_do_formulario + avaliacao.formulario.template + end +end diff --git a/app/models/template.rb b/app/models/template.rb new file mode 100644 index 0000000000..fe41a072cf --- /dev/null +++ b/app/models/template.rb @@ -0,0 +1,67 @@ +class Template < ApplicationRecord + belongs_to :adm, + class_name: "PerfilAdm", + foreign_key: :adm_id, + inverse_of: :templates + + has_many :utilizacao_questoes, + class_name: "UtilizacaoQuestao", + foreign_key: :template_id, + inverse_of: :template, + dependent: :destroy + + has_many :questoes, through: :utilizacao_questoes, source: :questao + + has_many :formularios, + class_name: "Formulario", + foreign_key: :template_id, + inverse_of: :template, + dependent: :nullify + + accepts_nested_attributes_for :utilizacao_questoes, allow_destroy: true + + before_validation :normalizar_titulo + before_validation :preencher_criado_em + + validates :adm, presence: true + + validates :titulo, + presence: true, + length: { maximum: 255 }, + uniqueness: { scope: :adm_id, case_sensitive: false } + + validates :descricao, length: { maximum: 2_000 }, allow_blank: true + + validates :criado_em, presence: true + + validate :deve_ter_ao_menos_uma_questao + + scope :recentes, -> { order(criado_em: :desc, id: :desc) } + scope :criados_por, ->(adm) { where(adm_id: adm&.id) } + scope :criados_por_outros, ->(adm) { where.not(adm_id: adm&.id) } + + def criado_por?(perfil_adm) + perfil_adm.present? && adm_id == perfil_adm.id + end + + def questoes_ordenadas + utilizacao_questoes.includes(:questao, :opcoes).ordenadas + end + + private + + def normalizar_titulo + self.titulo = titulo.to_s.strip if titulo.present? + end + + def preencher_criado_em + self.criado_em ||= Time.current + end + + def deve_ter_ao_menos_uma_questao + questoes_validas = utilizacao_questoes.reject(&:marked_for_destruction?) + return if questoes_validas.any? + + errors.add(:utilizacao_questoes, "deve conter ao menos uma questão") + end +end diff --git a/app/models/utilizacao_questao.rb b/app/models/utilizacao_questao.rb new file mode 100644 index 0000000000..8f02172526 --- /dev/null +++ b/app/models/utilizacao_questao.rb @@ -0,0 +1,84 @@ +class UtilizacaoQuestao < ApplicationRecord + belongs_to :template, + class_name: "Template", + foreign_key: :template_id, + inverse_of: :utilizacao_questoes + + belongs_to :questao, + class_name: "Questao", + foreign_key: :questao_id, + inverse_of: :utilizacao_questoes + + belongs_to :parent, + class_name: "UtilizacaoQuestao", + foreign_key: :parent_id, + inverse_of: :children, + optional: true + + has_many :children, + class_name: "UtilizacaoQuestao", + foreign_key: :parent_id, + inverse_of: :parent, + dependent: :destroy + + has_many :opcoes, through: :questao, source: :opcoes + + before_validation :herdar_template_do_parent + + validates :template, presence: true + + validates :questao, presence: true + + validates :numero, + presence: true, + numericality: { only_integer: true, greater_than: 0 }, + uniqueness: { scope: %i[template_id parent_id] } + + validate :parent_deve_pertencer_ao_mesmo_template + validate :parent_nao_pode_ser_o_proprio_registro + validate :nao_pode_formar_ciclo + + scope :raizes, -> { where(parent_id: nil) } + scope :ordenadas, -> { order(:numero, :id) } + + def raiz? + parent_id.nil? + end + + private + + def herdar_template_do_parent + return if parent.blank? + return if template_id.present? + + self.template = parent.template + end + + def parent_deve_pertencer_ao_mesmo_template + return if parent.blank? + return if parent.template_id == template_id + + errors.add(:parent, "deve pertencer ao mesmo template") + end + + def parent_nao_pode_ser_o_proprio_registro + return if parent.blank? + return if id.blank? + return unless parent_id == id + + errors.add(:parent, "não pode ser a própria utilização de questão") + end + + def nao_pode_formar_ciclo + ancestral = parent + + while ancestral.present? + if ancestral.id == id + errors.add(:parent, "não pode formar ciclo") + break + end + + ancestral = ancestral.parent + end + end +end diff --git a/db/migrate/20260612221557_create_questoes.rb b/db/migrate/20260612221557_create_questoes.rb new file mode 100644 index 0000000000..aa2f3a0f51 --- /dev/null +++ b/db/migrate/20260612221557_create_questoes.rb @@ -0,0 +1,10 @@ +class CreateQuestoes < ActiveRecord::Migration[8.1] + def change + create_table :questoes do |t| + t.text :enunciado, null: false + t.integer :tipo, null: false + + t.timestamps + end + end +end diff --git a/db/migrate/20260612221558_create_opcoes.rb b/db/migrate/20260612221558_create_opcoes.rb new file mode 100644 index 0000000000..e1c0b6fc25 --- /dev/null +++ b/db/migrate/20260612221558_create_opcoes.rb @@ -0,0 +1,13 @@ +class CreateOpcoes < ActiveRecord::Migration[8.1] + def change + create_table :opcoes do |t| + t.references :questao, null: false, foreign_key: true + t.integer :numero, null: false + t.text :texto, null: false + + t.timestamps + end + + add_index :opcoes, [ :questao_id, :numero ], unique: true + end +end diff --git a/db/migrate/20260612221559_create_templates.rb b/db/migrate/20260612221559_create_templates.rb new file mode 100644 index 0000000000..f3aad02143 --- /dev/null +++ b/db/migrate/20260612221559_create_templates.rb @@ -0,0 +1,14 @@ +class CreateTemplates < ActiveRecord::Migration[8.1] + def change + create_table :templates do |t| + t.references :adm, null: false, foreign_key: { to_table: :perfis_adm } + t.string :titulo, null: false + t.text :descricao + t.datetime :criado_em, null: false + + t.timestamps + end + + add_index :templates, [ :adm_id, :titulo ], unique: true + end +end diff --git a/db/migrate/20260612221655_create_utilizacoes_questoes.rb b/db/migrate/20260612221655_create_utilizacoes_questoes.rb new file mode 100644 index 0000000000..56a4cf4c46 --- /dev/null +++ b/db/migrate/20260612221655_create_utilizacoes_questoes.rb @@ -0,0 +1,34 @@ +class CreateUtilizacoesQuestoes < ActiveRecord::Migration[8.1] + def change + create_table :utilizacoes_questoes do |t| + t.references :template, null: false, foreign_key: true + t.references :questao, null: false, foreign_key: true + t.references :parent, + null: true, + foreign_key: { to_table: :utilizacoes_questoes } + t.integer :numero, null: false + + t.timestamps + end + + add_index :utilizacoes_questoes, + [ :template_id, :parent_id, :numero ], + unique: true, + where: "parent_id IS NOT NULL" + + add_index :utilizacoes_questoes, + [ :template_id, :numero ], + unique: true, + where: "parent_id IS NULL" + + add_index :utilizacoes_questoes, + [ :template_id, :parent_id, :questao_id ], + unique: true, + where: "parent_id IS NOT NULL" + + add_index :utilizacoes_questoes, + [ :template_id, :questao_id ], + unique: true, + where: "parent_id IS NULL" + end +end diff --git a/spec/models/opcao_spec.rb b/spec/models/opcao_spec.rb new file mode 100644 index 0000000000..53c128a633 --- /dev/null +++ b/spec/models/opcao_spec.rb @@ -0,0 +1,51 @@ +require "rails_helper" + +RSpec.describe Opcao, type: :model do + let(:questao) do + Questao.new( + enunciado: "Como você avalia a disciplina?", + tipo: :objetiva + ) + end + + describe "associações" do + it "pertence a uma questão" do + association = described_class.reflect_on_association(:questao) + + expect(association.macro).to eq(:belongs_to) + end + end + + describe "validações" do + it "é válida com número, texto e questão" do + opcao = described_class.new( + numero: 1, + texto: "Boa", + questao: questao + ) + + expect(opcao).to be_valid + end + + it "exige número" do + opcao = described_class.new(texto: "Boa", questao: questao) + + expect(opcao).not_to be_valid + expect(opcao.errors[:numero]).not_to be_empty + end + + it "exige texto" do + opcao = described_class.new(numero: 1, questao: questao) + + expect(opcao).not_to be_valid + expect(opcao.errors[:texto]).not_to be_empty + end + + it "exige questão" do + opcao = described_class.new(numero: 1, texto: "Boa") + + expect(opcao).not_to be_valid + expect(opcao.errors[:questao]).not_to be_empty + end + end +end diff --git a/spec/models/questao_spec.rb b/spec/models/questao_spec.rb new file mode 100644 index 0000000000..50284a68de --- /dev/null +++ b/spec/models/questao_spec.rb @@ -0,0 +1,114 @@ +require "rails_helper" + +RSpec.describe Questao, type: :model do + describe "associações" do + it "possui utilizações de questões com restrição de remoção" do + association = + described_class.reflect_on_association(:utilizacao_questoes) + + expect(association.macro).to eq(:has_many) + expect(association.class_name).to eq("UtilizacaoQuestao") + expect(association.foreign_key).to eq("questao_id") + expect(association.options[:dependent]).to eq(:restrict_with_error) + end + + it "possui opções removidas em cascata" do + association = described_class.reflect_on_association(:opcoes) + + expect(association.macro).to eq(:has_many) + expect(association.options[:dependent]).to eq(:destroy) + end + end + + describe "tipos" do + it "define os valores do enum" do + expect(described_class.tipos).to eq( + "objetiva" => 0, + "discursiva" => 1 + ) + end + end + + describe "validações" do + it "exige enunciado" do + questao = described_class.new(tipo: :discursiva) + + expect(questao).not_to be_valid + expect(questao.errors[:enunciado]).not_to be_empty + end + + it "exige tipo" do + questao = described_class.new(enunciado: "Questão sem tipo") + + expect(questao).not_to be_valid + expect(questao.errors[:tipo]).not_to be_empty + end + + it "permite questão discursiva sem opções" do + questao = described_class.new( + enunciado: "Descreva sua experiência", + tipo: :discursiva + ) + + expect(questao).to be_valid + end + + it "exige pelo menos duas opções para questão objetiva" do + questao = described_class.new( + enunciado: "Como você avalia a disciplina?", + tipo: :objetiva + ) + + expect(questao).not_to be_valid + expect(questao.errors[:opcoes]) + .to include("devem ter pelo menos duas alternativas para questão objetiva") + end + + it "permite questão objetiva com duas opções" do + questao = described_class.new( + enunciado: "Como você avalia a disciplina?", + tipo: :objetiva + ) + questao.opcoes.build(numero: 1, texto: "Boa") + questao.opcoes.build(numero: 2, texto: "Excelente") + + expect(questao).to be_valid + end + + it "ignora opções marcadas para remoção" do + questao = described_class.new( + enunciado: "Como você avalia a disciplina?", + tipo: :objetiva + ) + opcao = questao.opcoes.build(numero: 1, texto: "Boa") + opcao.mark_for_destruction + + expect(questao).not_to be_valid + expect(questao.errors[:opcoes]) + .to include("devem ter pelo menos duas alternativas para questão objetiva") + end + + it "não permite opções em questão discursiva" do + questao = described_class.new( + enunciado: "Descreva sua experiência", + tipo: :discursiva + ) + questao.opcoes.build(numero: 1, texto: "Boa") + + expect(questao).not_to be_valid + expect(questao.errors[:opcoes]) + .to include("não devem existir em questão discursiva") + end + + it "normaliza o enunciado" do + questao = described_class.new( + enunciado: " Descreva sua experiência ", + tipo: :discursiva + ) + + questao.valid? + + expect(questao.enunciado).to eq("Descreva sua experiência") + end + end +end diff --git a/spec/models/template_spec.rb b/spec/models/template_spec.rb new file mode 100644 index 0000000000..9c16d427af --- /dev/null +++ b/spec/models/template_spec.rb @@ -0,0 +1,145 @@ +require "rails_helper" + +RSpec.describe Template, type: :model do + let(:template) do + described_class.new( + titulo: "Avaliação de Disciplina", + descricao: "Template semestral", + criado_em: Time.current, + adm_id: 1 + ) + end + let(:utilizacao_questao) do + instance_double(UtilizacaoQuestao, marked_for_destruction?: false) + end + let(:utilizacao_removida) do + instance_double(UtilizacaoQuestao, marked_for_destruction?: true) + end + let(:adm) do + double( + "PerfilAdm", + destroyed?: false, + marked_for_destruction?: false, + new_record?: false, + valid?: true + ) + end + + before do + allow(template) + .to receive(:adm) + .and_return(adm) + end + + describe "associações" do + it "pertence a um administrador pela chave adm_id" do + association = described_class.reflect_on_association(:adm) + + expect(association.macro).to eq(:belongs_to) + expect(association.class_name).to eq("PerfilAdm") + expect(association.foreign_key).to eq("adm_id") + end + + it "possui utilizações de questões removidas em cascata" do + association = + described_class.reflect_on_association(:utilizacao_questoes) + + expect(association.macro).to eq(:has_many) + expect(association.class_name).to eq("UtilizacaoQuestao") + expect(association.foreign_key).to eq("template_id") + expect(association.options[:dependent]).to eq(:destroy) + end + + it "possui questões através das utilizações" do + association = described_class.reflect_on_association(:questoes) + + expect(association.macro).to eq(:has_many) + expect(association.options[:through]).to eq(:utilizacao_questoes) + expect(association.options[:source]).to eq(:questao) + end + + it "possui formulários sem removê-los junto com o template" do + association = described_class.reflect_on_association(:formularios) + + expect(association.macro).to eq(:has_many) + expect(association.class_name).to eq("Formulario") + expect(association.options[:dependent]).to eq(:nullify) + end + end + + describe "validações" do + it "exige título" do + template.titulo = nil + simular_utilizacoes(template, [ utilizacao_questao ]) + + expect(template).not_to be_valid + expect(template.errors[:titulo]).not_to be_empty + end + + it "exige ao menos uma utilização de questão" do + simular_utilizacoes(template, []) + + expect(template).not_to be_valid + expect(template.errors[:utilizacao_questoes]) + .to include("deve conter ao menos uma questão") + end + + it "ignora utilizações marcadas para remoção" do + simular_utilizacoes(template, [ utilizacao_removida ]) + + expect(template).not_to be_valid + expect(template.errors[:utilizacao_questoes]) + .to include("deve conter ao menos uma questão") + end + + it "é válido com título, administrador e uma questão" do + simular_utilizacoes(template, [ utilizacao_questao ]) + + expect(template).to be_valid + end + + it "preenche criado_em automaticamente" do + template.criado_em = nil + simular_utilizacoes(template, [ utilizacao_questao ]) + + template.valid? + + expect(template.criado_em).to be_present + end + + it "normaliza o título" do + template.titulo = " Avaliação de Disciplina " + simular_utilizacoes(template, [ utilizacao_questao ]) + + template.valid? + + expect(template.titulo).to eq("Avaliação de Disciplina") + end + + it "limita a descrição a dois mil caracteres" do + template.descricao = "a" * 2_001 + simular_utilizacoes(template, [ utilizacao_questao ]) + + expect(template).not_to be_valid + expect(template.errors[:descricao]).not_to be_empty + end + end + + describe "#criado_por?" do + it "retorna verdadeiro quando o perfil é o administrador criador" do + perfil_adm = double("PerfilAdm", id: 1) + + expect(template.criado_por?(perfil_adm)).to be(true) + end + + it "retorna falso quando o perfil é nil" do + expect(template.criado_por?(nil)).to be(false) + end + end + + def simular_utilizacoes(template, utilizacoes) + allow(template) + .to receive(:utilizacao_questoes) + .and_return(utilizacoes) + end +end diff --git a/spec/models/utilizacao_questao_spec.rb b/spec/models/utilizacao_questao_spec.rb new file mode 100644 index 0000000000..8096510a05 --- /dev/null +++ b/spec/models/utilizacao_questao_spec.rb @@ -0,0 +1,101 @@ +require "rails_helper" + +RSpec.describe UtilizacaoQuestao, type: :model do + describe "associações" do + it "pertence a um template" do + association = described_class.reflect_on_association(:template) + + expect(association.macro).to eq(:belongs_to) + expect(association.class_name).to eq("Template") + expect(association.foreign_key).to eq("template_id") + end + + it "pertence a uma questão" do + association = described_class.reflect_on_association(:questao) + + expect(association.macro).to eq(:belongs_to) + expect(association.class_name).to eq("Questao") + expect(association.foreign_key).to eq("questao_id") + end + + it "pode pertencer a outra utilização de questão como parent" do + association = described_class.reflect_on_association(:parent) + + expect(association.macro).to eq(:belongs_to) + expect(association.class_name).to eq("UtilizacaoQuestao") + expect(association.foreign_key).to eq("parent_id") + expect(association.options[:optional]).to be(true) + end + + it "possui children removidos em cascata" do + association = described_class.reflect_on_association(:children) + + expect(association.macro).to eq(:has_many) + expect(association.class_name).to eq("UtilizacaoQuestao") + expect(association.foreign_key).to eq("parent_id") + expect(association.options[:dependent]).to eq(:destroy) + end + + it "expõe opções através da questão" do + association = described_class.reflect_on_association(:opcoes) + + expect(association.macro).to eq(:has_many) + expect(association.options[:through]).to eq(:questao) + expect(association.options[:source]).to eq(:opcoes) + end + end + + describe "validações" do + it "exige template" do + validator = presence_validator_for(:template) + + expect(validator).to be_present + end + + it "exige questão" do + validator = presence_validator_for(:questao) + + expect(validator).to be_present + end + + it "exige número inteiro e positivo" do + validators = described_class.validators_on(:numero) + + expect(validators.map(&:kind)).to include(:presence, :numericality) + expect(numericality_validator_for(:numero).options) + .to include(only_integer: true, greater_than: 0) + end + + it "mantém número único por template e parent" do + validator = uniqueness_validator_for(:numero) + + expect(validator.options[:scope]).to eq(%i[template_id parent_id]) + end + end + + describe "#raiz?" do + it "retorna verdadeiro quando não há parent" do + utilizacao = described_class.new(parent_id: nil) + + expect(utilizacao).to be_raiz + end + + it "retorna falso quando há parent" do + utilizacao = described_class.new(parent_id: 1) + + expect(utilizacao).not_to be_raiz + end + end + + def presence_validator_for(attribute) + described_class.validators_on(attribute).find { |validator| validator.kind == :presence } + end + + def numericality_validator_for(attribute) + described_class.validators_on(attribute).find { |validator| validator.kind == :numericality } + end + + def uniqueness_validator_for(attribute) + described_class.validators_on(attribute).find { |validator| validator.kind == :uniqueness } + end +end diff --git a/spec/requests/templates_spec.rb b/spec/requests/templates_spec.rb new file mode 100644 index 0000000000..8cb487c4b4 --- /dev/null +++ b/spec/requests/templates_spec.rb @@ -0,0 +1,159 @@ +require "rails_helper" + +RSpec.describe "Templates", type: :request do + let(:current_adm) { double("PerfilAdm", id: 1) } + let(:current_usuario) do + double( + "Usuario", + administrador?: true, + perfil_adm: current_adm + ) + end + let(:template) do + Template.new( + id: 1, + titulo: "Avaliação", + descricao: "Descrição do template", + adm_id: 1 + ) + end + + before do + allow(template).to receive(:persisted?).and_return(true) + allow_any_instance_of(ApplicationController) + .to receive(:current_usuario) + .and_return(current_usuario) + + allow_any_instance_of(ApplicationController) + .to receive(:current_adm) + .and_return(current_adm) + + allow_any_instance_of(ApplicationController) + .to receive(:authorize!) + .and_return(true) + + allow(template).to receive(:questoes).and_return([]) + end + + describe "GET /templates" do + it "returns a successful response" do + policy_scope = double("TemplatePolicyScope") + included_scope = double("IncludedTemplates") + ordered_scope = double("OrderedTemplates") + + allow_any_instance_of(ApplicationController) + .to receive(:policy_scope) + .with(Template) + .and_return(policy_scope) + + allow(policy_scope) + .to receive(:includes) + .with(adm: :usuario) + .and_return(included_scope) + allow(included_scope).to receive(:recentes).and_return(ordered_scope) + allow(ordered_scope) + .to receive(:criados_por) + .with(current_adm) + .and_return([]) + allow(ordered_scope) + .to receive(:criados_por_outros) + .with(current_adm) + .and_return([]) + + get templates_path + + expect(response).to have_http_status(:ok) + end + end + + describe "GET /templates/new" do + it "returns a successful response" do + new_template = Template.new(adm_id: 1) + + allow(new_template).to receive(:questoes).and_return([]) + allow(Template) + .to receive(:new) + .with(adm: current_adm) + .and_return(new_template) + + get new_template_path + + expect(response).to have_http_status(:ok) + end + end + + describe "GET /templates/:id" do + it "returns a successful response" do + allow(Template).to receive(:find).with("1").and_return(template) + + get template_path(template) + + expect(response).to have_http_status(:ok) + end + end + + describe "POST /templates" do + it "creates a template and redirects to the created record" do + new_template = Template.new( + id: 1, + titulo: "Avaliação", + descricao: "Descrição", + adm_id: 1 + ) + + allow(new_template).to receive(:persisted?).and_return(true) + allow(new_template).to receive(:adm=) + allow(new_template).to receive(:save).and_return(true) + allow(Template) + .to receive(:new) + .and_return(new_template) + + post templates_path, params: { + template: { + titulo: "Avaliação", + descricao: "Descrição" + } + } + + expect(new_template).to have_received(:save) + expect(response).to redirect_to(template_path(new_template)) + end + end + + describe "GET /templates/:id/edit" do + it "returns a successful response for the owner" do + allow(Template).to receive(:find).with("1").and_return(template) + + get edit_template_path(template) + + expect(response).to have_http_status(:ok) + end + end + + describe "PATCH /templates/:id" do + it "updates the template and redirects to the index" do + allow(Template).to receive(:find).with("1").and_return(template) + allow(template).to receive(:update).and_return(true) + + patch template_path(template), params: { + template: { titulo: "Novo título" } + } + + expect(template).to have_received(:update) + .with(ActionController::Parameters.new(titulo: "Novo título").permit!) + expect(response).to redirect_to(template_path(template)) + end + end + + describe "DELETE /templates/:id" do + it "destroys the template and redirects to the index" do + allow(Template).to receive(:find).with("1").and_return(template) + allow(template).to receive(:destroy) + + delete template_path(template) + + expect(template).to have_received(:destroy) + expect(response).to redirect_to(templates_path) + end + end +end From 75bf31ff9f11f25b61640109fbe882d47a3003d9 Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Mon, 15 Jun 2026 18:18:50 -0300 Subject: [PATCH 032/125] =?UTF-8?q?feat(formulario):=20adiciona=20models?= =?UTF-8?q?=20de=20formul=C3=A1rio=20e=20avalia=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/avaliacao.rb | 64 ++++++++++++++++ app/models/formulario.rb | 73 +++++++++++++++++++ .../20260612221605_create_formularios.rb | 13 ++++ .../20260612221606_create_avaliacoes.rb | 15 ++++ 4 files changed, 165 insertions(+) create mode 100644 app/models/avaliacao.rb create mode 100644 app/models/formulario.rb create mode 100644 db/migrate/20260612221605_create_formularios.rb create mode 100644 db/migrate/20260612221606_create_avaliacoes.rb diff --git a/app/models/avaliacao.rb b/app/models/avaliacao.rb new file mode 100644 index 0000000000..85fb5cf8ea --- /dev/null +++ b/app/models/avaliacao.rb @@ -0,0 +1,64 @@ +class Avaliacao < ApplicationRecord + belongs_to :participacao_turma, + class_name: "ParticipacaoTurma", + inverse_of: :avaliacoes + + belongs_to :formulario, + class_name: "Formulario", + inverse_of: :avaliacoes + + has_many :respostas, + class_name: "Resposta", + dependent: :destroy, + inverse_of: :avaliacao + + validates :participacao_turma, presence: true + validates :formulario, presence: true + + validates :participacao_turma_id, + uniqueness: { + scope: :formulario_id, + message: "já possui avaliação para este formulário" + } + + validate :participacao_deve_ser_da_turma_do_formulario + validate :participacao_deve_corresponder_ao_publico_alvo + + scope :respondidas, -> { + where.not(respondido_em: nil) + } + + scope :pendentes, -> { + where(respondido_em: nil) + } + + def respondida? + respondido_em.present? + end + + def pendente? + !respondida? + end + + def marcar_como_respondida! + update!(respondido_em: Time.current) + end + + private + + def participacao_deve_ser_da_turma_do_formulario + return if participacao_turma.blank? + return if formulario.blank? + return if participacao_turma.turma_id == formulario.turma_id + + errors.add(:participacao_turma, "deve pertencer à turma do formulário") + end + + def participacao_deve_corresponder_ao_publico_alvo + return if participacao_turma.blank? + return if formulario.blank? + return if participacao_turma.corresponde_ao_publico?(formulario.publico_alvo) + + errors.add(:participacao_turma, "não corresponde ao público-alvo do formulário") + end +end diff --git a/app/models/formulario.rb b/app/models/formulario.rb new file mode 100644 index 0000000000..a9f8e6999b --- /dev/null +++ b/app/models/formulario.rb @@ -0,0 +1,73 @@ +class Formulario < ApplicationRecord + enum :publico_alvo, { + docentes: 0, + discentes: 1 + } + + belongs_to :adm, + class_name: "PerfilAdm", + foreign_key: :adm_id, + inverse_of: :formularios + + belongs_to :turma, + class_name: "Turma", + inverse_of: :formularios + + belongs_to :template, + class_name: "Template", + optional: true, + inverse_of: :formularios + + has_many :avaliacoes, + class_name: "Avaliacao", + dependent: :restrict_with_error, + inverse_of: :formulario + + validates :adm, presence: true + validates :turma, presence: true + validates :publico_alvo, presence: true + + validate :adm_deve_pertencer_ao_departamento_da_turma + + before_validation :definir_criado_em + + scope :recentes, -> { + order(criado_em: :desc, id: :desc) + } + + scope :do_departamento, ->(departamento) { + joins(turma: :materia) + .where(materias: { departamento_id: departamento.id }) + } + + def participacoes_alvo + return turma.participantes_docentes if docentes? + return turma.participantes_discentes if discentes? + + ParticipacaoTurma.none + end + + def criar_avaliacoes_pendentes! + participacoes_alvo.find_each do |participacao| + avaliacoes.find_or_create_by!(participacao_turma: participacao) + end + end + + def criado_por?(perfil_adm) + adm_id == perfil_adm&.id + end + + private + + def definir_criado_em + self.criado_em ||= Time.current + end + + def adm_deve_pertencer_ao_departamento_da_turma + return if adm.blank? + return if turma.blank? + return if adm.departamento_id == turma.departamento_id + + errors.add(:adm, "deve pertencer ao mesmo departamento da turma") + end +end diff --git a/db/migrate/20260612221605_create_formularios.rb b/db/migrate/20260612221605_create_formularios.rb new file mode 100644 index 0000000000..8f55ce56f8 --- /dev/null +++ b/db/migrate/20260612221605_create_formularios.rb @@ -0,0 +1,13 @@ +class CreateFormularios < ActiveRecord::Migration[8.1] + def change + create_table :formularios do |t| + t.references :adm, null: false, foreign_key: { to_table: :perfis_adm } + t.references :turma, null: false, foreign_key: true + t.references :template, null: true, foreign_key: true + t.integer :publico_alvo, null: false + t.datetime :criado_em, null: false + + t.timestamps + end + end +end diff --git a/db/migrate/20260612221606_create_avaliacoes.rb b/db/migrate/20260612221606_create_avaliacoes.rb new file mode 100644 index 0000000000..41ba05f4aa --- /dev/null +++ b/db/migrate/20260612221606_create_avaliacoes.rb @@ -0,0 +1,15 @@ +class CreateAvaliacoes < ActiveRecord::Migration[8.1] + def change + create_table :avaliacoes do |t| + t.references :participacao_turma, null: false, foreign_key: true + t.references :formulario, null: false, foreign_key: true + t.datetime :respondido_em + + t.timestamps + end + + add_index :avaliacoes, + [ :participacao_turma_id, :formulario_id ], + unique: true + end +end From 21471710f2ddf438e852cc4a4ce5f33f1fb83b61 Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Mon, 15 Jun 2026 18:19:33 -0300 Subject: [PATCH 033/125] feat(resposta): adiciona models de resposta --- app/models/opcao_escolhida.rb | 39 +++++++++++++++++++ app/models/texto.rb | 26 +++++++++++++ db/migrate/20260612221607_create_respostas.rb | 12 ++++++ db/migrate/20260612221608_create_textos.rb | 13 +++++++ ...20260612221609_create_opcoes_escolhidas.rb | 12 ++++++ 5 files changed, 102 insertions(+) create mode 100644 app/models/opcao_escolhida.rb create mode 100644 app/models/texto.rb create mode 100644 db/migrate/20260612221607_create_respostas.rb create mode 100644 db/migrate/20260612221608_create_textos.rb create mode 100644 db/migrate/20260612221609_create_opcoes_escolhidas.rb diff --git a/app/models/opcao_escolhida.rb b/app/models/opcao_escolhida.rb new file mode 100644 index 0000000000..6b2f436bd7 --- /dev/null +++ b/app/models/opcao_escolhida.rb @@ -0,0 +1,39 @@ +class OpcaoEscolhida < ApplicationRecord + belongs_to :resposta, + class_name: "Resposta", + inverse_of: :opcoes_escolhidas + + belongs_to :opcao, + class_name: "Opcao", + inverse_of: :opcoes_escolhidas + + validates :resposta, presence: true + validates :opcao, presence: true + + validates :opcao_id, + uniqueness: { + scope: :resposta_id, + message: "já foi escolhida nesta resposta" + } + + validate :opcao_deve_pertencer_a_questao_da_resposta + validate :resposta_deve_ser_de_questao_objetiva + + private + + def opcao_deve_pertencer_a_questao_da_resposta + return if resposta.blank? + return if opcao.blank? + return if resposta.questao_id == opcao.questao_id + + errors.add(:opcao, "deve pertencer à questão respondida") + end + + def resposta_deve_ser_de_questao_objetiva + return if resposta.blank? + return if resposta.questao.blank? + return if resposta.questao.objetiva? + + errors.add(:resposta, "deve pertencer a uma questão objetiva") + end +end diff --git a/app/models/texto.rb b/app/models/texto.rb new file mode 100644 index 0000000000..a38bb5b550 --- /dev/null +++ b/app/models/texto.rb @@ -0,0 +1,26 @@ +class Texto < ApplicationRecord + belongs_to :resposta, + class_name: "Resposta", + inverse_of: :texto + + validates :resposta, presence: true + validates :texto, presence: true + + validate :resposta_deve_ser_de_questao_discursiva + + before_validation :normalizar_texto + + private + + def normalizar_texto + self.texto = texto.to_s.strip + end + + def resposta_deve_ser_de_questao_discursiva + return if resposta.blank? + return if resposta.questao.blank? + return if resposta.questao.discursiva? + + errors.add(:resposta, "deve pertencer a uma questão discursiva") + end +end diff --git a/db/migrate/20260612221607_create_respostas.rb b/db/migrate/20260612221607_create_respostas.rb new file mode 100644 index 0000000000..75bc3d4136 --- /dev/null +++ b/db/migrate/20260612221607_create_respostas.rb @@ -0,0 +1,12 @@ +class CreateRespostas < ActiveRecord::Migration[8.1] + def change + create_table :respostas do |t| + t.references :questao, null: false, foreign_key: true + t.references :avaliacao, null: false, foreign_key: true + + t.timestamps + end + + add_index :respostas, [ :questao_id, :avaliacao_id ], unique: true + end +end diff --git a/db/migrate/20260612221608_create_textos.rb b/db/migrate/20260612221608_create_textos.rb new file mode 100644 index 0000000000..ed402e2ced --- /dev/null +++ b/db/migrate/20260612221608_create_textos.rb @@ -0,0 +1,13 @@ +class CreateTextos < ActiveRecord::Migration[8.1] + def change + create_table :textos do |t| + t.references :resposta, + null: false, + foreign_key: true, + index: { unique: true } + t.text :texto, null: false + + t.timestamps + end + end +end diff --git a/db/migrate/20260612221609_create_opcoes_escolhidas.rb b/db/migrate/20260612221609_create_opcoes_escolhidas.rb new file mode 100644 index 0000000000..05e07eb555 --- /dev/null +++ b/db/migrate/20260612221609_create_opcoes_escolhidas.rb @@ -0,0 +1,12 @@ +class CreateOpcoesEscolhidas < ActiveRecord::Migration[8.1] + def change + create_table :opcoes_escolhidas do |t| + t.references :resposta, null: false, foreign_key: true + t.references :opcao, null: false, foreign_key: true + + t.timestamps + end + + add_index :opcoes_escolhidas, [ :resposta_id, :opcao_id ], unique: true + end +end From b69ea22c9b33b3784241b62890f8d3cad8e0cdd7 Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Mon, 15 Jun 2026 18:22:31 -0300 Subject: [PATCH 034/125] =?UTF-8?q?feat(auth):=20adiciona=20uma=20camada?= =?UTF-8?q?=20"policy"=20para=20autoriza=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/policies/application_policy.rb | 33 ++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 app/policies/application_policy.rb diff --git a/app/policies/application_policy.rb b/app/policies/application_policy.rb new file mode 100644 index 0000000000..a200db57da --- /dev/null +++ b/app/policies/application_policy.rb @@ -0,0 +1,33 @@ +class ApplicationPolicy + attr_reader :usuario, :record + + def initialize(usuario, record) + @usuario = usuario + @record = record + end + + def self.scope(usuario, scope) + const_get(:Scope).new(usuario, scope).resolve + end + + def adm? + usuario&.administrador? + end + + def current_adm + usuario&.perfil_adm + end + + class Scope + attr_reader :usuario, :scope + + def initialize(usuario, scope) + @usuario = usuario + @scope = scope + end + + def resolve + scope.none + end + end +end From be78363aec267dd0bc1875f0e28608ddd1ec7121 Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Mon, 15 Jun 2026 18:26:35 -0300 Subject: [PATCH 035/125] =?UTF-8?q?feat(templates):=20adiciona=20uma=20pol?= =?UTF-8?q?=C3=ADtica=20de=20autoriza=C3=A7=C3=A3o=20para=20templates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/policies/template_policy.rb | 47 +++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 app/policies/template_policy.rb diff --git a/app/policies/template_policy.rb b/app/policies/template_policy.rb new file mode 100644 index 0000000000..155ff3e308 --- /dev/null +++ b/app/policies/template_policy.rb @@ -0,0 +1,47 @@ +class TemplatePolicy < ApplicationPolicy + class Scope < ApplicationPolicy::Scope + def resolve + return scope.all if usuario&.administrador? + + scope.none + end + end + + def index? + adm? + end + + def show? + adm? + end + + def new? + create? + end + + def create? + adm? + end + + def edit? + update? + end + + def update? + dono_do_template? + end + + def destroy? + dono_do_template? + end + + def use? + adm? + end + + private + + def dono_do_template? + adm? && record.criado_por?(current_adm) + end +end From 6daa1958d1fb2567a1a0281b287709509fb6480e Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Mon, 15 Jun 2026 18:29:39 -0300 Subject: [PATCH 036/125] =?UTF-8?q?chore(lint):=20adiciona=20configura?= =?UTF-8?q?=C3=A7=C3=B5es=20do=20ERB=20lint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .erb_lint.yml | 91 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 .erb_lint.yml diff --git a/.erb_lint.yml b/.erb_lint.yml new file mode 100644 index 0000000000..76b12fc74d --- /dev/null +++ b/.erb_lint.yml @@ -0,0 +1,91 @@ +--- +glob: "app/views/**/*.erb" + +exclude: + - "vendor/**/*" + - "node_modules/**/*" + - "tmp/**/*" + - "log/**/*" + +EnableDefaultLinters: true + +linters: + AllowedScriptType: + enabled: true + allow_blank: true + allowed_types: + - "application/json" + - "text/html" + - "text/javascript" + + ClosingErbTagIndent: + enabled: true + + CommentSyntax: + enabled: true + + ExtraNewline: + enabled: true + + FinalNewline: + enabled: true + + NoJavascriptTagHelper: + enabled: true + + ParserErrors: + enabled: true + + RequireInputAutocomplete: + enabled: true + + RightTrim: + enabled: true + + ErbSafety: + enabled: true + + NoUnusedDisable: + enabled: true + + Rubocop: + enabled: true + only: + - Lint/Debugger + - Lint/DuplicateBranch + - Lint/DuplicateHashKey + - Lint/RedundantSafeNavigation + - Lint/UselessAssignment + - Style/HashSyntax + rubocop_config: + inherit_gem: + rubocop-rails-omakase: rubocop.yml + + SelfClosingTag: + enabled: true + enforced_style: "never" + + SpaceAroundErbTag: + enabled: true + + SpaceIndentation: + enabled: true + tab_width: 2 + + SpaceInHtmlTag: + enabled: true + + TrailingWhitespace: + enabled: true + + HardCodedString: + enabled: false + + PartialInstanceVariable: + enabled: false + + RequireScriptNonce: + enabled: false + + StrictLocals: + enabled: false From a8e6d748226f6d58db23cdd156c30f4b35c4d7c8 Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Mon, 15 Jun 2026 18:30:45 -0300 Subject: [PATCH 037/125] =?UTF-8?q?chore(deps):=20adiciona=20as=20depend?= =?UTF-8?q?=C3=AAncias=20ERB=20lint=20e=20bcrypt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Gemfile | 3 ++- Gemfile.lock | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/Gemfile b/Gemfile index a7cf68ece5..33943730cf 100644 --- a/Gemfile +++ b/Gemfile @@ -18,7 +18,7 @@ gem "stimulus-rails" gem "jbuilder" # Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] -# gem "bcrypt", "~> 3.1.7" +gem "bcrypt", "~> 3.1.7" # Windows does not include zoneinfo files, so bundle the tzinfo-data gem gem "tzinfo-data", platforms: %i[ windows jruby ] @@ -61,6 +61,7 @@ group :development do gem "web-console" gem "rubycritic", require: false gem "rdoc", require: false + gem "erb_lint", require: false end group :test do diff --git a/Gemfile.lock b/Gemfile.lock index 71e9211154..ef597332b8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -83,7 +83,15 @@ GEM ice_nine (~> 0.11.0) thread_safe (~> 0.3, >= 0.3.1) base64 (0.3.0) + bcrypt (3.1.22) bcrypt_pbkdf (1.1.2) + better_html (2.2.0) + actionview (>= 7.0) + activesupport (>= 7.0) + ast (~> 2.0) + erubi (~> 1.4) + parser (>= 2.4) + smart_properties bigdecimal (4.1.2) bindex (0.8.1) bootsnap (1.24.3) @@ -184,6 +192,13 @@ GEM zeitwerk (~> 2.6) ed25519 (1.4.0) erb (6.0.4) + erb_lint (0.9.0) + activesupport + better_html (>= 2.0.1) + parser (>= 2.7.1.4) + rainbow + rubocop (>= 1) + smart_properties erubi (1.13.1) et-orbi (1.4.0) tzinfo @@ -449,6 +464,7 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) + smart_properties (1.17.0) solid_cable (3.0.12) actioncable (>= 7.2) activejob (>= 7.2) @@ -530,6 +546,7 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES + bcrypt (~> 3.1.7) bootsnap brakeman bundler-audit @@ -537,6 +554,7 @@ DEPENDENCIES cucumber-rails database_cleaner-active_record debug + erb_lint image_processing (~> 1.2) importmap-rails jbuilder @@ -577,7 +595,9 @@ CHECKSUMS ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 axiom-types (0.1.1) sha256=c1ff113f3de516fa195b2db7e0a9a95fd1b08475a502ff660d04507a09980383 base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + bcrypt (3.1.22) sha256=1f0072e88c2d705d94aff7f2c5cb02eb3f1ec4b8368671e19112527489f29032 bcrypt_pbkdf (1.1.2) sha256=c2414c23ce66869b3eb9f643d6a3374d8322dfb5078125c82792304c10b94cf6 + better_html (2.2.0) sha256=e68ab66ab09696b708333bbf35e8aa3c107500ba7892f528e2111624bdd8cf76 bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e bootsnap (1.24.3) sha256=f7fa3d20597e2f0aa52b0a1aba83fb54d4f79e9c2e210ec4fa1e8895514dcad8 @@ -617,6 +637,7 @@ CHECKSUMS dry-types (1.9.1) sha256=baebeecdb9f8395d6c9d227b62011279440943e3ef2468fe8ccc1ba11467f178 ed25519 (1.4.0) sha256=16e97f5198689a154247169f3453ef4cfd3f7a47481fde0ae33206cdfdcac506 erb (6.0.4) sha256=38e3803694be357fe2bfe312487c74beaf9fb4e5beb3e22498952fe1645b95d9 + erb_lint (0.9.0) sha256=dfb5e40ad839e8d1f0d56ca85ec9a7ac4c9cd966ec281138282f35b323ca7c31 erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 et-orbi (1.4.0) sha256=6c7e3c90779821f9e3b324c5e96fda9767f72995d6ae435b96678a4f3e2de8bc ffi (1.17.4-aarch64-linux-gnu) sha256=b208f06f91ffd8f5e1193da3cae3d2ccfc27fc36fba577baf698d26d91c080df @@ -715,6 +736,7 @@ CHECKSUMS simplecov (0.22.0) sha256=fe2622c7834ff23b98066bb0a854284b2729a569ac659f82621fc22ef36213a5 simplecov-html (0.13.2) sha256=bd0b8e54e7c2d7685927e8d6286466359b6f16b18cb0df47b508e8d73c777246 simplecov_json_formatter (0.1.4) sha256=529418fbe8de1713ac2b2d612aa3daa56d316975d307244399fa4838c601b428 + smart_properties (1.17.0) sha256=f9323f8122e932341756ddec8e0ac9ec6e238408a7661508be99439ca6d6384b solid_cable (3.0.12) sha256=a168a54731a455d5627af48d8441ea3b554b8c1f6e6cd6074109de493e6b0460 solid_cache (1.0.10) sha256=bc05a2fb3ac78a6f43cbb5946679cf9db67dd30d22939ededc385cb93e120d41 solid_queue (1.4.0) sha256=e6a18d196f0b27cb6e3c77c5b31258b05fb634f8ed64fb1866ed164047216c2a From 608e55d5a360953b89ec1ed23dabb72fd1e199e3 Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Mon, 15 Jun 2026 18:33:40 -0300 Subject: [PATCH 038/125] chore(test): remove estrutura `test` de Minitest e substitui por RSpec --- config/application.rb | 4 ++++ test/controllers/.keep | 0 test/fixtures/files/.keep | 0 test/helpers/.keep | 0 test/integration/.keep | 0 test/mailers/.keep | 0 test/models/.keep | 0 test/test_helper.rb | 15 --------------- 8 files changed, 4 insertions(+), 15 deletions(-) delete mode 100644 test/controllers/.keep delete mode 100644 test/fixtures/files/.keep delete mode 100644 test/helpers/.keep delete mode 100644 test/integration/.keep delete mode 100644 test/mailers/.keep delete mode 100644 test/models/.keep delete mode 100644 test/test_helper.rb diff --git a/config/application.rb b/config/application.rb index b4848d838b..6eb99b157c 100644 --- a/config/application.rb +++ b/config/application.rb @@ -23,5 +23,9 @@ class Application < Rails::Application # # config.time_zone = "Central Time (US & Canada)" # config.eager_load_paths << Rails.root.join("extras") + + config.generators do |g| + g.test_framework :rspec + end end end diff --git a/test/controllers/.keep b/test/controllers/.keep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/test/fixtures/files/.keep b/test/fixtures/files/.keep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/test/helpers/.keep b/test/helpers/.keep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/test/integration/.keep b/test/integration/.keep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/test/mailers/.keep b/test/mailers/.keep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/test/models/.keep b/test/models/.keep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/test/test_helper.rb b/test/test_helper.rb deleted file mode 100644 index 0c22470ec1..0000000000 --- a/test/test_helper.rb +++ /dev/null @@ -1,15 +0,0 @@ -ENV["RAILS_ENV"] ||= "test" -require_relative "../config/environment" -require "rails/test_help" - -module ActiveSupport - class TestCase - # Run tests in parallel with specified workers - parallelize(workers: :number_of_processors) - - # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. - fixtures :all - - # Add more helper methods to be used by all tests here... - end -end From 2a5ec0cffd3da0697351adc82260269b6dceba92 Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Mon, 15 Jun 2026 18:34:19 -0300 Subject: [PATCH 039/125] =?UTF-8?q?fix(db):=20atualiza=20o=20schema=20ap?= =?UTF-8?q?=C3=B3s=20as=20migra=C3=A7=C3=B5es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db/schema.rb | 192 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 191 insertions(+), 1 deletion(-) diff --git a/db/schema.rb b/db/schema.rb index 03e73681a8..bf123ebc37 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,5 +10,195 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 0) do +ActiveRecord::Schema[8.1].define(version: 2026_06_12_221655) do + create_table "avaliacoes", force: :cascade do |t| + t.datetime "created_at", null: false + t.integer "formulario_id", null: false + t.integer "participacao_turma_id", null: false + t.datetime "respondido_em" + t.datetime "updated_at", null: false + t.index ["formulario_id"], name: "index_avaliacoes_on_formulario_id" + t.index ["participacao_turma_id", "formulario_id"], name: "index_avaliacoes_on_participacao_turma_id_and_formulario_id", unique: true + t.index ["participacao_turma_id"], name: "index_avaliacoes_on_participacao_turma_id" + end + + create_table "departamentos", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "nome", null: false + t.datetime "updated_at", null: false + t.index ["nome"], name: "index_departamentos_on_nome", unique: true + end + + create_table "formularios", force: :cascade do |t| + t.integer "adm_id", null: false + t.datetime "created_at", null: false + t.datetime "criado_em", null: false + t.integer "publico_alvo", null: false + t.integer "template_id" + t.integer "turma_id", null: false + t.datetime "updated_at", null: false + t.index ["adm_id"], name: "index_formularios_on_adm_id" + t.index ["template_id"], name: "index_formularios_on_template_id" + t.index ["turma_id"], name: "index_formularios_on_turma_id" + end + + create_table "materias", force: :cascade do |t| + t.string "codigo", null: false + t.datetime "created_at", null: false + t.integer "departamento_id", null: false + t.string "nome", null: false + t.datetime "updated_at", null: false + t.index ["codigo"], name: "index_materias_on_codigo", unique: true + t.index ["departamento_id"], name: "index_materias_on_departamento_id" + end + + create_table "opcoes", force: :cascade do |t| + t.datetime "created_at", null: false + t.integer "numero", null: false + t.integer "questao_id", null: false + t.text "texto", null: false + t.datetime "updated_at", null: false + t.index ["questao_id", "numero"], name: "index_opcoes_on_questao_id_and_numero", unique: true + t.index ["questao_id"], name: "index_opcoes_on_questao_id" + end + + create_table "opcoes_escolhidas", force: :cascade do |t| + t.datetime "created_at", null: false + t.integer "opcao_id", null: false + t.integer "resposta_id", null: false + t.datetime "updated_at", null: false + t.index ["opcao_id"], name: "index_opcoes_escolhidas_on_opcao_id" + t.index ["resposta_id", "opcao_id"], name: "index_opcoes_escolhidas_on_resposta_id_and_opcao_id", unique: true + t.index ["resposta_id"], name: "index_opcoes_escolhidas_on_resposta_id" + end + + create_table "participacoes_turmas", force: :cascade do |t| + t.datetime "created_at", null: false + t.integer "tipo_participacao", null: false + t.integer "turma_id", null: false + t.datetime "updated_at", null: false + t.integer "usuario_id", null: false + t.index ["turma_id"], name: "index_participacoes_turmas_on_turma_id" + t.index ["usuario_id", "turma_id", "tipo_participacao"], name: "idx_on_usuario_id_turma_id_tipo_participacao_a374668730", unique: true + t.index ["usuario_id"], name: "index_participacoes_turmas_on_usuario_id" + end + + create_table "perfis_adm", force: :cascade do |t| + t.datetime "created_at", null: false + t.integer "departamento_id", null: false + t.datetime "updated_at", null: false + t.index ["departamento_id"], name: "index_perfis_adm_on_departamento_id" + end + + create_table "perfis_discentes", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "matricula", null: false + t.datetime "updated_at", null: false + t.index ["matricula"], name: "index_perfis_discentes_on_matricula", unique: true + end + + create_table "perfis_docentes", force: :cascade do |t| + t.datetime "created_at", null: false + t.integer "departamento_id", null: false + t.datetime "updated_at", null: false + t.index ["departamento_id"], name: "index_perfis_docentes_on_departamento_id" + end + + create_table "questoes", force: :cascade do |t| + t.datetime "created_at", null: false + t.text "enunciado", null: false + t.integer "tipo", null: false + t.datetime "updated_at", null: false + end + + create_table "respostas", force: :cascade do |t| + t.integer "avaliacao_id", null: false + t.datetime "created_at", null: false + t.integer "questao_id", null: false + t.datetime "updated_at", null: false + t.index ["avaliacao_id"], name: "index_respostas_on_avaliacao_id" + t.index ["questao_id", "avaliacao_id"], name: "index_respostas_on_questao_id_and_avaliacao_id", unique: true + t.index ["questao_id"], name: "index_respostas_on_questao_id" + end + + create_table "templates", force: :cascade do |t| + t.integer "adm_id", null: false + t.datetime "created_at", null: false + t.datetime "criado_em", null: false + t.text "descricao" + t.string "titulo", null: false + t.datetime "updated_at", null: false + t.index ["adm_id", "titulo"], name: "index_templates_on_adm_id_and_titulo", unique: true + t.index ["adm_id"], name: "index_templates_on_adm_id" + end + + create_table "textos", force: :cascade do |t| + t.datetime "created_at", null: false + t.integer "resposta_id", null: false + t.text "texto", null: false + t.datetime "updated_at", null: false + t.index ["resposta_id"], name: "index_textos_on_resposta_id", unique: true + end + + create_table "turmas", force: :cascade do |t| + t.integer "ano", null: false + t.datetime "created_at", null: false + t.integer "materia_id", null: false + t.integer "numero", null: false + t.integer "semestre", null: false + t.datetime "updated_at", null: false + t.index ["materia_id", "ano", "semestre", "numero"], name: "index_turmas_on_materia_id_and_ano_and_semestre_and_numero", unique: true + t.index ["materia_id"], name: "index_turmas_on_materia_id" + end + + create_table "usuarios", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "email", null: false + t.string "nome", null: false + t.string "senha_digest" + t.integer "status", default: 0, null: false + t.datetime "updated_at", null: false + t.index ["email"], name: "index_usuarios_on_email", unique: true + end + + create_table "utilizacoes_questoes", force: :cascade do |t| + t.datetime "created_at", null: false + t.integer "numero", null: false + t.integer "parent_id" + t.integer "questao_id", null: false + t.integer "template_id", null: false + t.datetime "updated_at", null: false + t.index ["parent_id"], name: "index_utilizacoes_questoes_on_parent_id" + t.index ["questao_id"], name: "index_utilizacoes_questoes_on_questao_id" + t.index ["template_id", "numero"], name: "index_utilizacoes_questoes_on_template_id_and_numero", unique: true, where: "parent_id IS NULL" + t.index ["template_id", "parent_id", "numero"], name: "idx_on_template_id_parent_id_numero_d7bb201bb5", unique: true, where: "parent_id IS NOT NULL" + t.index ["template_id", "parent_id", "questao_id"], name: "idx_on_template_id_parent_id_questao_id_960c40742a", unique: true, where: "parent_id IS NOT NULL" + t.index ["template_id", "questao_id"], name: "index_utilizacoes_questoes_on_template_id_and_questao_id", unique: true, where: "parent_id IS NULL" + t.index ["template_id"], name: "index_utilizacoes_questoes_on_template_id" + end + + add_foreign_key "avaliacoes", "formularios" + add_foreign_key "avaliacoes", "participacoes_turmas" + add_foreign_key "formularios", "perfis_adm", column: "adm_id" + add_foreign_key "formularios", "templates" + add_foreign_key "formularios", "turmas" + add_foreign_key "materias", "departamentos" + add_foreign_key "opcoes", "questoes" + add_foreign_key "opcoes_escolhidas", "opcoes" + add_foreign_key "opcoes_escolhidas", "respostas" + add_foreign_key "participacoes_turmas", "turmas" + add_foreign_key "participacoes_turmas", "usuarios" + add_foreign_key "perfis_adm", "departamentos" + add_foreign_key "perfis_adm", "usuarios", column: "id" + add_foreign_key "perfis_discentes", "usuarios", column: "id" + add_foreign_key "perfis_docentes", "departamentos" + add_foreign_key "perfis_docentes", "usuarios", column: "id" + add_foreign_key "respostas", "avaliacoes" + add_foreign_key "respostas", "questoes" + add_foreign_key "templates", "perfis_adm", column: "adm_id" + add_foreign_key "textos", "respostas" + add_foreign_key "turmas", "materias" + add_foreign_key "utilizacoes_questoes", "questoes" + add_foreign_key "utilizacoes_questoes", "templates" + add_foreign_key "utilizacoes_questoes", "utilizacoes_questoes", column: "parent_id" end From e76b00fcb62a551b9478d751e219585d70d09bab Mon Sep 17 00:00:00 2001 From: Marcio Vieira Date: Mon, 15 Jun 2026 19:54:51 -0300 Subject: [PATCH 040/125] feat(avaliacoes): adiciona listagem de formularios nao respondidos (HU13) --- app/controllers/avaliacoes_controller.rb | 11 +++++++++++ app/views/avaliacoes/pendentes.html.haml | 18 ++++++++++++++++++ config/routes.rb | 8 +++++++- 3 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 app/controllers/avaliacoes_controller.rb create mode 100644 app/views/avaliacoes/pendentes.html.haml diff --git a/app/controllers/avaliacoes_controller.rb b/app/controllers/avaliacoes_controller.rb new file mode 100644 index 0000000000..1e73b586ba --- /dev/null +++ b/app/controllers/avaliacoes_controller.rb @@ -0,0 +1,11 @@ +class AvaliacoesController < ApplicationController + def pendentes + # Simulando o usuário logado provisoriamente + usuario_atual = defined?(current_usuario) ? current_usuario : Usuario.first + + @avaliacoes_pendentes = Avaliacao.pendentes + .joins(:participacao_turma) + .where(participacoes_turmas: { usuario_id: usuario_atual.id }) + .includes(formulario: { turma: :materia }) + end +end \ No newline at end of file diff --git a/app/views/avaliacoes/pendentes.html.haml b/app/views/avaliacoes/pendentes.html.haml new file mode 100644 index 0000000000..a06a5d2124 --- /dev/null +++ b/app/views/avaliacoes/pendentes.html.haml @@ -0,0 +1,18 @@ +%h1 Avaliações Pendentes + +- if @avaliacoes_pendentes.any? + %table + %thead + %tr + %th Turma + %th Status + %th Ação + %tbody + - @avaliacoes_pendentes.each do |avaliacao| + %tr + %td= avaliacao.formulario.turma.materia.nome + %td Não respondido + %td + = link_to 'Responder', '#' +- else + %p Nenhum formulário pendente foi encontrado. \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 48254e88ed..a6a1e4fdd6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -11,4 +11,10 @@ # Defines the root path route ("/") # root "posts#index" -end + + resources :avaliacoes, only: [] do + collection do + get 'pendentes' + end + end +end \ No newline at end of file From c9c713da8b40513f7212c64b523f1736fd60fcdd Mon Sep 17 00:00:00 2001 From: PedroFFranca Date: Sun, 14 Jun 2026 22:42:41 -0300 Subject: [PATCH 041/125] =?UTF-8?q?feat:=20adicionar=20specs=20RSpec=20e?= =?UTF-8?q?=20steps=20Cucumber=20para=20criar=20formul=C3=A1rio=20a=20part?= =?UTF-8?q?ir=20de=20template?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/controllers/application_controller.rb | 12 ++ app/controllers/formularios_controller.rb | 21 +++ .../formularios/create_from_template.rb | 101 +++++++++++++++ app/services/formularios/error.rb | 10 ++ app/services/sigaa/unavailable_error.rb | 7 + app/views/formularios/new.html.erb | 32 +++++ app/views/gerenciamentos/show.html.erb | 4 + config/routes.rb | 2 + ...614120000_create_templates_and_questoes.rb | 22 ++++ ...ate_formularios_and_questoes_formulario.rb | 25 ++++ db/migrate/20260614140000_create_perfis.rb | 37 ++++++ ...20260614150000_align_templates_with_uml.rb | 35 +++++ ...0_align_formularios_and_turmas_with_uml.rb | 78 +++++++++++ .../20260614170000_align_questoes_with_uml.rb | 122 ++++++++++++++++++ .../20260614180000_create_avaliacao_chain.rb | 32 +++++ .../step_definitions/autenticacao_steps.rb | 4 + .../step_definitions/formularios_steps.rb | 64 +++++++++ .../sigaa/class_members_missing_email.json | 25 ++++ spec/models/formulario_spec.rb | 12 ++ spec/rails_helper.rb | 2 +- spec/requests/formularios_spec.rb | 55 ++++++++ .../formularios/create_from_template_spec.rb | 110 ++++++++++++++++ spec/support/authentication_helpers.rb | 9 ++ spec/support/test_builders.rb | 72 +++++++++++ 24 files changed, 892 insertions(+), 1 deletion(-) create mode 100644 app/controllers/formularios_controller.rb create mode 100644 app/services/formularios/create_from_template.rb create mode 100644 app/services/formularios/error.rb create mode 100644 app/services/sigaa/unavailable_error.rb create mode 100644 app/views/formularios/new.html.erb create mode 100644 app/views/gerenciamentos/show.html.erb create mode 100644 db/migrate/20260614120000_create_templates_and_questoes.rb create mode 100644 db/migrate/20260614130000_create_formularios_and_questoes_formulario.rb create mode 100644 db/migrate/20260614140000_create_perfis.rb create mode 100644 db/migrate/20260614150000_align_templates_with_uml.rb create mode 100644 db/migrate/20260614160000_align_formularios_and_turmas_with_uml.rb create mode 100644 db/migrate/20260614170000_align_questoes_with_uml.rb create mode 100644 db/migrate/20260614180000_create_avaliacao_chain.rb create mode 100644 features/step_definitions/autenticacao_steps.rb create mode 100644 features/step_definitions/formularios_steps.rb create mode 100644 spec/fixtures/sigaa/class_members_missing_email.json create mode 100644 spec/models/formulario_spec.rb create mode 100644 spec/requests/formularios_spec.rb create mode 100644 spec/services/formularios/create_from_template_spec.rb create mode 100644 spec/support/authentication_helpers.rb create mode 100644 spec/support/test_builders.rb diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index c3537563da..e3bf9dc4e7 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -4,4 +4,16 @@ class ApplicationController < ActionController::Base # Changes to the importmap will invalidate the etag for HTML responses stale_when_importmap_changes + + helper_method :current_usuario + + def current_usuario + @current_usuario ||= Usuario.find_by(id: session[:usuario_id]) if session[:usuario_id] + end + + def require_admin! + return if current_usuario&.administrador? + + redirect_to(request.referrer || "/", alert: "Acesso não autorizado") + end end diff --git a/app/controllers/formularios_controller.rb b/app/controllers/formularios_controller.rb new file mode 100644 index 0000000000..54532d6a2b --- /dev/null +++ b/app/controllers/formularios_controller.rb @@ -0,0 +1,21 @@ +class FormulariosController < ApplicationController + before_action :require_admin! + + def new + @templates = Template.all + @turmas = Turma.do_semestre_atual.sem_formulario.includes(:materia) + end + + def create + Formularios::CreateFromTemplate.call( + template_id: params[:template_id], + turma_ids: params[:turma_ids], + perfil_adm: current_usuario.perfil_adm + ) + + redirect_to new_formulario_path, + notice: "Formulário criado com sucesso para as turmas selecionadas" + rescue Formularios::Error => e + redirect_to new_formulario_path, alert: e.message + end +end diff --git a/app/services/formularios/create_from_template.rb b/app/services/formularios/create_from_template.rb new file mode 100644 index 0000000000..d18d459ab6 --- /dev/null +++ b/app/services/formularios/create_from_template.rb @@ -0,0 +1,101 @@ +module Formularios + class CreateFromTemplate + SEM_TURMAS = "É necessário selecionar pelo menos uma turma" + SEM_QUESTOES = "O template deve possuir pelo menos uma questão" + TURMAS_INVALIDAS = "Uma ou mais turmas selecionadas são inválidas" + TURMA_COM_FORMULARIO = "Uma ou mais turmas selecionadas já possuem formulário" + + def self.call(template_id:, turma_ids:, perfil_adm:) + new(template_id:, turma_ids:, perfil_adm:).call + end + + def initialize(template_id:, turma_ids:, perfil_adm:) + @template_id = template_id + @turma_ids = Array(turma_ids).map(&:to_i).uniq.reject(&:zero?) + @perfil_adm = perfil_adm + end + + def call + validate_turma_ids! + validate_template! + validate_turmas! + validate_turmas_sem_formulario! + + formularios = [] + + ActiveRecord::Base.transaction do + turmas.each do |turma| + formulario = Formulario.create!( + template: template, + perfil_adm: perfil_adm + ) + + copy_questoes!(formulario) + turma.update!(formulario: formulario) + formularios << formulario + end + end + + formularios + end + + private + + attr_reader :template_id, :turma_ids, :perfil_adm + + def validate_turma_ids! + raise Error, SEM_TURMAS if turma_ids.empty? + end + + def validate_template! + raise Error, SEM_QUESTOES if template.questoes.none? + end + + def validate_turmas! + raise Error, TURMAS_INVALIDAS if turmas.count != turma_ids.size + end + + def validate_turmas_sem_formulario! + raise Error, TURMA_COM_FORMULARIO if turmas.where.not(formulario_id: nil).exists? + end + + def template + @template ||= Template.find(template_id) + end + + def turmas + @turmas ||= Turma.do_semestre_atual.where(id: turma_ids) + end + + def copy_questoes!(formulario) + template.questoes.order(:posicao).each do |questao_template| + questao = formulario.questoes.create!( + enunciado: questao_template.enunciado, + tipo: questao_template.tipo, + obrigatoria: questao_template.obrigatoria, + posicao: questao_template.posicao + ) + + copy_opcoes!(questao_template, questao) + end + end + + def copy_opcoes!(questao_template, questao) + return unless questao_template.objetiva? + + opcoes_template(questao_template).each do |texto| + questao.opcoes.create!(texto: texto) + end + end + + def opcoes_template(questao_template) + if questao_template.opcoes.any? + questao_template.opcoes.pluck(:texto) + elsif questao_template.read_attribute(:opcoes).present? + Array(questao_template.read_attribute(:opcoes)) + else + [] + end + end + end +end diff --git a/app/services/formularios/error.rb b/app/services/formularios/error.rb new file mode 100644 index 0000000000..fa09017b3d --- /dev/null +++ b/app/services/formularios/error.rb @@ -0,0 +1,10 @@ +module Formularios + class Error < StandardError + attr_reader :message + + def initialize(message) + @message = message + super + end + end +end diff --git a/app/services/sigaa/unavailable_error.rb b/app/services/sigaa/unavailable_error.rb new file mode 100644 index 0000000000..d39cdad137 --- /dev/null +++ b/app/services/sigaa/unavailable_error.rb @@ -0,0 +1,7 @@ +module Sigaa + class UnavailableError < StandardError + def flash_message + "Não foi possível buscar os dados. Tente novamente mais tarde." + end + end +end diff --git a/app/views/formularios/new.html.erb b/app/views/formularios/new.html.erb new file mode 100644 index 0000000000..a78c677b1d --- /dev/null +++ b/app/views/formularios/new.html.erb @@ -0,0 +1,32 @@ +

Criar Formulário

+ +<% if flash[:notice] %> +

<%= flash[:notice] %>

+<% end %> + +<% if flash[:alert] %> +

<%= flash[:alert] %>

+<% end %> + +<%= form_with url: formularios_path do |form| %> +
+ + <%= select_tag :template_id, + options_from_collection_for_select(@templates, :id, :nome), + prompt: "Selecione um template" %> +
+ +
+

Turmas

+ <% @turmas.each do |turma| %> +
+ +
+ <% end %> +
+ + <%= form.submit "Criar Formulário" %> +<% end %> diff --git a/app/views/gerenciamentos/show.html.erb b/app/views/gerenciamentos/show.html.erb new file mode 100644 index 0000000000..ede5073aa3 --- /dev/null +++ b/app/views/gerenciamentos/show.html.erb @@ -0,0 +1,4 @@ +

Gerenciamento

+ +

Logado como <%= current_usuario.nome %>.

+<%= button_to "Sair", session_path, method: :delete %> diff --git a/config/routes.rb b/config/routes.rb index 48254e88ed..2bb7396755 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -11,4 +11,6 @@ # Defines the root path route ("/") # root "posts#index" + + resources :formularios, only: %i[new create] end diff --git a/db/migrate/20260614120000_create_templates_and_questoes.rb b/db/migrate/20260614120000_create_templates_and_questoes.rb new file mode 100644 index 0000000000..c278595b5a --- /dev/null +++ b/db/migrate/20260614120000_create_templates_and_questoes.rb @@ -0,0 +1,22 @@ +class CreateTemplatesAndQuestoes < ActiveRecord::Migration[8.1] + def change + create_table :templates do |t| + t.string :titulo, null: false + t.text :descricao + t.references :usuario, foreign_key: true + + t.timestamps + end + + create_table :questoes do |t| + t.references :template, null: false, foreign_key: true + t.text :enunciado, null: false + t.string :tipo, null: false + t.json :opcoes + t.boolean :obrigatoria, default: false, null: false + t.integer :posicao, null: false + + t.timestamps + end + end +end diff --git a/db/migrate/20260614130000_create_formularios_and_questoes_formulario.rb b/db/migrate/20260614130000_create_formularios_and_questoes_formulario.rb new file mode 100644 index 0000000000..d425feedc8 --- /dev/null +++ b/db/migrate/20260614130000_create_formularios_and_questoes_formulario.rb @@ -0,0 +1,25 @@ +class CreateFormulariosAndQuestoesFormulario < ActiveRecord::Migration[8.1] + def change + create_table :formularios do |t| + t.string :nome + t.references :template, null: false, foreign_key: true + t.references :turma, null: false, foreign_key: true + t.references :usuario, null: false, foreign_key: true + + t.timestamps + end + + add_index :formularios, %i[template_id turma_id], unique: true + + create_table :questoes_formulario do |t| + t.references :formulario, null: false, foreign_key: true + t.text :enunciado, null: false + t.string :tipo, null: false + t.json :opcoes + t.boolean :obrigatoria, default: false, null: false + t.integer :posicao, null: false + + t.timestamps + end + end +end diff --git a/db/migrate/20260614140000_create_perfis.rb b/db/migrate/20260614140000_create_perfis.rb new file mode 100644 index 0000000000..1b962dcdc3 --- /dev/null +++ b/db/migrate/20260614140000_create_perfis.rb @@ -0,0 +1,37 @@ +class CreatePerfis < ActiveRecord::Migration[8.1] + def change + create_table :perfil_adms do |t| + t.references :usuario, null: false, foreign_key: true, index: { unique: true } + t.references :departamento, foreign_key: true + + t.timestamps + end + + create_table :perfil_docentes do |t| + t.references :usuario, null: false, foreign_key: true, index: { unique: true } + t.references :departamento, foreign_key: true + + t.timestamps + end + + create_table :perfil_discentes do |t| + t.references :usuario, null: false, foreign_key: true, index: { unique: true } + t.string :matricula, null: false + + t.timestamps + end + + reversible do |dir| + dir.up do + execute <<~SQL.squish + INSERT INTO perfil_adms (usuario_id, departamento_id, created_at, updated_at) + SELECT id, departamento_id, datetime('now'), datetime('now') + FROM usuarios + WHERE admin = 1 + SQL + end + end + + remove_column :usuarios, :admin, :boolean, default: false, null: false + end +end diff --git a/db/migrate/20260614150000_align_templates_with_uml.rb b/db/migrate/20260614150000_align_templates_with_uml.rb new file mode 100644 index 0000000000..dc570db422 --- /dev/null +++ b/db/migrate/20260614150000_align_templates_with_uml.rb @@ -0,0 +1,35 @@ +class AlignTemplatesWithUml < ActiveRecord::Migration[8.1] + def up + add_reference :templates, :perfil_adm, foreign_key: true + + execute <<~SQL.squish + UPDATE templates + SET perfil_adm_id = ( + SELECT perfil_adms.id + FROM perfil_adms + WHERE perfil_adms.usuario_id = templates.usuario_id + ) + WHERE usuario_id IS NOT NULL + SQL + + remove_reference :templates, :usuario, foreign_key: true + rename_column :templates, :titulo, :nome + end + + def down + add_reference :templates, :usuario, foreign_key: true + + execute <<~SQL.squish + UPDATE templates + SET usuario_id = ( + SELECT perfil_adms.usuario_id + FROM perfil_adms + WHERE perfil_adms.id = templates.perfil_adm_id + ) + WHERE perfil_adm_id IS NOT NULL + SQL + + rename_column :templates, :nome, :titulo + remove_reference :templates, :perfil_adm, foreign_key: true + end +end diff --git a/db/migrate/20260614160000_align_formularios_and_turmas_with_uml.rb b/db/migrate/20260614160000_align_formularios_and_turmas_with_uml.rb new file mode 100644 index 0000000000..eaf6071f0d --- /dev/null +++ b/db/migrate/20260614160000_align_formularios_and_turmas_with_uml.rb @@ -0,0 +1,78 @@ +class AlignFormulariosAndTurmasWithUml < ActiveRecord::Migration[8.1] + def up + add_reference :formularios, :perfil_adm, foreign_key: true + add_reference :turmas, :formulario, foreign_key: true + add_column :turmas, :numero, :integer + add_column :turmas, :ano, :integer + add_column :turmas, :periodo, :integer + + execute <<~SQL.squish + UPDATE formularios + SET perfil_adm_id = ( + SELECT perfil_adms.id + FROM perfil_adms + WHERE perfil_adms.usuario_id = formularios.usuario_id + ) + WHERE usuario_id IS NOT NULL + SQL + + execute <<~SQL.squish + UPDATE turmas + SET formulario_id = ( + SELECT formularios.id + FROM formularios + WHERE formularios.turma_id = turmas.id + ) + WHERE EXISTS ( + SELECT 1 + FROM formularios + WHERE formularios.turma_id = turmas.id + ) + SQL + + remove_index :formularios, name: "index_formularios_on_template_id_and_turma_id" + remove_reference :formularios, :turma, foreign_key: true + remove_reference :formularios, :usuario, foreign_key: true + remove_column :formularios, :nome, :string + + change_column_null :formularios, :perfil_adm_id, false + end + + def down + add_column :formularios, :nome, :string + add_reference :formularios, :usuario, foreign_key: true + add_reference :formularios, :turma, foreign_key: true + + execute <<~SQL.squish + UPDATE formularios + SET usuario_id = ( + SELECT perfil_adms.usuario_id + FROM perfil_adms + WHERE perfil_adms.id = formularios.perfil_adm_id + ) + WHERE perfil_adm_id IS NOT NULL + SQL + + execute <<~SQL.squish + UPDATE formularios + SET turma_id = ( + SELECT turmas.id + FROM turmas + WHERE turmas.formulario_id = formularios.id + ) + WHERE EXISTS ( + SELECT 1 + FROM turmas + WHERE turmas.formulario_id = formularios.id + ) + SQL + + add_index :formularios, %i[template_id turma_id], unique: true + + remove_reference :turmas, :formulario, foreign_key: true + remove_column :turmas, :periodo, :integer + remove_column :turmas, :ano, :integer + remove_column :turmas, :numero, :integer + remove_reference :formularios, :perfil_adm, foreign_key: true + end +end diff --git a/db/migrate/20260614170000_align_questoes_with_uml.rb b/db/migrate/20260614170000_align_questoes_with_uml.rb new file mode 100644 index 0000000000..ffc831b649 --- /dev/null +++ b/db/migrate/20260614170000_align_questoes_with_uml.rb @@ -0,0 +1,122 @@ +class AlignQuestoesWithUml < ActiveRecord::Migration[8.1] + def up + add_reference :questoes, :formulario, foreign_key: true + add_column :questoes, :tipo_novo, :integer + + execute <<~SQL.squish + UPDATE questoes + SET tipo_novo = CASE + WHEN tipo = 'multipla_escolha' THEN 0 + ELSE 1 + END + SQL + + create_table :opcoes do |t| + t.references :questao, null: false, foreign_key: true + t.string :texto, null: false + + t.timestamps + end + + questoes_formulario_rows.each do |row| + questao_id = insert_questao_formulario(row) + insert_opcoes(questao_id, row["opcoes"]) + end + + change_column_null :questoes, :tipo_novo, false + remove_column :questoes, :tipo, :string + rename_column :questoes, :tipo_novo, :tipo + change_column_null :questoes, :template_id, true + drop_table :questoes_formulario + end + + def down + create_table :questoes_formulario do |t| + t.references :formulario, null: false, foreign_key: true + t.text :enunciado, null: false + t.string :tipo, null: false + t.json :opcoes + t.boolean :obrigatoria, default: false, null: false + t.integer :posicao, null: false + + t.timestamps + end + + questoes_formulario_backfill + + drop_table :opcoes + remove_reference :questoes, :formulario, foreign_key: true + rename_column :questoes, :tipo, :tipo_novo + add_column :questoes, :tipo, :string + + execute <<~SQL.squish + UPDATE questoes + SET tipo = CASE + WHEN tipo_novo = 0 THEN 'multipla_escolha' + ELSE 'texto' + END + SQL + + change_column_null :questoes, :tipo, false + remove_column :questoes, :tipo_novo, :integer + change_column_null :questoes, :template_id, false + end + + private + + def questoes_formulario_rows + connection.select_all("SELECT * FROM questoes_formulario ORDER BY id") + end + + def insert_questao_formulario(row) + tipo = row["tipo"] == "multipla_escolha" ? 0 : 1 + now = connection.quote(Time.current) + + connection.insert <<~SQL.squish + INSERT INTO questoes + (formulario_id, template_id, enunciado, tipo_novo, obrigatoria, posicao, created_at, updated_at) + VALUES + (#{row["formulario_id"]}, NULL, #{connection.quote(row["enunciado"])}, #{tipo}, + #{row["obrigatoria"] ? 1 : 0}, #{row["posicao"]}, #{now}, #{now}) + SQL + end + + def insert_opcoes(questao_id, opcoes_json) + return if opcoes_json.blank? + + opcoes = JSON.parse(opcoes_json) + now = connection.quote(Time.current) + + opcoes.each do |texto| + connection.execute <<~SQL.squish + INSERT INTO opcoes (questao_id, texto, created_at, updated_at) + VALUES (#{questao_id}, #{connection.quote(texto)}, #{now}, #{now}) + SQL + end + end + + def questoes_formulario_backfill + questoes = connection.select_all(<<~SQL.squish) + SELECT questoes.*, GROUP_CONCAT(opcoes.texto) AS opcoes_textos + FROM questoes + LEFT JOIN opcoes ON opcoes.questao_id = questoes.id + WHERE questoes.formulario_id IS NOT NULL + GROUP BY questoes.id + SQL + + questoes.each do |questao| + tipo = questao["tipo"].to_i == 0 ? "multipla_escolha" : "texto" + opcoes = questao["opcoes_textos"]&.split(",") || [] + now = connection.quote(Time.current) + + connection.execute <<~SQL.squish + INSERT INTO questoes_formulario + (formulario_id, enunciado, tipo, opcoes, obrigatoria, posicao, created_at, updated_at) + VALUES + (#{questao["formulario_id"]}, #{connection.quote(questao["enunciado"])}, #{connection.quote(tipo)}, + #{connection.quote(opcoes.to_json)}, #{questao["obrigatoria"] ? 1 : 0}, #{questao["posicao"]}, + #{now}, #{now}) + SQL + end + end +end diff --git a/db/migrate/20260614180000_create_avaliacao_chain.rb b/db/migrate/20260614180000_create_avaliacao_chain.rb new file mode 100644 index 0000000000..b193b90f46 --- /dev/null +++ b/db/migrate/20260614180000_create_avaliacao_chain.rb @@ -0,0 +1,32 @@ +class CreateAvaliacaoChain < ActiveRecord::Migration[8.1] + def change + create_table :avaliacoes do |t| + t.references :participacao_turma, null: false, foreign_key: true + t.references :formulario, null: false, foreign_key: true + t.datetime :respondido_em + + t.timestamps + end + + create_table :respostas do |t| + t.references :avaliacao, null: false, foreign_key: true + t.references :questao, null: false, foreign_key: true + + t.timestamps + end + + create_table :opcoes_escolhidas do |t| + t.references :resposta, null: false, foreign_key: true, index: { unique: true } + t.references :opcao, null: false, foreign_key: true + + t.timestamps + end + + create_table :textos do |t| + t.references :resposta, null: false, foreign_key: true, index: { unique: true } + t.text :texto, null: false + + t.timestamps + end + end +end diff --git a/features/step_definitions/autenticacao_steps.rb b/features/step_definitions/autenticacao_steps.rb new file mode 100644 index 0000000000..f2a9fa3a58 --- /dev/null +++ b/features/step_definitions/autenticacao_steps.rb @@ -0,0 +1,4 @@ +Dado("que estou autenticado como administrador") do + @admin ||= criar_administrador + login_como(@admin) +end diff --git a/features/step_definitions/formularios_steps.rb b/features/step_definitions/formularios_steps.rb new file mode 100644 index 0000000000..6105e3d9bd --- /dev/null +++ b/features/step_definitions/formularios_steps.rb @@ -0,0 +1,64 @@ +Dado("que existe um template cadastrado chamado {string}") do |titulo| + @admin ||= criar_administrador + @template = criar_template_com_questoes(titulo: titulo) +end + +Dado("que existem as turmas {string} e {string} cadastradas no semestre atual") do |nome_turma_a, nome_turma_b| + @turma_a = criar_turma_do_nome_exibicao(nome_turma_a) + @turma_b = criar_turma_do_nome_exibicao(nome_turma_b) +end + +Dado("que estou na página de criação de formulários") do + visit new_formulario_path +end + +Quando("eu seleciono o template {string}") do |titulo| + select titulo, from: "template_id" +end + +Quando("seleciono as turmas {string} e {string}") do |nome_turma_a, nome_turma_b| + check nome_turma_a + check nome_turma_b +end + +Quando("não seleciono nenhuma turma") do + page.all('input[name="turma_ids[]"]').each do |checkbox| + checkbox.set(false) if checkbox.checked? + end +end + +Quando("clico em {string}") do |botao| + click_button botao +end + +Então("o formulário deve ser gerado com sucesso para ambas as turmas") do + expect(Formulario.count).to eq(2) + + expect(@turma_a.reload.formulario).to be_present + expect(@turma_b.reload.formulario).to be_present + expect(@turma_a.formulario.template).to eq(@template) + expect(@turma_b.formulario.template).to eq(@template) + + [@turma_a, @turma_b].each do |turma| + questoes_formulario = turma.formulario.questoes.order(:posicao) + expect(questoes_formulario.count).to eq(@template.questoes.count) + expect(questoes_formulario.pluck(:enunciado)).to eq(@template.questoes.order(:posicao).pluck(:enunciado)) + end +end + +Então("devo ver a mensagem {string}") do |mensagem| + expect(page).to have_content(mensagem) +end + +Então("eu devo ver uma mensagem de erro dizendo {string}") do |mensagem| + expect(page).to have_content(mensagem) +end + +Então("nenhum formulário deve ser gerado") do + expect(Formulario.count).to eq(0) +end + +def criar_turma_do_nome_exibicao(nome_exibicao) + materia_nome, codigo_turma = nome_exibicao.split(" - Turma ", 2) + criar_turma(nome_materia: materia_nome, codigo_turma: codigo_turma) +end diff --git a/spec/fixtures/sigaa/class_members_missing_email.json b/spec/fixtures/sigaa/class_members_missing_email.json new file mode 100644 index 0000000000..0f830adfbe --- /dev/null +++ b/spec/fixtures/sigaa/class_members_missing_email.json @@ -0,0 +1,25 @@ +[ + { + "code": "CIC0097", + "classCode": "TA", + "semester": "2021.2", + "dicente": [ + { + "nome": "Participante Sem E-mail", + "curso": "CIÊNCIA DA COMPUTAÇÃO/CIC", + "matricula": "199999999", + "usuario": "199999999", + "formacao": "graduando", + "ocupacao": "dicente" + } + ], + "docente": { + "nome": "MARISTELA TERTO DE HOLANDA", + "departamento": "DEPTO CIÊNCIAS DA COMPUTAÇÃO", + "formacao": "DOUTORADO", + "usuario": "83807519491", + "email": "mholanda@unb.br", + "ocupacao": "docente" + } + } +] diff --git a/spec/models/formulario_spec.rb b/spec/models/formulario_spec.rb new file mode 100644 index 0000000000..8c8b9a94d8 --- /dev/null +++ b/spec/models/formulario_spec.rb @@ -0,0 +1,12 @@ +require "rails_helper" + +RSpec.describe Formulario, type: :model do + describe "validações" do + it "exige perfil administrativo" do + formulario = described_class.new + + expect(formulario).not_to be_valid + expect(formulario.errors[:perfil_adm]).to include("must exist") + end + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index ef75d46770..b991d35ea4 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -23,7 +23,7 @@ # directory. Alternatively, in the individual `*_spec.rb` files, manually # require only the support files necessary. # -# Rails.root.glob('spec/support/**/*.rb').sort_by(&:to_s).each { |f| require f } +Rails.root.glob('spec/support/**/*.rb').sort_by(&:to_s).each { |f| require f } # Ensures that the test database schema matches the current schema file. # If there are pending migrations it will invoke `db:test:prepare` to diff --git a/spec/requests/formularios_spec.rb b/spec/requests/formularios_spec.rb new file mode 100644 index 0000000000..d45b546838 --- /dev/null +++ b/spec/requests/formularios_spec.rb @@ -0,0 +1,55 @@ +require "rails_helper" + +RSpec.describe "Formularios", type: :request do + let(:admin) { create_admin_usuario } + let(:usuario) { create_usuario } + let(:template) { create_template_with_questoes(titulo: "Avaliação Docente") } + let(:turma_a) { create_turma(nome_materia: "MDS", codigo_turma: "A") } + let(:turma_b) { create_turma(nome_materia: "IHC", codigo_turma: "B") } + + describe "POST /formularios" do + it "cria formulários para múltiplas turmas quando admin autenticado" do + sign_in_as(admin) + + expect do + post formularios_path, + params: { + template_id: template.id, + turma_ids: [turma_a.id, turma_b.id] + } + end.to change(Formulario, :count).by(2) + + expect(response).to redirect_to(new_formulario_path) + follow_redirect! + expect(response.body).to include("Formulário criado com sucesso para as turmas selecionadas") + end + + it "retorna erro quando nenhuma turma é selecionada" do + sign_in_as(admin) + + expect do + post formularios_path, + params: { template_id: template.id, turma_ids: [] } + end.not_to change(Formulario, :count) + + expect(response).to redirect_to(new_formulario_path) + follow_redirect! + expect(response.body).to include("É necessário selecionar pelo menos uma turma") + end + + it "bloqueia usuário não administrador" do + sign_in_as(usuario) + + post formularios_path, + params: { + template_id: template.id, + turma_ids: [turma_a.id] + } + + expect(response).to redirect_to("/") + follow_redirect! + expect(response.body).to include("Acesso não autorizado") + expect(Formulario.count).to eq(0) + end + end +end diff --git a/spec/services/formularios/create_from_template_spec.rb b/spec/services/formularios/create_from_template_spec.rb new file mode 100644 index 0000000000..330e6ef768 --- /dev/null +++ b/spec/services/formularios/create_from_template_spec.rb @@ -0,0 +1,110 @@ +require "rails_helper" + +RSpec.describe Formularios::CreateFromTemplate do + let(:admin) { create_admin_usuario } + let(:perfil_adm) { admin.perfil_adm } + let(:template) { create_template_with_questoes(titulo: "Avaliação Docente") } + let(:turma_a) { create_turma(nome_materia: "MDS", codigo_turma: "A") } + let(:turma_b) { create_turma(nome_materia: "IHC", codigo_turma: "B") } + + describe ".call" do + it "cria um formulário por turma selecionada" do + formularios = described_class.call( + template_id: template.id, + turma_ids: [turma_a.id, turma_b.id], + perfil_adm: perfil_adm + ) + + expect(formularios.size).to eq(2) + expect(Formulario.count).to eq(2) + expect(turma_a.reload.formulario).to eq(formularios.first) + expect(turma_b.reload.formulario).to eq(formularios.second) + end + + it "copia as questões do template como snapshot independente" do + formularios = described_class.call( + template_id: template.id, + turma_ids: [turma_a.id], + perfil_adm: perfil_adm + ) + + formulario = formularios.first + questoes_template = template.questoes.order(:posicao) + questoes_formulario = formulario.questoes.order(:posicao) + + expect(questoes_formulario.count).to eq(questoes_template.count) + expect(questoes_formulario.pluck(:enunciado)).to eq(questoes_template.pluck(:enunciado)) + expect(questoes_formulario.pluck(:tipo)).to eq(questoes_template.pluck(:tipo)) + + questao_template = questoes_template.first + questao_formulario = questoes_formulario.first + enunciado_original = questao_formulario.enunciado + + questao_template.update!(enunciado: "Enunciado alterado no template") + + expect(questao_formulario.reload.enunciado).to eq(enunciado_original) + end + + it "copia opções para questões objetivas" do + formularios = described_class.call( + template_id: template.id, + turma_ids: [turma_a.id], + perfil_adm: perfil_adm + ) + + questao_objetiva = formularios.first.questoes.find_by(tipo: :objetiva) + expect(questao_objetiva.opcoes.pluck(:texto)).to eq(%w[Ruim Regular Boa Excelente]) + end + + it "faz rollback se a criação de um formulário falhar" do + call_count = 0 + + allow(Formulario).to receive(:create!).and_wrap_original do |method, **args| + call_count += 1 + raise ActiveRecord::RecordInvalid.new(Formulario.new) if call_count == 2 + + method.call(**args) + end + + expect do + described_class.call( + template_id: template.id, + turma_ids: [turma_a.id, turma_b.id], + perfil_adm: perfil_adm + ) + end.to raise_error(ActiveRecord::RecordInvalid) + + expect(Formulario.count).to eq(0) + expect(turma_a.reload.formulario_id).to be_nil + expect(turma_b.reload.formulario_id).to be_nil + end + + it "levanta erro quando nenhuma turma é selecionada" do + expect do + described_class.call( + template_id: template.id, + turma_ids: [], + perfil_adm: perfil_adm + ) + end.to raise_error(Formularios::Error, "É necessário selecionar pelo menos uma turma") + + expect(Formulario.count).to eq(0) + end + + it "levanta erro quando turma já possui formulário" do + described_class.call( + template_id: template.id, + turma_ids: [turma_a.id], + perfil_adm: perfil_adm + ) + + expect do + described_class.call( + template_id: template.id, + turma_ids: [turma_a.id], + perfil_adm: perfil_adm + ) + end.to raise_error(Formularios::Error, "Uma ou mais turmas selecionadas já possuem formulário") + end + end +end diff --git a/spec/support/authentication_helpers.rb b/spec/support/authentication_helpers.rb new file mode 100644 index 0000000000..10eda069dd --- /dev/null +++ b/spec/support/authentication_helpers.rb @@ -0,0 +1,9 @@ +module AuthenticationHelpers + def sign_in_as(usuario) + allow_any_instance_of(ApplicationController).to receive(:current_usuario).and_return(usuario) + end +end + +RSpec.configure do |config| + config.include AuthenticationHelpers, type: :request +end diff --git a/spec/support/test_builders.rb b/spec/support/test_builders.rb new file mode 100644 index 0000000000..abb8798d51 --- /dev/null +++ b/spec/support/test_builders.rb @@ -0,0 +1,72 @@ +module TestBuilders + def create_admin_usuario(**attrs) + defaults = { + nome: "Administrador", + matricula: SecureRandom.hex(4), + status: "ativo" + } + usuario = Usuario.create!(defaults.merge(attrs.except(:departamento))) + PerfilAdm.create!(usuario: usuario, departamento: attrs[:departamento]) + usuario + end + + def create_usuario(**attrs) + defaults = { + nome: "Usuário", + matricula: SecureRandom.hex(4), + status: "ativo" + } + Usuario.create!(defaults.merge(attrs)) + end + + def create_template_with_questoes(titulo:, perfil_adm: nil, questoes: nil) + perfil_adm ||= create_admin_usuario.perfil_adm + template = Template.create!(nome: titulo, descricao: "Descrição de teste", perfil_adm: perfil_adm) + + (questoes || default_questoes).each do |questao_attrs| + attrs = questao_attrs.dup + opcoes = attrs.delete(:opcoes) + questao = template.questoes.create!(attrs) + Array(opcoes).each { |texto| questao.opcoes.create!(texto: texto) } if opcoes.present? + end + + template + end + + def create_turma(nome_materia:, codigo_turma:, semestre: Turma.semestre_atual) + materia = Materia.create!( + nome: nome_materia, + codigo: "#{nome_materia.parameterize}-#{SecureRandom.hex(2)}" + ) + + Turma.create!( + materia: materia, + codigo_turma: codigo_turma, + semestre: semestre + ) + end + + private + + def default_questoes + [ + { + enunciado: "Como você avalia a disciplina?", + tipo: :discursiva, + posicao: 1, + obrigatoria: true + }, + { + enunciado: "Nota geral", + tipo: :objetiva, + posicao: 2, + obrigatoria: true, + opcoes: %w[Ruim Regular Boa Excelente] + } + ] + end +end + +RSpec.configure do |config| + config.include TestBuilders +end From a69b95882c7d2ddb1df0e9deb8235370641bf9e5 Mon Sep 17 00:00:00 2001 From: Marcio Vieira Date: Mon, 15 Jun 2026 21:02:22 -0300 Subject: [PATCH 042/125] =?UTF-8?q?feat(HU13):=20implementa=20visualiza?= =?UTF-8?q?=C3=A7=C3=A3o=20de=20formul=C3=A1rios=20pendentes=20e=20corre?= =?UTF-8?q?=C3=A7=C3=A3o=20de=20testes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Gemfile.lock | 18 +++ app/controllers/application_controller.rb | 7 +- app/controllers/avaliacoes_controller.rb | 22 +++- app/views/avaliacoes/pendentes.html.erb | 24 ++++ app/views/avaliacoes/pendentes.html.haml | 18 --- config/routes.rb | 2 +- features/step_definitions/avaliacoes_steps.rb | 105 ++++++++++++++++++ features/support/rspec_mocks.rb | 15 +++ 8 files changed, 185 insertions(+), 26 deletions(-) create mode 100644 app/views/avaliacoes/pendentes.html.erb delete mode 100644 app/views/avaliacoes/pendentes.html.haml create mode 100644 features/step_definitions/avaliacoes_steps.rb create mode 100644 features/support/rspec_mocks.rb diff --git a/Gemfile.lock b/Gemfile.lock index ef597332b8..495b24a63d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -206,6 +206,7 @@ GEM ffi (1.17.4-aarch64-linux-musl) ffi (1.17.4-arm-linux-gnu) ffi (1.17.4-arm-linux-musl) + ffi (1.17.4-x64-mingw-ucrt) ffi (1.17.4-x86_64-linux-gnu) ffi (1.17.4-x86_64-linux-musl) flay (2.14.4) @@ -303,6 +304,8 @@ GEM racc (~> 1.4) nokogiri (1.19.3-arm-linux-musl) racc (~> 1.4) + nokogiri (1.19.3-x64-mingw-ucrt) + racc (~> 1.4) nokogiri (1.19.3-x86_64-linux-gnu) racc (~> 1.4) nokogiri (1.19.3-x86_64-linux-musl) @@ -485,6 +488,7 @@ GEM sqlite3 (2.9.4-aarch64-linux-musl) sqlite3 (2.9.4-arm-linux-gnu) sqlite3 (2.9.4-arm-linux-musl) + sqlite3 (2.9.4-x64-mingw-ucrt) sqlite3 (2.9.4-x86_64-linux-gnu) sqlite3 (2.9.4-x86_64-linux-musl) sshkit (1.25.0) @@ -500,6 +504,10 @@ GEM 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) thruster (0.1.20) @@ -513,6 +521,8 @@ GEM railties (>= 7.1.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) + tzinfo-data (1.2026.2) + tzinfo (>= 1.0.0) unicode-display_width (3.2.0) unicode-emoji (~> 4.1) unicode-emoji (4.2.0) @@ -531,6 +541,7 @@ 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.5) @@ -541,6 +552,7 @@ PLATFORMS aarch64-linux-musl arm-linux-gnu arm-linux-musl + x64-mingw-ucrt x86_64-linux x86_64-linux-gnu x86_64-linux-musl @@ -644,6 +656,7 @@ CHECKSUMS ffi (1.17.4-aarch64-linux-musl) sha256=9286b7a615f2676245283aef0a0a3b475ae3aae2bb5448baace630bb77b91f39 ffi (1.17.4-arm-linux-gnu) sha256=d6dbddf7cb77bf955411af5f187a65b8cd378cb003c15c05697f5feee1cb1564 ffi (1.17.4-arm-linux-musl) sha256=9d4838ded0465bef6e2426935f6bcc93134b6616785a84ffd2a3d82bc3cf6f95 + ffi (1.17.4-x64-mingw-ucrt) sha256=f6ff9618cfccc494138bddade27aa06c74c6c7bc367a1ea1103d80c2fcb9ed35 ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d ffi (1.17.4-x86_64-linux-musl) sha256=3fdf9888483de005f8ef8d1cf2d3b20d86626af206cbf780f6a6a12439a9c49e flay (2.14.4) sha256=a62d96a51d1da185aa41ba95b696966df9f7d1d91a457709277f24515895de77 @@ -685,6 +698,7 @@ CHECKSUMS nokogiri (1.19.3-aarch64-linux-musl) sha256=8392dfdcd21be7a94dbbe9ccc138dea01b97b24cb2dc02a114ca98bfb1d9a0b7 nokogiri (1.19.3-arm-linux-gnu) sha256=3919d5ffc334ad778a4a9eb88fda7dcb8b1fb58c8a52ac640c6dcd2f038e774f nokogiri (1.19.3-arm-linux-musl) sha256=9ce1cb6346bb9c67b1550eb537aa183ead91e4b6eadb2f36ade02d8dd2a79fb6 + nokogiri (1.19.3-x64-mingw-ucrt) sha256=8bb7132cad356c879a1286eaabcb5e68326cb2490317984280fbc62f456d506a nokogiri (1.19.3-x86_64-linux-gnu) sha256=2f5078620fe12e83669b5b17311b32532a8153d02eee7ad06948b926d6080976 nokogiri (1.19.3-x86_64-linux-musl) sha256=248c906d2166eca5efb56d52fdee5f9a1f51d69a72e2b64fdac647b4ce39ea3f ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 @@ -744,12 +758,14 @@ CHECKSUMS sqlite3 (2.9.4-aarch64-linux-musl) sha256=ffb4255947fb54c8c3eeca97460c9702b40de91ce390455ef7367ca6a3929a31 sqlite3 (2.9.4-arm-linux-gnu) sha256=9ee2008b9fbec984c3c165b0d7eedd2bd2a415100b761bfa3a4c6fbec9208bf6 sqlite3 (2.9.4-arm-linux-musl) sha256=8dc1fe4da6977992cd62decf4a93ccf6cc2e124a5e6a340160d52092f70e837a + sqlite3 (2.9.4-x64-mingw-ucrt) sha256=40997c549b19e2fdfcc5e271f6bdd4d502179742c0bfd678da23d0d09b929848 sqlite3 (2.9.4-x86_64-linux-gnu) sha256=537a3eda71b1df1336d0055cbebe55a7317c34870c192c7b6b9d8d0be6871847 sqlite3 (2.9.4-x86_64-linux-musl) sha256=3fc5e865b4be9a85d998203ef8d0c0fdcb92f20acf34a254346ff8a19088efec sshkit (1.25.0) sha256=c8c6543cdb60f91f1d277306d585dd11b6a064cb44eab0972827e4311ff96744 stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06 stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1 sys-uname (1.5.1) sha256=784d7e6491b0393c25cbbe5ac38324ac7be9fda083a6094832648af669386d7b + sys-uname (1.5.1-universal-mingw32) sha256=aceb618e3276da5eae0ce368e9f6fae8c1f3e9ef23a0595cb88db7b6ecd45f62 thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 thread_safe (0.3.6) sha256=9ed7072821b51c57e8d6b7011a8e282e25aeea3a4065eab326e43f66f063b05a thruster (0.1.20) sha256=c05f2fbcae527bbe093a6e6d84fb12d9d680617e7c162325d9b97e8e9d4b5201 @@ -760,6 +776,7 @@ CHECKSUMS tty-which (0.5.0) sha256=5824055f0d6744c97e7c4426544f01d519c40d1806ef2ef47d9854477993f466 turbo-rails (2.0.23) sha256=ee0d90733aafff056cf51ff11e803d65e43cae258cc55f6492020ec1f9f9315f tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b + tzinfo-data (1.2026.2) sha256=7db0d3d3d53b8d7601fc183fccc8c6d056a3004e14eb59ea995bf6aec4ae10bc unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 @@ -769,6 +786,7 @@ CHECKSUMS websocket (1.2.11) sha256=b7e7a74e2410b5e85c25858b26b3322f29161e300935f70a0e0d3c35e0462737 websocket-driver (0.8.0) sha256=ed0dba4b943c22f17f9a734817e808bc84cdce6a7e22045f5315aa57676d4962 websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241 + win32ole (1.9.3) sha256=01f43dc5dc13806e6e58204f538b4a28f3d85968ea89074abc9a3cd118e94d96 xpath (3.2.0) sha256=6dfda79d91bb3b949b947ecc5919f042ef2f399b904013eb3ef6d20dd3a4082e zeitwerk (2.7.5) sha256=d8da92128c09ea6ec62c949011b00ed4a20242b255293dd66bf41545398f73dd diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index c3537563da..18ffcb478a 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,7 +1,12 @@ class ApplicationController < ActionController::Base + def current_usuario + @current_usuario ||= Usuario.find_by(id: session[:usuario_id]) + end + helper_method :current_usuario # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. allow_browser versions: :modern # Changes to the importmap will invalidate the etag for HTML responses - stale_when_importmap_changes + stale_when_importmap_changes + end diff --git a/app/controllers/avaliacoes_controller.rb b/app/controllers/avaliacoes_controller.rb index 1e73b586ba..415f3b0787 100644 --- a/app/controllers/avaliacoes_controller.rb +++ b/app/controllers/avaliacoes_controller.rb @@ -1,11 +1,21 @@ class AvaliacoesController < ApplicationController + before_action :require_login + def pendentes - # Simulando o usuário logado provisoriamente - usuario_atual = defined?(current_usuario) ? current_usuario : Usuario.first + turmas_ids = ParticipacaoTurma + .where(usuario: current_usuario) + .pluck(:turma_id) + + @avaliacoes_pendentes = Avaliacao + .pendentes + .joins(:participacao_turma) + .where(participacoes_turmas: { turma_id: turmas_ids }) + .includes(formulario: { turma: :materia }) + end + + private - @avaliacoes_pendentes = Avaliacao.pendentes - .joins(:participacao_turma) - .where(participacoes_turmas: { usuario_id: usuario_atual.id }) - .includes(formulario: { turma: :materia }) + def require_login + redirect_to root_path, alert: 'Usuário não autenticado' unless current_usuario end end \ No newline at end of file diff --git a/app/views/avaliacoes/pendentes.html.erb b/app/views/avaliacoes/pendentes.html.erb new file mode 100644 index 0000000000..b5ddfa8cb6 --- /dev/null +++ b/app/views/avaliacoes/pendentes.html.erb @@ -0,0 +1,24 @@ +

Avaliações Pendentes

+ +<% if @avaliacoes_pendentes.any? %> + + + + + + + + + + <% @avaliacoes_pendentes.each do |avaliacao| %> + + + + + + <% end %> + +
TurmaStatusAção
<%= avaliacao.formulario.turma.materia.nome %>Não respondido<%= link_to 'Responder', '#' %>
+<% else %> +

Nenhum formulário pendente foi encontrado.

+<% end %> \ No newline at end of file diff --git a/app/views/avaliacoes/pendentes.html.haml b/app/views/avaliacoes/pendentes.html.haml deleted file mode 100644 index a06a5d2124..0000000000 --- a/app/views/avaliacoes/pendentes.html.haml +++ /dev/null @@ -1,18 +0,0 @@ -%h1 Avaliações Pendentes - -- if @avaliacoes_pendentes.any? - %table - %thead - %tr - %th Turma - %th Status - %th Ação - %tbody - - @avaliacoes_pendentes.each do |avaliacao| - %tr - %td= avaliacao.formulario.turma.materia.nome - %td Não respondido - %td - = link_to 'Responder', '#' -- else - %p Nenhum formulário pendente foi encontrado. \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index a6a1e4fdd6..c8425aaff7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -16,5 +16,5 @@ collection do get 'pendentes' end - end + end end \ No newline at end of file diff --git a/features/step_definitions/avaliacoes_steps.rb b/features/step_definitions/avaliacoes_steps.rb new file mode 100644 index 0000000000..40da5568cf --- /dev/null +++ b/features/step_definitions/avaliacoes_steps.rb @@ -0,0 +1,105 @@ +# features/step_definitions/avaliacoes_steps.rb + +Before do + Avaliacao.destroy_all + ParticipacaoTurma.destroy_all + Formulario.destroy_all + Turma.destroy_all + Materia.destroy_all + Departamento.destroy_all + PerfilDiscente.destroy_all + PerfilAdm.destroy_all + Usuario.destroy_all +end + +def criar_participacao_com_perfil(usuario, turma, tipo) + if tipo == :discente + PerfilDiscente.find_or_create_by!(usuario: usuario) do |p| + p.matricula = "MAT#{rand(10000..99999)}" + end + end + + ParticipacaoTurma.find_or_create_by!(usuario: usuario, turma: turma) do |p| + p.tipo_participacao = tipo + end +end + +Dado('que existe um usuário participante cadastrado no sistema') do + @email_teste = "joao#{rand(10000)}@teste.com" + @usuario = Usuario.create!( + nome: 'João Participante', + email: @email_teste, + senha: 'password123', + status: :ativo + ) + PerfilDiscente.create!(usuario: @usuario, matricula: "MAT#{rand(10000..99999)}") +end + +Dado('que estou autenticado como participante') do + # allow_any_instance_of agora funciona via features/support/rspec_mocks.rb + allow_any_instance_of(ApplicationController) + .to receive(:current_usuario) + .and_return(@usuario) +end + +Dado('que estou matriculado na turma {string}') do |nome_turma| + depto = Departamento.find_or_create_by!(nome: 'Departamento Geral') + materia = Materia.find_or_create_by!(nome: nome_turma, departamento: depto) do |m| + m.codigo = "COD#{rand(1000..9999)}" + end + @turma = Turma.find_or_create_by!(materia: materia, ano: 2026, semestre: :primeiro) do |t| + t.numero = rand(1..100) + end + + criar_participacao_com_perfil(@usuario, @turma, :discente) +end + +Dado('que existe um formulário pendente para a turma {string}') do |nome_turma| + depto = Departamento.find_or_create_by!(nome: 'Departamento Geral') + materia = Materia.find_or_create_by!(nome: nome_turma, departamento: depto) do |m| + m.codigo = "COD#{rand(1000..9999)}" + end + + # Reutiliza @turma se já foi criada para esse nome (cenário matriculado) + # ou cria uma nova turma isolada (cenário sem matrícula) + turma = @turma || Turma.find_or_create_by!(materia: materia, ano: 2026, semestre: :primeiro) do |t| + t.numero = rand(1..100) + end + + adm = Usuario.create!(nome: 'Adm', email: "adm#{rand(10000)}@t.com", senha: 'password123', status: :ativo) + perf_adm = PerfilAdm.create!(usuario: adm, departamento: depto) + + @formulario = Formulario.create!(adm: perf_adm, turma: turma, publico_alvo: :discentes) + + # Cria a avaliação para o @usuario (participante do teste), não para um fantasma + participacao = ParticipacaoTurma.find_by(usuario: @usuario, turma: turma) + Avaliacao.create!(formulario: @formulario, participacao_turma: participacao) if participacao +end + +Quando('eu acesso a página de avaliações pendentes') do + visit pendentes_avaliacoes_path +end + +Então('devo ver o formulário da turma {string} na lista') do |nome_turma| + expect(page).to have_content(nome_turma) +end + +Então('o status do formulário deve ser {string}') do |status| + expect(page).to have_content(status) +end + +Dado('que não possuo formulários pendentes nas minhas turmas') do + # O Before já limpou o banco; nenhuma avaliação existe. Nada a fazer. +end + +Então('devo ver uma mensagem informando que nenhum formulário pendente foi encontrado') do + expect(page).to have_content('Nenhum formulário pendente foi encontrado.') +end + +Dado('que não estou matriculado na turma {string}') do |_nome_turma| + # Intencionalmente vazio: não criamos participação para @usuario nessa turma. +end + +Então('não devo ver o formulário da turma {string} na lista') do |nome_turma| + expect(page).not_to have_content(nome_turma) +end \ No newline at end of file diff --git a/features/support/rspec_mocks.rb b/features/support/rspec_mocks.rb new file mode 100644 index 0000000000..850a572375 --- /dev/null +++ b/features/support/rspec_mocks.rb @@ -0,0 +1,15 @@ +require 'rspec/mocks' + +World(RSpec::Mocks::ExampleMethods) + +Before do + RSpec::Mocks.setup +end + +After do + begin + RSpec::Mocks.verify + ensure + RSpec::Mocks.teardown + end +end \ No newline at end of file From aff4e4577096bb38bfc227ebba5b4710187aae08 Mon Sep 17 00:00:00 2001 From: Marcio Vieira Date: Mon, 15 Jun 2026 21:42:17 -0300 Subject: [PATCH 043/125] =?UTF-8?q?feat(hu14):=20implementa=20resposta=20d?= =?UTF-8?q?e=20question=C3=A1rio=20da=20turma?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/controllers/avaliacoes_controller.rb | 95 ++++++++++++++++++ app/views/avaliacoes/pendentes.html.erb | 10 +- app/views/avaliacoes/responder.html.erb | 33 +++++++ config/routes.rb | 17 +--- .../responder_avaliacao_steps.rb | 96 +++++++++++++++++++ 5 files changed, 238 insertions(+), 13 deletions(-) create mode 100644 app/views/avaliacoes/responder.html.erb create mode 100644 features/step_definitions/responder_avaliacao_steps.rb diff --git a/app/controllers/avaliacoes_controller.rb b/app/controllers/avaliacoes_controller.rb index 415f3b0787..e85d3633ca 100644 --- a/app/controllers/avaliacoes_controller.rb +++ b/app/controllers/avaliacoes_controller.rb @@ -1,7 +1,9 @@ class AvaliacoesController < ApplicationController before_action :require_login + before_action :set_avaliacao, only: %i[responder submeter] # ← linha nova def pendentes + # ← sem alteração, exatamente como está na HU13 turmas_ids = ParticipacaoTurma .where(usuario: current_usuario) .pluck(:turma_id) @@ -13,9 +15,102 @@ def pendentes .includes(formulario: { turma: :materia }) end + # ← tudo abaixo é novo da HU14 + def responder + if @avaliacao.respondida? + redirect_to pendentes_avaliacoes_path, + alert: 'Esta avaliação já foi respondida.' + return + end + + @formulario = @avaliacao.formulario + @questoes = questoes_do_formulario + end + + def submeter + if @avaliacao.respondida? + redirect_to pendentes_avaliacoes_path, + alert: 'Esta avaliação já foi respondida.' + return + end + + @formulario = @avaliacao.formulario + @questoes = questoes_do_formulario + + if todas_obrigatorias_preenchidas? + salvar_respostas_e_finalizar + else + flash.now[:alert] = 'Todas as questões obrigatórias devem ser preenchidas.' + render :responder, status: :unprocessable_entity + end + end + private def require_login redirect_to root_path, alert: 'Usuário não autenticado' unless current_usuario end + + def set_avaliacao + participacao_ids = ParticipacaoTurma + .where(usuario: current_usuario) + .pluck(:id) + + @avaliacao = Avaliacao.find_by!(id: params[:id], + participacao_turma_id: participacao_ids) + rescue ActiveRecord::RecordNotFound + redirect_to pendentes_avaliacoes_path, alert: 'Avaliação não encontrada.' + end + + def questoes_do_formulario + template = @formulario.template + return Questao.none unless template + + template.questoes + .includes(:opcoes) + .order('utilizacoes_questoes.numero') + end + + def todas_obrigatorias_preenchidas? + respostas_params = params[:respostas] || {} + + @questoes.all? do |questao| + resposta = respostas_params[questao.id.to_s] || {} + + if questao.discursiva? + resposta[:texto].to_s.strip.present? + else + Array(resposta[:opcao_id]).any?(&:present?) + end + end + end + + def salvar_respostas_e_finalizar + ActiveRecord::Base.transaction do + respostas_params = params[:respostas] || {} + + @questoes.each do |questao| + resposta_data = respostas_params[questao.id.to_s] || {} + resposta = Resposta.find_or_initialize_by(avaliacao: @avaliacao, questao: questao) + resposta.save! + + if questao.discursiva? + Texto.find_or_initialize_by(resposta: resposta) + .update!(texto: resposta_data[:texto].to_s.strip) + else + opcao_id = resposta_data[:opcao_id].to_s + opcao = questao.opcoes.find(opcao_id) + OpcaoEscolhida.find_or_create_by!(resposta: resposta, opcao: opcao) + end + end + + @avaliacao.marcar_como_respondida! + end + + redirect_to pendentes_avaliacoes_path, + notice: 'Avaliação registrada com sucesso.' + rescue ActiveRecord::RecordInvalid + flash.now[:alert] = 'Todas as questões obrigatórias devem ser preenchidas.' + render :responder, status: :unprocessable_entity + end end \ No newline at end of file diff --git a/app/views/avaliacoes/pendentes.html.erb b/app/views/avaliacoes/pendentes.html.erb index b5ddfa8cb6..3e90b9ca6e 100644 --- a/app/views/avaliacoes/pendentes.html.erb +++ b/app/views/avaliacoes/pendentes.html.erb @@ -1,5 +1,13 @@

Avaliações Pendentes

+<% if flash[:notice] %> +

<%= flash[:notice] %>

+<% end %> + +<% if flash[:alert] %> +

<%= flash[:alert] %>

+<% end %> + <% if @avaliacoes_pendentes.any? %> @@ -14,7 +22,7 @@ - + <% end %> diff --git a/app/views/avaliacoes/responder.html.erb b/app/views/avaliacoes/responder.html.erb new file mode 100644 index 0000000000..1be17731e6 --- /dev/null +++ b/app/views/avaliacoes/responder.html.erb @@ -0,0 +1,33 @@ +

Responder Avaliação

+ +<% if flash[:alert] %> +

<%= flash[:alert] %>

+<% end %> + +

Turma: <%= @formulario.turma.materia.nome %>

+ +<%= form_with url: submeter_avaliacao_path(@avaliacao), method: :post do |f| %> + <% @questoes.each_with_index do |questao, i| %> +
+

<%= i + 1 %>. <%= questao.enunciado %>

+ + <% if questao.discursiva? %> + + + <% else %> + <% questao.opcoes.ordenadas.each do |opcao| %> +
+ <% end %> + <% end %> +
+
+ <% end %> + + +<% end %> \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index c8425aaff7..363fbd4c7d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,20 +1,13 @@ Rails.application.routes.draw do - # 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 - # Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb) - # get "manifest" => "rails/pwa#manifest", as: :pwa_manifest - # get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker - - # Defines the root path route ("/") - # root "posts#index" - resources :avaliacoes, only: [] do collection do get 'pendentes' end - end + member do # ← apenas isso é novo + get 'responder' + post 'submeter' + end + end end \ No newline at end of file diff --git a/features/step_definitions/responder_avaliacao_steps.rb b/features/step_definitions/responder_avaliacao_steps.rb new file mode 100644 index 0000000000..fe0578ece7 --- /dev/null +++ b/features/step_definitions/responder_avaliacao_steps.rb @@ -0,0 +1,96 @@ +def criar_contexto_formulario_com_questoes(nome_turma) + depto = Departamento.find_or_create_by!(nome: 'Departamento Geral') + materia = Materia.find_or_create_by!(nome: nome_turma, departamento: depto) do |m| + m.codigo = "COD#{rand(1000..9999)}" + end + + @turma = Turma.find_or_create_by!(materia: materia, ano: 2026, semestre: :primeiro) do |t| + t.numero = rand(1..100) + end + + adm = Usuario.create!(nome: 'Adm', email: "adm#{rand(10000)}@t.com", + senha: 'password123', status: :ativo) + perf_adm = PerfilAdm.create!(usuario: adm, departamento: depto) + + @questao_discursiva = Questao.create!( + enunciado: 'Como você avalia a turma?', + tipo: :discursiva + ) + + @questao_objetiva = Questao.create!( + enunciado: 'Qual nota você dá?', + tipo: :objetiva + ) + Opcao.create!(questao: @questao_objetiva, numero: 1, texto: 'Ótimo') + Opcao.create!(questao: @questao_objetiva, numero: 2, texto: 'Ruim') + + template = Template.create!(adm: perf_adm, titulo: "Template #{rand(1000)}") + UtilizacaoQuestao.create!(template: template, questao: @questao_discursiva, numero: 1) + UtilizacaoQuestao.create!(template: template, questao: @questao_objetiva, numero: 2) + + @formulario = Formulario.create!( + adm: perf_adm, + turma: @turma, + publico_alvo: :discentes, + template: template + ) + + criar_participacao_com_perfil(@usuario, @turma, :discente) + + participacao = ParticipacaoTurma.find_by!(usuario: @usuario, turma: @turma) + @avaliacao = Avaliacao.create!(formulario: @formulario, participacao_turma: participacao) +end + +Dado('que estou na página de resposta do formulário da turma {string}') do |nome_turma| + criar_contexto_formulario_com_questoes(nome_turma) + visit responder_avaliacao_path(@avaliacao) +end + +Dado('que já respondi o formulário da turma {string} anteriormente') do |nome_turma| + criar_contexto_formulario_com_questoes(nome_turma) + @avaliacao.marcar_como_respondida! +end + +Quando('eu preencho todas as questões obrigatórias') do + find("textarea[name='respostas[#{@questao_discursiva.id}][texto]']") + .set('Achei a turma muito boa.') + + primeira_opcao = @questao_objetiva.opcoes.ordenadas.first + find("input[name='respostas[#{@questao_objetiva.id}][opcao_id]'][value='#{primeira_opcao.id}']") + .set(true) +end + +Quando('eu deixo uma questão obrigatória em branco') do + find("textarea[name='respostas[#{@questao_discursiva.id}][texto]']") + .set('Resposta parcial.') + # questão objetiva intencionalmente deixada em branco +end + +Quando('confirmo o envio da avaliação') do + click_button 'Confirmar envio' +end + +Quando('eu tento acessar a página de resposta do formulário da turma {string}') do |_nome_turma| + visit responder_avaliacao_path(@avaliacao) +end + +Então('devo ver uma mensagem informando que a avaliação foi registrada com sucesso') do + expect(page).to have_content('Avaliação registrada com sucesso.') +end + +Então('o formulário da turma {string} não deve mais aparecer na lista de pendentes') do |nome_turma| + expect(page).not_to have_content(nome_turma) +end + +Então('devo ver uma mensagem informando que todas as questões obrigatórias devem ser preenchidas') do + expect(page).to have_content('Todas as questões obrigatórias devem ser preenchidas.') +end + +Então('a avaliação não deve ser registrada') do + @avaliacao.reload + expect(@avaliacao.respondida?).to be false +end + +Então('devo ver uma mensagem informando que esta avaliação já foi respondida') do + expect(page).to have_content('Esta avaliação já foi respondida.') +end From da3ed221da2fd945a7ddad98b1ece2777500be93 Mon Sep 17 00:00:00 2001 From: PedroFFranca Date: Mon, 15 Jun 2026 22:11:44 -0300 Subject: [PATCH 044/125] fix: ajustando o commit anterior para se adequar aos models corretos. --- .../sigaa/password_requests_controller.rb | 15 +++ app/models/formulario.rb | 5 + app/models/questao.rb | 5 + app/models/turma.rb | 30 +++++ .../formularios/create_from_template.rb | 47 +++---- app/views/formularios/new.html.erb | 2 +- ...614120000_create_templates_and_questoes.rb | 22 ---- ...ate_formularios_and_questoes_formulario.rb | 25 ---- db/migrate/20260614140000_create_perfis.rb | 37 ------ ...20260614150000_align_templates_with_uml.rb | 35 ----- ...0_align_formularios_and_turmas_with_uml.rb | 78 ----------- .../20260614170000_align_questoes_with_uml.rb | 122 ------------------ .../20260614180000_create_avaliacao_chain.rb | 32 ----- ...260615120000_add_formulario_to_questoes.rb | 5 + db/schema.rb | 5 +- .../step_definitions/formularios_steps.rb | 25 ++-- features/support/database_cleaner.rb | 9 ++ features/support/rspec_mocks.rb | 15 +++ features/support/sigaa_test_data.rb | 5 + features/support/test_data_helpers.rb | 82 ++++++++++++ spec/models/formulario_spec.rb | 2 +- spec/requests/formularios_spec.rb | 9 +- .../formularios/create_from_template_spec.rb | 27 ++-- spec/support/test_builders.rb | 58 ++++++--- 24 files changed, 266 insertions(+), 431 deletions(-) create mode 100644 app/controllers/sigaa/password_requests_controller.rb delete mode 100644 db/migrate/20260614120000_create_templates_and_questoes.rb delete mode 100644 db/migrate/20260614130000_create_formularios_and_questoes_formulario.rb delete mode 100644 db/migrate/20260614140000_create_perfis.rb delete mode 100644 db/migrate/20260614150000_align_templates_with_uml.rb delete mode 100644 db/migrate/20260614160000_align_formularios_and_turmas_with_uml.rb delete mode 100644 db/migrate/20260614170000_align_questoes_with_uml.rb delete mode 100644 db/migrate/20260614180000_create_avaliacao_chain.rb create mode 100644 db/migrate/20260615120000_add_formulario_to_questoes.rb create mode 100644 features/support/database_cleaner.rb create mode 100644 features/support/rspec_mocks.rb create mode 100644 features/support/sigaa_test_data.rb create mode 100644 features/support/test_data_helpers.rb diff --git a/app/controllers/sigaa/password_requests_controller.rb b/app/controllers/sigaa/password_requests_controller.rb new file mode 100644 index 0000000000..f5bcd88416 --- /dev/null +++ b/app/controllers/sigaa/password_requests_controller.rb @@ -0,0 +1,15 @@ +module Sigaa + class PasswordRequestsController < ApplicationController + before_action :require_admin! + + def create + result = PasswordRequestService.new.call + + if result.success? + redirect_to gerenciamento_path, notice: result.message + else + redirect_to gerenciamento_path, alert: result.message + end + end + end +end diff --git a/app/models/formulario.rb b/app/models/formulario.rb index a9f8e6999b..d12660984d 100644 --- a/app/models/formulario.rb +++ b/app/models/formulario.rb @@ -18,6 +18,11 @@ class Formulario < ApplicationRecord optional: true, inverse_of: :formularios + has_many :questoes, + class_name: "Questao", + dependent: :destroy, + inverse_of: :formulario + has_many :avaliacoes, class_name: "Avaliacao", dependent: :restrict_with_error, diff --git a/app/models/questao.rb b/app/models/questao.rb index 3713fef555..f2a9c2da3a 100644 --- a/app/models/questao.rb +++ b/app/models/questao.rb @@ -4,6 +4,11 @@ class Questao < ApplicationRecord discursiva: 1 } + belongs_to :formulario, + class_name: "Formulario", + optional: true, + inverse_of: :questoes + has_many :utilizacao_questoes, class_name: "UtilizacaoQuestao", foreign_key: :questao_id, diff --git a/app/models/turma.rb b/app/models/turma.rb index fe6e324362..fbebcda42f 100644 --- a/app/models/turma.rb +++ b/app/models/turma.rb @@ -46,6 +46,36 @@ class Turma < ApplicationRecord joins(:materia).where(materias: { departamento_id: departamento.id }) } + scope :do_semestre_atual, -> { + where(ano: Date.current.year, semestre: semestre_atual) + } + + scope :sem_formulario, -> { + left_joins(:formularios).where(formularios: { id: nil }) + } + + def self.semestre_atual + mes = Date.current.month + return :primeiro if mes <= 6 + return :segundo if mes <= 11 + + :verao + end + + def self.numero_de_codigo_exibicao(codigo) + return codigo.to_i if codigo.match?(/\A\d+\z/) + + codigo.upcase.ord - 64 + end + + def codigo_exibicao + (numero + 64).chr + end + + def nome_exibicao + "#{materia.nome} - Turma #{codigo_exibicao}" + end + def departamento materia.departamento end diff --git a/app/services/formularios/create_from_template.rb b/app/services/formularios/create_from_template.rb index d18d459ab6..a1ac56f123 100644 --- a/app/services/formularios/create_from_template.rb +++ b/app/services/formularios/create_from_template.rb @@ -26,12 +26,13 @@ def call ActiveRecord::Base.transaction do turmas.each do |turma| formulario = Formulario.create!( + adm: perfil_adm, template: template, - perfil_adm: perfil_adm + turma: turma, + publico_alvo: :docentes ) - copy_questoes!(formulario) - turma.update!(formulario: formulario) + copy_questoes_from_template!(formulario) formularios << formulario end end @@ -48,7 +49,7 @@ def validate_turma_ids! end def validate_template! - raise Error, SEM_QUESTOES if template.questoes.none? + raise Error, SEM_QUESTOES if template.utilizacao_questoes.raizes.none? end def validate_turmas! @@ -56,7 +57,7 @@ def validate_turmas! end def validate_turmas_sem_formulario! - raise Error, TURMA_COM_FORMULARIO if turmas.where.not(formulario_id: nil).exists? + raise Error, TURMA_COM_FORMULARIO if turmas.joins(:formularios).exists? end def template @@ -67,34 +68,20 @@ def turmas @turmas ||= Turma.do_semestre_atual.where(id: turma_ids) end - def copy_questoes!(formulario) - template.questoes.order(:posicao).each do |questao_template| - questao = formulario.questoes.create!( - enunciado: questao_template.enunciado, - tipo: questao_template.tipo, - obrigatoria: questao_template.obrigatoria, - posicao: questao_template.posicao - ) - - copy_opcoes!(questao_template, questao) - end - end + def copy_questoes_from_template!(formulario) + template.utilizacao_questoes.raizes.ordenadas.each do |utilizacao| + questao_origem = utilizacao.questao - def copy_opcoes!(questao_template, questao) - return unless questao_template.objetiva? + questao = formulario.questoes.build( + enunciado: questao_origem.enunciado, + tipo: questao_origem.tipo + ) - opcoes_template(questao_template).each do |texto| - questao.opcoes.create!(texto: texto) - end - end + questao_origem.opcoes.ordenadas.each do |opcao| + questao.opcoes.build(numero: opcao.numero, texto: opcao.texto) + end - def opcoes_template(questao_template) - if questao_template.opcoes.any? - questao_template.opcoes.pluck(:texto) - elsif questao_template.read_attribute(:opcoes).present? - Array(questao_template.read_attribute(:opcoes)) - else - [] + questao.save! end end end diff --git a/app/views/formularios/new.html.erb b/app/views/formularios/new.html.erb index a78c677b1d..d22975599e 100644 --- a/app/views/formularios/new.html.erb +++ b/app/views/formularios/new.html.erb @@ -12,7 +12,7 @@
<%= select_tag :template_id, - options_from_collection_for_select(@templates, :id, :nome), + options_from_collection_for_select(@templates, :id, :titulo), prompt: "Selecione um template" %>
diff --git a/db/migrate/20260614120000_create_templates_and_questoes.rb b/db/migrate/20260614120000_create_templates_and_questoes.rb deleted file mode 100644 index c278595b5a..0000000000 --- a/db/migrate/20260614120000_create_templates_and_questoes.rb +++ /dev/null @@ -1,22 +0,0 @@ -class CreateTemplatesAndQuestoes < ActiveRecord::Migration[8.1] - def change - create_table :templates do |t| - t.string :titulo, null: false - t.text :descricao - t.references :usuario, foreign_key: true - - t.timestamps - end - - create_table :questoes do |t| - t.references :template, null: false, foreign_key: true - t.text :enunciado, null: false - t.string :tipo, null: false - t.json :opcoes - t.boolean :obrigatoria, default: false, null: false - t.integer :posicao, null: false - - t.timestamps - end - end -end diff --git a/db/migrate/20260614130000_create_formularios_and_questoes_formulario.rb b/db/migrate/20260614130000_create_formularios_and_questoes_formulario.rb deleted file mode 100644 index d425feedc8..0000000000 --- a/db/migrate/20260614130000_create_formularios_and_questoes_formulario.rb +++ /dev/null @@ -1,25 +0,0 @@ -class CreateFormulariosAndQuestoesFormulario < ActiveRecord::Migration[8.1] - def change - create_table :formularios do |t| - t.string :nome - t.references :template, null: false, foreign_key: true - t.references :turma, null: false, foreign_key: true - t.references :usuario, null: false, foreign_key: true - - t.timestamps - end - - add_index :formularios, %i[template_id turma_id], unique: true - - create_table :questoes_formulario do |t| - t.references :formulario, null: false, foreign_key: true - t.text :enunciado, null: false - t.string :tipo, null: false - t.json :opcoes - t.boolean :obrigatoria, default: false, null: false - t.integer :posicao, null: false - - t.timestamps - end - end -end diff --git a/db/migrate/20260614140000_create_perfis.rb b/db/migrate/20260614140000_create_perfis.rb deleted file mode 100644 index 1b962dcdc3..0000000000 --- a/db/migrate/20260614140000_create_perfis.rb +++ /dev/null @@ -1,37 +0,0 @@ -class CreatePerfis < ActiveRecord::Migration[8.1] - def change - create_table :perfil_adms do |t| - t.references :usuario, null: false, foreign_key: true, index: { unique: true } - t.references :departamento, foreign_key: true - - t.timestamps - end - - create_table :perfil_docentes do |t| - t.references :usuario, null: false, foreign_key: true, index: { unique: true } - t.references :departamento, foreign_key: true - - t.timestamps - end - - create_table :perfil_discentes do |t| - t.references :usuario, null: false, foreign_key: true, index: { unique: true } - t.string :matricula, null: false - - t.timestamps - end - - reversible do |dir| - dir.up do - execute <<~SQL.squish - INSERT INTO perfil_adms (usuario_id, departamento_id, created_at, updated_at) - SELECT id, departamento_id, datetime('now'), datetime('now') - FROM usuarios - WHERE admin = 1 - SQL - end - end - - remove_column :usuarios, :admin, :boolean, default: false, null: false - end -end diff --git a/db/migrate/20260614150000_align_templates_with_uml.rb b/db/migrate/20260614150000_align_templates_with_uml.rb deleted file mode 100644 index dc570db422..0000000000 --- a/db/migrate/20260614150000_align_templates_with_uml.rb +++ /dev/null @@ -1,35 +0,0 @@ -class AlignTemplatesWithUml < ActiveRecord::Migration[8.1] - def up - add_reference :templates, :perfil_adm, foreign_key: true - - execute <<~SQL.squish - UPDATE templates - SET perfil_adm_id = ( - SELECT perfil_adms.id - FROM perfil_adms - WHERE perfil_adms.usuario_id = templates.usuario_id - ) - WHERE usuario_id IS NOT NULL - SQL - - remove_reference :templates, :usuario, foreign_key: true - rename_column :templates, :titulo, :nome - end - - def down - add_reference :templates, :usuario, foreign_key: true - - execute <<~SQL.squish - UPDATE templates - SET usuario_id = ( - SELECT perfil_adms.usuario_id - FROM perfil_adms - WHERE perfil_adms.id = templates.perfil_adm_id - ) - WHERE perfil_adm_id IS NOT NULL - SQL - - rename_column :templates, :nome, :titulo - remove_reference :templates, :perfil_adm, foreign_key: true - end -end diff --git a/db/migrate/20260614160000_align_formularios_and_turmas_with_uml.rb b/db/migrate/20260614160000_align_formularios_and_turmas_with_uml.rb deleted file mode 100644 index eaf6071f0d..0000000000 --- a/db/migrate/20260614160000_align_formularios_and_turmas_with_uml.rb +++ /dev/null @@ -1,78 +0,0 @@ -class AlignFormulariosAndTurmasWithUml < ActiveRecord::Migration[8.1] - def up - add_reference :formularios, :perfil_adm, foreign_key: true - add_reference :turmas, :formulario, foreign_key: true - add_column :turmas, :numero, :integer - add_column :turmas, :ano, :integer - add_column :turmas, :periodo, :integer - - execute <<~SQL.squish - UPDATE formularios - SET perfil_adm_id = ( - SELECT perfil_adms.id - FROM perfil_adms - WHERE perfil_adms.usuario_id = formularios.usuario_id - ) - WHERE usuario_id IS NOT NULL - SQL - - execute <<~SQL.squish - UPDATE turmas - SET formulario_id = ( - SELECT formularios.id - FROM formularios - WHERE formularios.turma_id = turmas.id - ) - WHERE EXISTS ( - SELECT 1 - FROM formularios - WHERE formularios.turma_id = turmas.id - ) - SQL - - remove_index :formularios, name: "index_formularios_on_template_id_and_turma_id" - remove_reference :formularios, :turma, foreign_key: true - remove_reference :formularios, :usuario, foreign_key: true - remove_column :formularios, :nome, :string - - change_column_null :formularios, :perfil_adm_id, false - end - - def down - add_column :formularios, :nome, :string - add_reference :formularios, :usuario, foreign_key: true - add_reference :formularios, :turma, foreign_key: true - - execute <<~SQL.squish - UPDATE formularios - SET usuario_id = ( - SELECT perfil_adms.usuario_id - FROM perfil_adms - WHERE perfil_adms.id = formularios.perfil_adm_id - ) - WHERE perfil_adm_id IS NOT NULL - SQL - - execute <<~SQL.squish - UPDATE formularios - SET turma_id = ( - SELECT turmas.id - FROM turmas - WHERE turmas.formulario_id = formularios.id - ) - WHERE EXISTS ( - SELECT 1 - FROM turmas - WHERE turmas.formulario_id = formularios.id - ) - SQL - - add_index :formularios, %i[template_id turma_id], unique: true - - remove_reference :turmas, :formulario, foreign_key: true - remove_column :turmas, :periodo, :integer - remove_column :turmas, :ano, :integer - remove_column :turmas, :numero, :integer - remove_reference :formularios, :perfil_adm, foreign_key: true - end -end diff --git a/db/migrate/20260614170000_align_questoes_with_uml.rb b/db/migrate/20260614170000_align_questoes_with_uml.rb deleted file mode 100644 index ffc831b649..0000000000 --- a/db/migrate/20260614170000_align_questoes_with_uml.rb +++ /dev/null @@ -1,122 +0,0 @@ -class AlignQuestoesWithUml < ActiveRecord::Migration[8.1] - def up - add_reference :questoes, :formulario, foreign_key: true - add_column :questoes, :tipo_novo, :integer - - execute <<~SQL.squish - UPDATE questoes - SET tipo_novo = CASE - WHEN tipo = 'multipla_escolha' THEN 0 - ELSE 1 - END - SQL - - create_table :opcoes do |t| - t.references :questao, null: false, foreign_key: true - t.string :texto, null: false - - t.timestamps - end - - questoes_formulario_rows.each do |row| - questao_id = insert_questao_formulario(row) - insert_opcoes(questao_id, row["opcoes"]) - end - - change_column_null :questoes, :tipo_novo, false - remove_column :questoes, :tipo, :string - rename_column :questoes, :tipo_novo, :tipo - change_column_null :questoes, :template_id, true - drop_table :questoes_formulario - end - - def down - create_table :questoes_formulario do |t| - t.references :formulario, null: false, foreign_key: true - t.text :enunciado, null: false - t.string :tipo, null: false - t.json :opcoes - t.boolean :obrigatoria, default: false, null: false - t.integer :posicao, null: false - - t.timestamps - end - - questoes_formulario_backfill - - drop_table :opcoes - remove_reference :questoes, :formulario, foreign_key: true - rename_column :questoes, :tipo, :tipo_novo - add_column :questoes, :tipo, :string - - execute <<~SQL.squish - UPDATE questoes - SET tipo = CASE - WHEN tipo_novo = 0 THEN 'multipla_escolha' - ELSE 'texto' - END - SQL - - change_column_null :questoes, :tipo, false - remove_column :questoes, :tipo_novo, :integer - change_column_null :questoes, :template_id, false - end - - private - - def questoes_formulario_rows - connection.select_all("SELECT * FROM questoes_formulario ORDER BY id") - end - - def insert_questao_formulario(row) - tipo = row["tipo"] == "multipla_escolha" ? 0 : 1 - now = connection.quote(Time.current) - - connection.insert <<~SQL.squish - INSERT INTO questoes - (formulario_id, template_id, enunciado, tipo_novo, obrigatoria, posicao, created_at, updated_at) - VALUES - (#{row["formulario_id"]}, NULL, #{connection.quote(row["enunciado"])}, #{tipo}, - #{row["obrigatoria"] ? 1 : 0}, #{row["posicao"]}, #{now}, #{now}) - SQL - end - - def insert_opcoes(questao_id, opcoes_json) - return if opcoes_json.blank? - - opcoes = JSON.parse(opcoes_json) - now = connection.quote(Time.current) - - opcoes.each do |texto| - connection.execute <<~SQL.squish - INSERT INTO opcoes (questao_id, texto, created_at, updated_at) - VALUES (#{questao_id}, #{connection.quote(texto)}, #{now}, #{now}) - SQL - end - end - - def questoes_formulario_backfill - questoes = connection.select_all(<<~SQL.squish) - SELECT questoes.*, GROUP_CONCAT(opcoes.texto) AS opcoes_textos - FROM questoes - LEFT JOIN opcoes ON opcoes.questao_id = questoes.id - WHERE questoes.formulario_id IS NOT NULL - GROUP BY questoes.id - SQL - - questoes.each do |questao| - tipo = questao["tipo"].to_i == 0 ? "multipla_escolha" : "texto" - opcoes = questao["opcoes_textos"]&.split(",") || [] - now = connection.quote(Time.current) - - connection.execute <<~SQL.squish - INSERT INTO questoes_formulario - (formulario_id, enunciado, tipo, opcoes, obrigatoria, posicao, created_at, updated_at) - VALUES - (#{questao["formulario_id"]}, #{connection.quote(questao["enunciado"])}, #{connection.quote(tipo)}, - #{connection.quote(opcoes.to_json)}, #{questao["obrigatoria"] ? 1 : 0}, #{questao["posicao"]}, - #{now}, #{now}) - SQL - end - end -end diff --git a/db/migrate/20260614180000_create_avaliacao_chain.rb b/db/migrate/20260614180000_create_avaliacao_chain.rb deleted file mode 100644 index b193b90f46..0000000000 --- a/db/migrate/20260614180000_create_avaliacao_chain.rb +++ /dev/null @@ -1,32 +0,0 @@ -class CreateAvaliacaoChain < ActiveRecord::Migration[8.1] - def change - create_table :avaliacoes do |t| - t.references :participacao_turma, null: false, foreign_key: true - t.references :formulario, null: false, foreign_key: true - t.datetime :respondido_em - - t.timestamps - end - - create_table :respostas do |t| - t.references :avaliacao, null: false, foreign_key: true - t.references :questao, null: false, foreign_key: true - - t.timestamps - end - - create_table :opcoes_escolhidas do |t| - t.references :resposta, null: false, foreign_key: true, index: { unique: true } - t.references :opcao, null: false, foreign_key: true - - t.timestamps - end - - create_table :textos do |t| - t.references :resposta, null: false, foreign_key: true, index: { unique: true } - t.text :texto, null: false - - t.timestamps - end - end -end diff --git a/db/migrate/20260615120000_add_formulario_to_questoes.rb b/db/migrate/20260615120000_add_formulario_to_questoes.rb new file mode 100644 index 0000000000..fb60b23cbc --- /dev/null +++ b/db/migrate/20260615120000_add_formulario_to_questoes.rb @@ -0,0 +1,5 @@ +class AddFormularioToQuestoes < ActiveRecord::Migration[8.1] + def change + add_reference :questoes, :formulario, foreign_key: true, null: true + end +end diff --git a/db/schema.rb b/db/schema.rb index bf123ebc37..dd04ee2dd4 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_06_12_221655) do +ActiveRecord::Schema[8.1].define(version: 2026_06_15_120000) do create_table "avaliacoes", force: :cascade do |t| t.datetime "created_at", null: false t.integer "formulario_id", null: false @@ -107,8 +107,10 @@ create_table "questoes", force: :cascade do |t| t.datetime "created_at", null: false t.text "enunciado", null: false + t.integer "formulario_id" t.integer "tipo", null: false t.datetime "updated_at", null: false + t.index ["formulario_id"], name: "index_questoes_on_formulario_id" end create_table "respostas", force: :cascade do |t| @@ -193,6 +195,7 @@ add_foreign_key "perfis_discentes", "usuarios", column: "id" add_foreign_key "perfis_docentes", "departamentos" add_foreign_key "perfis_docentes", "usuarios", column: "id" + add_foreign_key "questoes", "formularios" add_foreign_key "respostas", "avaliacoes" add_foreign_key "respostas", "questoes" add_foreign_key "templates", "perfis_adm", column: "adm_id" diff --git a/features/step_definitions/formularios_steps.rb b/features/step_definitions/formularios_steps.rb index 6105e3d9bd..eb8fd026a2 100644 --- a/features/step_definitions/formularios_steps.rb +++ b/features/step_definitions/formularios_steps.rb @@ -34,15 +34,20 @@ Então("o formulário deve ser gerado com sucesso para ambas as turmas") do expect(Formulario.count).to eq(2) - expect(@turma_a.reload.formulario).to be_present - expect(@turma_b.reload.formulario).to be_present - expect(@turma_a.formulario.template).to eq(@template) - expect(@turma_b.formulario.template).to eq(@template) + formulario_a = @turma_a.reload.formularios.sole + formulario_b = @turma_b.reload.formularios.sole - [@turma_a, @turma_b].each do |turma| - questoes_formulario = turma.formulario.questoes.order(:posicao) - expect(questoes_formulario.count).to eq(@template.questoes.count) - expect(questoes_formulario.pluck(:enunciado)).to eq(@template.questoes.order(:posicao).pluck(:enunciado)) + expect(formulario_a).to be_present + expect(formulario_b).to be_present + expect(formulario_a.template).to eq(@template) + expect(formulario_b.template).to eq(@template) + + questoes_template = questoes_ordenadas_do_template(@template) + + [formulario_a, formulario_b].each do |formulario| + questoes_formulario = formulario.questoes.order(:id) + expect(questoes_formulario.count).to eq(questoes_template.count) + expect(questoes_formulario.pluck(:enunciado)).to eq(questoes_template.map(&:enunciado)) end end @@ -59,6 +64,6 @@ end def criar_turma_do_nome_exibicao(nome_exibicao) - materia_nome, codigo_turma = nome_exibicao.split(" - Turma ", 2) - criar_turma(nome_materia: materia_nome, codigo_turma: codigo_turma) + materia_nome, codigo = nome_exibicao.split(" - Turma ", 2) + criar_turma(nome_materia: materia_nome, numero: Turma.numero_de_codigo_exibicao(codigo)) end diff --git a/features/support/database_cleaner.rb b/features/support/database_cleaner.rb new file mode 100644 index 0000000000..9d7abd5099 --- /dev/null +++ b/features/support/database_cleaner.rb @@ -0,0 +1,9 @@ +Before do + DatabaseCleaner.start + SigaaTestData.configure! + ActionMailer::Base.deliveries.clear +end + +After do + DatabaseCleaner.clean +end diff --git a/features/support/rspec_mocks.rb b/features/support/rspec_mocks.rb new file mode 100644 index 0000000000..33a282fef3 --- /dev/null +++ b/features/support/rspec_mocks.rb @@ -0,0 +1,15 @@ +require "rspec/mocks" + +World(RSpec::Mocks::ExampleMethods) + +Before do + RSpec::Mocks.setup +end + +After do + begin + RSpec::Mocks.verify + ensure + RSpec::Mocks.teardown + end +end diff --git a/features/support/sigaa_test_data.rb b/features/support/sigaa_test_data.rb new file mode 100644 index 0000000000..c9ad190492 --- /dev/null +++ b/features/support/sigaa_test_data.rb @@ -0,0 +1,5 @@ +module SigaaTestData + def self.configure! + # Reservado para configuração compartilhada de cenários SIGAA. + end +end diff --git a/features/support/test_data_helpers.rb b/features/support/test_data_helpers.rb new file mode 100644 index 0000000000..3a443b6f0a --- /dev/null +++ b/features/support/test_data_helpers.rb @@ -0,0 +1,82 @@ +module TestDataHelpers + def criar_administrador(**attrs) + departamento = attrs[:departamento] || Departamento.create!(nome: "DCC #{SecureRandom.hex(2)}") + @departamento = departamento + defaults = { + nome: "Administrador", + email: "#{SecureRandom.hex(4)}@example.com", + status: :ativo, + senha: "senha12345" + } + usuario = Usuario.create!(defaults.merge(attrs.except(:departamento))) + PerfilAdm.create!(usuario: usuario, departamento: departamento) + usuario + end + + def criar_template_com_questoes(titulo:, questoes: nil) + template = Template.new( + titulo: titulo, + descricao: "Template de teste", + adm: @admin.perfil_adm, + criado_em: Time.current + ) + + (questoes || questoes_padrao).each_with_index do |questao_attrs, index| + attrs = questao_attrs.dup + opcoes = attrs.delete(:opcoes) + numero = attrs.delete(:numero) || attrs.delete(:posicao) || (index + 1) + attrs.delete(:obrigatoria) + + questao = Questao.new(attrs) + + Array(opcoes).each_with_index do |texto, opcao_index| + questao.opcoes.build(numero: opcao_index + 1, texto: texto) + end if opcoes.present? + + questao.save! + + template.utilizacao_questoes.build(questao: questao, numero: numero) + end + + template.save! + template.utilizacao_questoes.each(&:save!) + template + end + + def criar_turma(nome_materia:, numero:, semestre: Turma.semestre_atual, ano: Date.current.year) + departamento = @departamento || @admin&.perfil_adm&.departamento || + Departamento.create!(nome: "DCC #{SecureRandom.hex(2)}") + materia = Materia.find_or_create_by!(codigo: nome_materia.parameterize.upcase) do |record| + record.nome = nome_materia + record.departamento = departamento + end + + Turma.create!( + materia: materia, + numero: numero, + semestre: semestre, + ano: ano + ) + end + + def login_como(usuario) + allow_any_instance_of(ApplicationController).to receive(:current_usuario).and_return(usuario) + end + + def questoes_ordenadas_do_template(template) + template.utilizacao_questoes.raizes.ordenadas.includes(:questao).map(&:questao) + end + + private + + def questoes_padrao + [ + { + enunciado: "Como você avalia a disciplina?", + tipo: :discursiva + } + ] + end +end + +World(TestDataHelpers) diff --git a/spec/models/formulario_spec.rb b/spec/models/formulario_spec.rb index 8c8b9a94d8..48ccb5ff36 100644 --- a/spec/models/formulario_spec.rb +++ b/spec/models/formulario_spec.rb @@ -6,7 +6,7 @@ formulario = described_class.new expect(formulario).not_to be_valid - expect(formulario.errors[:perfil_adm]).to include("must exist") + expect(formulario.errors[:adm]).to include("must exist") end end end diff --git a/spec/requests/formularios_spec.rb b/spec/requests/formularios_spec.rb index d45b546838..09249f0a8b 100644 --- a/spec/requests/formularios_spec.rb +++ b/spec/requests/formularios_spec.rb @@ -1,11 +1,12 @@ require "rails_helper" RSpec.describe "Formularios", type: :request do - let(:admin) { create_admin_usuario } + let(:departamento) { Departamento.create!(nome: "DCC #{SecureRandom.hex(2)}") } + let(:admin) { create_admin_usuario(departamento: departamento) } let(:usuario) { create_usuario } - let(:template) { create_template_with_questoes(titulo: "Avaliação Docente") } - let(:turma_a) { create_turma(nome_materia: "MDS", codigo_turma: "A") } - let(:turma_b) { create_turma(nome_materia: "IHC", codigo_turma: "B") } + let(:template) { create_template_with_questoes(titulo: "Avaliação Docente", adm: admin.perfil_adm) } + let(:turma_a) { create_turma(nome_materia: "MDS", numero: 1, departamento: departamento) } + let(:turma_b) { create_turma(nome_materia: "IHC", numero: 2, departamento: departamento) } describe "POST /formularios" do it "cria formulários para múltiplas turmas quando admin autenticado" do diff --git a/spec/services/formularios/create_from_template_spec.rb b/spec/services/formularios/create_from_template_spec.rb index 330e6ef768..16d274e16c 100644 --- a/spec/services/formularios/create_from_template_spec.rb +++ b/spec/services/formularios/create_from_template_spec.rb @@ -1,11 +1,12 @@ require "rails_helper" RSpec.describe Formularios::CreateFromTemplate do - let(:admin) { create_admin_usuario } + let(:departamento) { Departamento.create!(nome: "DCC #{SecureRandom.hex(2)}") } + let(:admin) { create_admin_usuario(departamento: departamento) } let(:perfil_adm) { admin.perfil_adm } - let(:template) { create_template_with_questoes(titulo: "Avaliação Docente") } - let(:turma_a) { create_turma(nome_materia: "MDS", codigo_turma: "A") } - let(:turma_b) { create_turma(nome_materia: "IHC", codigo_turma: "B") } + let(:template) { create_template_with_questoes(titulo: "Avaliação Docente", adm: perfil_adm) } + let(:turma_a) { create_turma(nome_materia: "MDS", numero: 1, departamento: departamento) } + let(:turma_b) { create_turma(nome_materia: "IHC", numero: 2, departamento: departamento) } describe ".call" do it "cria um formulário por turma selecionada" do @@ -17,8 +18,8 @@ expect(formularios.size).to eq(2) expect(Formulario.count).to eq(2) - expect(turma_a.reload.formulario).to eq(formularios.first) - expect(turma_b.reload.formulario).to eq(formularios.second) + expect(turma_a.reload.formularios.sole).to eq(formularios.first) + expect(turma_b.reload.formularios.sole).to eq(formularios.second) end it "copia as questões do template como snapshot independente" do @@ -29,12 +30,12 @@ ) formulario = formularios.first - questoes_template = template.questoes.order(:posicao) - questoes_formulario = formulario.questoes.order(:posicao) + questoes_template = questoes_ordenadas_do_template(template) + questoes_formulario = formulario.questoes.order(:id) expect(questoes_formulario.count).to eq(questoes_template.count) - expect(questoes_formulario.pluck(:enunciado)).to eq(questoes_template.pluck(:enunciado)) - expect(questoes_formulario.pluck(:tipo)).to eq(questoes_template.pluck(:tipo)) + expect(questoes_formulario.pluck(:enunciado)).to eq(questoes_template.map(&:enunciado)) + expect(questoes_formulario.pluck(:tipo)).to eq(questoes_template.map(&:tipo)) questao_template = questoes_template.first questao_formulario = questoes_formulario.first @@ -53,7 +54,7 @@ ) questao_objetiva = formularios.first.questoes.find_by(tipo: :objetiva) - expect(questao_objetiva.opcoes.pluck(:texto)).to eq(%w[Ruim Regular Boa Excelente]) + expect(questao_objetiva.opcoes.order(:numero).pluck(:texto)).to eq(%w[Ruim Regular Boa Excelente]) end it "faz rollback se a criação de um formulário falhar" do @@ -75,8 +76,8 @@ end.to raise_error(ActiveRecord::RecordInvalid) expect(Formulario.count).to eq(0) - expect(turma_a.reload.formulario_id).to be_nil - expect(turma_b.reload.formulario_id).to be_nil + expect(turma_a.reload.formularios).to be_empty + expect(turma_b.reload.formularios).to be_empty end it "levanta erro quando nenhuma turma é selecionada" do diff --git a/spec/support/test_builders.rb b/spec/support/test_builders.rb index abb8798d51..f3954ae832 100644 --- a/spec/support/test_builders.rb +++ b/spec/support/test_builders.rb @@ -1,66 +1,84 @@ module TestBuilders def create_admin_usuario(**attrs) + departamento = attrs[:departamento] || Departamento.create!(nome: "DCC #{SecureRandom.hex(2)}") defaults = { nome: "Administrador", - matricula: SecureRandom.hex(4), - status: "ativo" + email: "#{SecureRandom.hex(4)}@example.com", + status: :ativo, + senha: "senha12345" } usuario = Usuario.create!(defaults.merge(attrs.except(:departamento))) - PerfilAdm.create!(usuario: usuario, departamento: attrs[:departamento]) + PerfilAdm.create!(usuario: usuario, departamento: departamento) usuario end def create_usuario(**attrs) defaults = { nome: "Usuário", - matricula: SecureRandom.hex(4), - status: "ativo" + email: "#{SecureRandom.hex(4)}@example.com", + status: :ativo, + senha: "senha12345" } Usuario.create!(defaults.merge(attrs)) end - def create_template_with_questoes(titulo:, perfil_adm: nil, questoes: nil) - perfil_adm ||= create_admin_usuario.perfil_adm - template = Template.create!(nome: titulo, descricao: "Descrição de teste", perfil_adm: perfil_adm) + def create_template_with_questoes(titulo:, adm: nil, questoes: nil) + adm ||= create_admin_usuario.perfil_adm + template = Template.new(titulo: titulo, descricao: "Descrição de teste", adm: adm, criado_em: Time.current) - (questoes || default_questoes).each do |questao_attrs| + (questoes || default_questoes).each_with_index do |questao_attrs, index| attrs = questao_attrs.dup opcoes = attrs.delete(:opcoes) - questao = template.questoes.create!(attrs) - Array(opcoes).each { |texto| questao.opcoes.create!(texto: texto) } if opcoes.present? + numero = attrs.delete(:numero) || attrs.delete(:posicao) || (index + 1) + attrs.delete(:obrigatoria) + + questao = Questao.new(attrs) + + Array(opcoes).each_with_index do |texto, opcao_index| + questao.opcoes.build(numero: opcao_index + 1, texto: texto) + end if opcoes.present? + + questao.save! + + template.utilizacao_questoes.build(questao: questao, numero: numero) end + template.save! + template.utilizacao_questoes.each(&:save!) template end - def create_turma(nome_materia:, codigo_turma:, semestre: Turma.semestre_atual) + def create_turma(nome_materia:, numero:, semestre: Turma.semestre_atual, departamento: nil, ano: Date.current.year) + departamento ||= Departamento.create!(nome: "DCC #{SecureRandom.hex(2)}") materia = Materia.create!( nome: nome_materia, - codigo: "#{nome_materia.parameterize}-#{SecureRandom.hex(2)}" + codigo: "#{nome_materia.parameterize}-#{SecureRandom.hex(2)}".upcase, + departamento: departamento ) Turma.create!( materia: materia, - codigo_turma: codigo_turma, - semestre: semestre + numero: numero, + semestre: semestre, + ano: ano ) end + def questoes_ordenadas_do_template(template) + template.utilizacao_questoes.raizes.ordenadas.includes(:questao).map(&:questao) + end + private def default_questoes [ { enunciado: "Como você avalia a disciplina?", - tipo: :discursiva, - posicao: 1, - obrigatoria: true + tipo: :discursiva }, { enunciado: "Nota geral", tipo: :objetiva, - posicao: 2, - obrigatoria: true, opcoes: %w[Ruim Regular Boa Excelente] } ] From 4a1fc9b562782a4387ed5076277cb37a195f64ce Mon Sep 17 00:00:00 2001 From: PedroFFranca Date: Mon, 15 Jun 2026 22:44:28 -0300 Subject: [PATCH 045/125] feat (specs): adicionando os tests com rspecs --- spec/models/avaliacao_spec.rb | 57 ++++++++++++ spec/models/formulario_spec.rb | 71 +++++++++++++++ spec/models/participacao_turma_spec.rb | 49 ++++++++++ spec/requests/avaliacoes_spec.rb | 66 ++++++++++++++ spec/requests/formularios_spec.rb | 73 ++++++++++++--- .../formularios/create_from_template_spec.rb | 91 ++++++++++++------- spec/support/test_builders.rb | 39 ++++++++ 7 files changed, 396 insertions(+), 50 deletions(-) create mode 100644 spec/models/avaliacao_spec.rb create mode 100644 spec/models/participacao_turma_spec.rb create mode 100644 spec/requests/avaliacoes_spec.rb diff --git a/spec/models/avaliacao_spec.rb b/spec/models/avaliacao_spec.rb new file mode 100644 index 0000000000..903d774e6b --- /dev/null +++ b/spec/models/avaliacao_spec.rb @@ -0,0 +1,57 @@ +require "rails_helper" + +RSpec.describe Avaliacao, type: :model do + let(:departamento) { Departamento.create!(nome: "DCC #{SecureRandom.hex(2)}") } + let(:admin) { create_admin_usuario(departamento: departamento) } + let(:turma) { create_turma(nome_materia: "MDS", numero: 1, departamento: departamento) } + let(:template) { create_template_with_questoes(titulo: "Avaliação", adm: admin.perfil_adm) } + + describe "validação de público-alvo" do + it "rejeita participação docente em formulário para discentes" do + docente = create_usuario + participacao = create_participacao(usuario: docente, turma: turma, tipo_participacao: :docente) + formulario = create_formulario( + turma: turma, + adm: admin.perfil_adm, + template: template, + publico_alvo: :discentes + ) + + avaliacao = described_class.new(formulario: formulario, participacao_turma: participacao) + + expect(avaliacao).not_to be_valid + expect(avaliacao.errors[:participacao_turma]).to include("não corresponde ao público-alvo do formulário") + end + + it "rejeita participação discente em formulário para docentes" do + discente = create_usuario + participacao = create_participacao(usuario: discente, turma: turma, tipo_participacao: :discente) + formulario = create_formulario( + turma: turma, + adm: admin.perfil_adm, + template: template, + publico_alvo: :docentes + ) + + avaliacao = described_class.new(formulario: formulario, participacao_turma: participacao) + + expect(avaliacao).not_to be_valid + expect(avaliacao.errors[:participacao_turma]).to include("não corresponde ao público-alvo do formulário") + end + + it "aceita participação compatível com o público-alvo" do + docente = create_usuario + participacao = create_participacao(usuario: docente, turma: turma, tipo_participacao: :docente) + formulario = create_formulario( + turma: turma, + adm: admin.perfil_adm, + template: template, + publico_alvo: :docentes + ) + + avaliacao = described_class.new(formulario: formulario, participacao_turma: participacao) + + expect(avaliacao).to be_valid + end + end +end diff --git a/spec/models/formulario_spec.rb b/spec/models/formulario_spec.rb index 48ccb5ff36..6690e6ea5c 100644 --- a/spec/models/formulario_spec.rb +++ b/spec/models/formulario_spec.rb @@ -1,6 +1,11 @@ require "rails_helper" RSpec.describe Formulario, type: :model do + let(:departamento) { Departamento.create!(nome: "DCC #{SecureRandom.hex(2)}") } + let(:admin) { create_admin_usuario(departamento: departamento) } + let(:turma) { create_turma(nome_materia: "MDS", numero: 1, departamento: departamento) } + let(:template) { create_template_with_questoes(titulo: "Avaliação", adm: admin.perfil_adm) } + describe "validações" do it "exige perfil administrativo" do formulario = described_class.new @@ -9,4 +14,70 @@ expect(formulario.errors[:adm]).to include("must exist") end end + + describe "#participacoes_alvo" do + let!(:docente) do + usuario = create_usuario + create_participacao(usuario: usuario, turma: turma, tipo_participacao: :docente) + usuario.participacoes_turma.sole + end + + let!(:discente) do + usuario = create_usuario + create_participacao(usuario: usuario, turma: turma, tipo_participacao: :discente) + usuario.participacoes_turma.sole + end + + it "retorna participações docentes quando público-alvo é docentes" do + formulario = create_formulario(turma: turma, adm: admin.perfil_adm, template: template, publico_alvo: :docentes) + + expect(formulario.participacoes_alvo).to contain_exactly(docente) + end + + it "retorna participações discentes quando público-alvo é discentes" do + formulario = create_formulario(turma: turma, adm: admin.perfil_adm, template: template, publico_alvo: :discentes) + + expect(formulario.participacoes_alvo).to contain_exactly(discente) + end + end + + describe "#criar_avaliacoes_pendentes!" do + let!(:docente) do + usuario = create_usuario + create_participacao(usuario: usuario, turma: turma, tipo_participacao: :docente) + usuario + end + + let!(:discente) do + usuario = create_usuario + create_participacao(usuario: usuario, turma: turma, tipo_participacao: :discente) + usuario + end + + it "cria avaliações pendentes para cada participação do público-alvo" do + formulario = create_formulario(turma: turma, adm: admin.perfil_adm, template: template, publico_alvo: :docentes) + + expect do + formulario.criar_avaliacoes_pendentes! + end.to change(Avaliacao, :count).by(1) + + avaliacao = formulario.avaliacoes.sole + expect(avaliacao).to be_pendente + expect(avaliacao.participacao_turma.usuario).to eq(docente) + end + + it "não duplica avaliações existentes" do + formulario = create_formulario( + turma: turma, + adm: admin.perfil_adm, + template: template, + publico_alvo: :discentes, + criar_avaliacoes: true + ) + + expect do + formulario.criar_avaliacoes_pendentes! + end.not_to change(Avaliacao, :count) + end + end end diff --git a/spec/models/participacao_turma_spec.rb b/spec/models/participacao_turma_spec.rb new file mode 100644 index 0000000000..edbf760ae7 --- /dev/null +++ b/spec/models/participacao_turma_spec.rb @@ -0,0 +1,49 @@ +require "rails_helper" + +RSpec.describe ParticipacaoTurma, type: :model do + let(:departamento) { Departamento.create!(nome: "DCC #{SecureRandom.hex(2)}") } + let(:turma) { create_turma(nome_materia: "MDS", numero: 1, departamento: departamento) } + + describe "#corresponde_ao_publico?" do + it "retorna true para docente quando público-alvo é docentes" do + participacao = create_participacao( + usuario: create_usuario, + turma: turma, + tipo_participacao: :docente + ) + + expect(participacao.corresponde_ao_publico?(:docentes)).to be(true) + expect(participacao.corresponde_ao_publico?("docentes")).to be(true) + end + + it "retorna false para docente quando público-alvo é discentes" do + participacao = create_participacao( + usuario: create_usuario, + turma: turma, + tipo_participacao: :docente + ) + + expect(participacao.corresponde_ao_publico?(:discentes)).to be(false) + end + + it "retorna true para discente quando público-alvo é discentes" do + participacao = create_participacao( + usuario: create_usuario, + turma: turma, + tipo_participacao: :discente + ) + + expect(participacao.corresponde_ao_publico?(:discentes)).to be(true) + end + + it "retorna false para público-alvo inválido" do + participacao = create_participacao( + usuario: create_usuario, + turma: turma, + tipo_participacao: :discente + ) + + expect(participacao.corresponde_ao_publico?(:invalido)).to be(false) + end + end +end diff --git a/spec/requests/avaliacoes_spec.rb b/spec/requests/avaliacoes_spec.rb new file mode 100644 index 0000000000..218111dd10 --- /dev/null +++ b/spec/requests/avaliacoes_spec.rb @@ -0,0 +1,66 @@ +require "rails_helper" + +RSpec.describe "Avaliacoes", type: :request do + let(:departamento) { Departamento.create!(nome: "DCC #{SecureRandom.hex(2)}") } + let(:admin) { create_admin_usuario(departamento: departamento) } + let(:turma) { create_turma(nome_materia: "Cálculo 1", numero: 1, departamento: departamento) } + let(:template) { create_template_with_questoes(titulo: "Avaliação Geral", adm: admin.perfil_adm) } + + describe "GET /avaliacoes/pendentes" do + it "exibe pendências apenas para participantes do público-alvo correto" do + docente = create_usuario(nome: "Docente") + discente = create_usuario(nome: "Discente") + create_participacao(usuario: docente, turma: turma, tipo_participacao: :docente) + create_participacao(usuario: discente, turma: turma, tipo_participacao: :discente) + + create_formulario( + turma: turma, + adm: admin.perfil_adm, + template: template, + publico_alvo: :discentes, + criar_avaliacoes: true + ) + + sign_in_as(discente) + get avaliacoes_pendentes_path + + expect(response).to have_http_status(:ok) + expect(response.body).to include(turma.nome_exibicao) + expect(response.body).to include("Não respondido") + + sign_in_as(docente) + get avaliacoes_pendentes_path + + expect(response.body).not_to include(template.titulo) + expect(response.body).to include("Nenhum formulário pendente foi encontrado") + end + + it "não exibe pendências de turmas em que o usuário não participa" do + participante = create_usuario + outro_usuario = create_usuario + create_participacao(usuario: participante, turma: turma, tipo_participacao: :discente) + + outra_turma = create_turma(nome_materia: "Estrutura de Dados", numero: 1, departamento: departamento) + create_participacao(usuario: outro_usuario, turma: outra_turma, tipo_participacao: :discente) + create_formulario( + turma: outra_turma, + adm: admin.perfil_adm, + template: template, + publico_alvo: :discentes, + criar_avaliacoes: true + ) + + sign_in_as(participante) + get avaliacoes_pendentes_path + + expect(response.body).not_to include(outra_turma.nome_exibicao) + expect(response.body).to include("Nenhum formulário pendente foi encontrado") + end + + it "redireciona usuário não autenticado" do + get avaliacoes_pendentes_path + + expect(response).to redirect_to("/") + end + end +end diff --git a/spec/requests/formularios_spec.rb b/spec/requests/formularios_spec.rb index 09249f0a8b..f6d393a94f 100644 --- a/spec/requests/formularios_spec.rb +++ b/spec/requests/formularios_spec.rb @@ -8,16 +8,53 @@ let(:turma_a) { create_turma(nome_materia: "MDS", numero: 1, departamento: departamento) } let(:turma_b) { create_turma(nome_materia: "IHC", numero: 2, departamento: departamento) } + def preparar_formulario(template_id: template.id, turma_ids: [ turma_a.id, turma_b.id ]) + post preparar_formularios_path, + params: { template_id: template_id, turma_ids: turma_ids } + end + + describe "POST /formularios/preparar" do + it "grava sessão e redireciona para publicação quando dados válidos" do + sign_in_as(admin) + + preparar_formulario + + expect(response).to redirect_to(publicar_formularios_path) + follow_redirect! + expect(response.body).to include("Avaliação Docente") + expect(response.body).to include(turma_a.nome_exibicao) + end + + it "retorna erro quando nenhuma turma é selecionada" do + sign_in_as(admin) + + expect do + preparar_formulario(turma_ids: []) + end.not_to change(Formulario, :count) + + expect(response).to redirect_to(new_formulario_path) + follow_redirect! + expect(response.body).to include("É necessário selecionar pelo menos uma turma") + end + end + + describe "GET /formularios/publicar" do + it "redireciona para new quando sessão está vazia" do + sign_in_as(admin) + + get publicar_formularios_path + + expect(response).to redirect_to(new_formulario_path) + end + end + describe "POST /formularios" do - it "cria formulários para múltiplas turmas quando admin autenticado" do + it "cria formulários após wizard completo com público-alvo" do sign_in_as(admin) + preparar_formulario expect do - post formularios_path, - params: { - template_id: template.id, - turma_ids: [turma_a.id, turma_b.id] - } + post formularios_path, params: { publico_alvo: "docentes" } end.to change(Formulario, :count).by(2) expect(response).to redirect_to(new_formulario_path) @@ -25,27 +62,33 @@ expect(response.body).to include("Formulário criado com sucesso para as turmas selecionadas") end - it "retorna erro quando nenhuma turma é selecionada" do + it "retorna erro quando público-alvo não é informado" do sign_in_as(admin) + preparar_formulario(turma_ids: [ turma_a.id ]) expect do - post formularios_path, - params: { template_id: template.id, turma_ids: [] } + post formularios_path, params: { publico_alvo: "" } end.not_to change(Formulario, :count) + expect(response).to redirect_to(publicar_formularios_path) + follow_redirect! + expect(response.body).to include("Por favor, selecione o público-alvo do formulário") + end + + it "redireciona para new quando sessão está vazia" do + sign_in_as(admin) + + post formularios_path, params: { publico_alvo: "docentes" } + expect(response).to redirect_to(new_formulario_path) follow_redirect! - expect(response.body).to include("É necessário selecionar pelo menos uma turma") + expect(response.body).to include("Selecione um template e as turmas antes de publicar") end it "bloqueia usuário não administrador" do sign_in_as(usuario) - post formularios_path, - params: { - template_id: template.id, - turma_ids: [turma_a.id] - } + post formularios_path, params: { publico_alvo: "docentes" } expect(response).to redirect_to("/") follow_redirect! diff --git a/spec/services/formularios/create_from_template_spec.rb b/spec/services/formularios/create_from_template_spec.rb index 16d274e16c..966efeb146 100644 --- a/spec/services/formularios/create_from_template_spec.rb +++ b/spec/services/formularios/create_from_template_spec.rb @@ -7,14 +7,21 @@ let(:template) { create_template_with_questoes(titulo: "Avaliação Docente", adm: perfil_adm) } let(:turma_a) { create_turma(nome_materia: "MDS", numero: 1, departamento: departamento) } let(:turma_b) { create_turma(nome_materia: "IHC", numero: 2, departamento: departamento) } + let(:publico_alvo) { :docentes } + + def call_service(**overrides) + described_class.call( + template_id: template.id, + turma_ids: [ turma_a.id, turma_b.id ], + publico_alvo: publico_alvo, + perfil_adm: perfil_adm, + **overrides + ) + end describe ".call" do it "cria um formulário por turma selecionada" do - formularios = described_class.call( - template_id: template.id, - turma_ids: [turma_a.id, turma_b.id], - perfil_adm: perfil_adm - ) + formularios = call_service expect(formularios.size).to eq(2) expect(Formulario.count).to eq(2) @@ -23,11 +30,7 @@ end it "copia as questões do template como snapshot independente" do - formularios = described_class.call( - template_id: template.id, - turma_ids: [turma_a.id], - perfil_adm: perfil_adm - ) + formularios = call_service(turma_ids: [ turma_a.id ]) formulario = formularios.first questoes_template = questoes_ordenadas_do_template(template) @@ -47,11 +50,7 @@ end it "copia opções para questões objetivas" do - formularios = described_class.call( - template_id: template.id, - turma_ids: [turma_a.id], - perfil_adm: perfil_adm - ) + formularios = call_service(turma_ids: [ turma_a.id ]) questao_objetiva = formularios.first.questoes.find_by(tipo: :objetiva) expect(questao_objetiva.opcoes.order(:numero).pluck(:texto)).to eq(%w[Ruim Regular Boa Excelente]) @@ -68,11 +67,7 @@ end expect do - described_class.call( - template_id: template.id, - turma_ids: [turma_a.id, turma_b.id], - perfil_adm: perfil_adm - ) + call_service end.to raise_error(ActiveRecord::RecordInvalid) expect(Formulario.count).to eq(0) @@ -82,30 +77,56 @@ it "levanta erro quando nenhuma turma é selecionada" do expect do - described_class.call( - template_id: template.id, - turma_ids: [], - perfil_adm: perfil_adm - ) + call_service(turma_ids: []) end.to raise_error(Formularios::Error, "É necessário selecionar pelo menos uma turma") expect(Formulario.count).to eq(0) end it "levanta erro quando turma já possui formulário" do - described_class.call( - template_id: template.id, - turma_ids: [turma_a.id], - perfil_adm: perfil_adm - ) + call_service(turma_ids: [ turma_a.id ]) expect do - described_class.call( - template_id: template.id, - turma_ids: [turma_a.id], - perfil_adm: perfil_adm - ) + call_service(turma_ids: [ turma_a.id ]) end.to raise_error(Formularios::Error, "Uma ou mais turmas selecionadas já possuem formulário") end + + it "persiste o público-alvo informado" do + formularios = call_service(turma_ids: [ turma_a.id ], publico_alvo: :discentes) + + expect(formularios.first.publico_alvo).to eq("discentes") + end + + it "cria avaliações pendentes para o público-alvo da turma" do + docente = create_usuario(nome: "Docente") + PerfilDocente.create!(usuario: docente, departamento: departamento) + ParticipacaoTurma.create!(usuario: docente, turma: turma_a, tipo_participacao: :docente) + + discente = create_usuario(nome: "Discente") + PerfilDiscente.create!(usuario: discente, matricula: "20260001") + ParticipacaoTurma.create!(usuario: discente, turma: turma_a, tipo_participacao: :discente) + + formularios = call_service(turma_ids: [ turma_a.id ], publico_alvo: :docentes) + + expect(Avaliacao.count).to eq(1) + expect(formularios.first.avaliacoes.sole.participacao_turma).to eq(docente.participacoes_turma.sole) + expect(formularios.first.avaliacoes.sole).to be_pendente + end + + it "levanta erro quando público-alvo não é informado" do + expect do + call_service(turma_ids: [ turma_a.id ], publico_alvo: nil) + end.to raise_error(Formularios::Error, "Por favor, selecione o público-alvo do formulário") + + expect(Formulario.count).to eq(0) + end + + it "levanta erro quando público-alvo é inválido" do + expect do + call_service(turma_ids: [ turma_a.id ], publico_alvo: "invalido") + end.to raise_error(Formularios::Error, "Por favor, selecione o público-alvo do formulário") + + expect(Formulario.count).to eq(0) + end end end diff --git a/spec/support/test_builders.rb b/spec/support/test_builders.rb index f3954ae832..2ee70e1d20 100644 --- a/spec/support/test_builders.rb +++ b/spec/support/test_builders.rb @@ -68,6 +68,45 @@ def questoes_ordenadas_do_template(template) template.utilizacao_questoes.raizes.ordenadas.includes(:questao).map(&:questao) end + def create_perfil_docente(usuario, departamento: nil, **attrs) + departamento ||= Departamento.create!(nome: "DCC #{SecureRandom.hex(2)}") + PerfilDocente.create!(id: usuario.id, usuario: usuario, departamento: departamento, **attrs) + usuario + end + + def create_perfil_discente(usuario, matricula: nil, **attrs) + matricula ||= "2026#{SecureRandom.hex(4)}" + PerfilDiscente.create!(id: usuario.id, usuario: usuario, matricula: matricula, **attrs) + usuario + end + + def create_participacao(usuario:, turma:, tipo_participacao:) + case tipo_participacao.to_sym + when :docente + create_perfil_docente(usuario, departamento: turma.departamento) unless usuario.docente? + when :discente + create_perfil_discente(usuario) unless usuario.discente? + end + + ParticipacaoTurma.create!(usuario: usuario, turma: turma, tipo_participacao: tipo_participacao) + end + + def create_formulario(turma:, adm: nil, template: nil, publico_alvo: :docentes, criar_avaliacoes: false, **attrs) + adm ||= create_admin_usuario(departamento: turma.departamento).perfil_adm + template ||= create_template_with_questoes(titulo: "Formulário de teste", adm: adm) + + formulario = Formulario.create!( + adm: adm, + turma: turma, + template: template, + publico_alvo: publico_alvo, + **attrs + ) + + formulario.criar_avaliacoes_pendentes! if criar_avaliacoes + formulario + end + private def default_questoes From dcf9bd792adef3bd867eabc777f7c79092b9427b Mon Sep 17 00:00:00 2001 From: PedroFFranca Date: Mon, 15 Jun 2026 22:45:26 -0300 Subject: [PATCH 046/125] feat (views): adicionando as views do escolher publico alvo --- app/views/avaliacoes/index.html.erb | 15 +++++++++++ app/views/formularios/new.html.erb | 4 +-- app/views/formularios/publicar.html.erb | 36 +++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 app/views/avaliacoes/index.html.erb create mode 100644 app/views/formularios/publicar.html.erb diff --git a/app/views/avaliacoes/index.html.erb b/app/views/avaliacoes/index.html.erb new file mode 100644 index 0000000000..e47f1cd2ed --- /dev/null +++ b/app/views/avaliacoes/index.html.erb @@ -0,0 +1,15 @@ +

Formulários Pendentes

+ +<% if @pendencias.any? %> +
    + <% @pendencias.each do |pendencia| %> +
  • +

    <%= pendencia.formulario.turma.nome_exibicao %>

    +

    <%= pendencia.formulario.template.titulo %>

    +

    Não respondido

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

Nenhum formulário pendente foi encontrado.

+<% end %> diff --git a/app/views/formularios/new.html.erb b/app/views/formularios/new.html.erb index d22975599e..3cb417b64d 100644 --- a/app/views/formularios/new.html.erb +++ b/app/views/formularios/new.html.erb @@ -8,7 +8,7 @@

<%= flash[:alert] %>

<% end %> -<%= form_with url: formularios_path do |form| %> +<%= form_with url: preparar_formularios_path do |form| %>
<%= select_tag :template_id, @@ -28,5 +28,5 @@ <% end %>
- <%= form.submit "Criar Formulário" %> + <%= form.submit "Continuar" %> <% end %> diff --git a/app/views/formularios/publicar.html.erb b/app/views/formularios/publicar.html.erb new file mode 100644 index 0000000000..f7a54fe7ec --- /dev/null +++ b/app/views/formularios/publicar.html.erb @@ -0,0 +1,36 @@ +

Publicar Formulário

+ +<% if flash[:notice] %> +

<%= flash[:notice] %>

+<% end %> + +<% if flash[:alert] %> +

<%= flash[:alert] %>

+<% end %> + +
+

Resumo

+

Template: <%= @template.titulo %>

+

Turmas:

+
    + <% @turmas.each do |turma| %> +
  • <%= turma.nome_exibicao %>
  • + <% end %> +
+
+ +<%= form_with url: formularios_path do |form| %> +
+

Público-alvo

+ <% Formulario.publico_alvos.each do |valor, _| %> +
+ +
+ <% end %> +
+ + <%= form.submit "Confirmar Publicação" %> +<% end %> From 4d32c37525a7bc5bd00277ae0b072badc52fd5ce Mon Sep 17 00:00:00 2001 From: PedroFFranca Date: Mon, 15 Jun 2026 22:46:06 -0300 Subject: [PATCH 047/125] feat (services): adicionando as services do escolher publico alvo --- .../formularios/create_from_template.rb | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/app/services/formularios/create_from_template.rb b/app/services/formularios/create_from_template.rb index a1ac56f123..65270d60a2 100644 --- a/app/services/formularios/create_from_template.rb +++ b/app/services/formularios/create_from_template.rb @@ -4,22 +4,33 @@ class CreateFromTemplate SEM_QUESTOES = "O template deve possuir pelo menos uma questão" TURMAS_INVALIDAS = "Uma ou mais turmas selecionadas são inválidas" TURMA_COM_FORMULARIO = "Uma ou mais turmas selecionadas já possuem formulário" + SEM_PUBLICO_ALVO = "Por favor, selecione o público-alvo do formulário" - def self.call(template_id:, turma_ids:, perfil_adm:) - new(template_id:, turma_ids:, perfil_adm:).call + def self.call(template_id:, turma_ids:, publico_alvo:, perfil_adm:) + new(template_id:, turma_ids:, publico_alvo:, perfil_adm:).call end - def initialize(template_id:, turma_ids:, perfil_adm:) + def self.validate_preparacao!(template_id:, turma_ids:) + new(template_id:, turma_ids:, publico_alvo: :docentes, perfil_adm: nil).validate_preparacao! + end + + def initialize(template_id:, turma_ids:, publico_alvo:, perfil_adm:) @template_id = template_id @turma_ids = Array(turma_ids).map(&:to_i).uniq.reject(&:zero?) + @publico_alvo = publico_alvo @perfil_adm = perfil_adm end - def call + def validate_preparacao! validate_turma_ids! validate_template! validate_turmas! validate_turmas_sem_formulario! + end + + def call + validate_preparacao! + validate_publico_alvo! formularios = [] @@ -29,10 +40,11 @@ def call adm: perfil_adm, template: template, turma: turma, - publico_alvo: :docentes + publico_alvo: publico_alvo ) copy_questoes_from_template!(formulario) + formulario.criar_avaliacoes_pendentes! formularios << formulario end end @@ -42,7 +54,7 @@ def call private - attr_reader :template_id, :turma_ids, :perfil_adm + attr_reader :template_id, :turma_ids, :publico_alvo, :perfil_adm def validate_turma_ids! raise Error, SEM_TURMAS if turma_ids.empty? @@ -60,6 +72,11 @@ def validate_turmas_sem_formulario! raise Error, TURMA_COM_FORMULARIO if turmas.joins(:formularios).exists? end + def validate_publico_alvo! + raise Error, SEM_PUBLICO_ALVO if publico_alvo.blank? + raise Error, SEM_PUBLICO_ALVO unless Formulario.publico_alvos.key?(publico_alvo.to_s) + end + def template @template ||= Template.find(template_id) end From 1f1f6c26476b1936dbcbc0ddae16689212cf4646 Mon Sep 17 00:00:00 2001 From: PedroFFranca Date: Mon, 15 Jun 2026 22:47:10 -0300 Subject: [PATCH 048/125] feat (bdd): adicionando o step definition do bdd --- features/forms/criar_formulario.feature | 6 +- .../step_definitions/formularios_steps.rb | 90 ++++++++++++++++++- 2 files changed, 93 insertions(+), 3 deletions(-) diff --git a/features/forms/criar_formulario.feature b/features/forms/criar_formulario.feature index 649da5793c..b026a3d8ac 100644 --- a/features/forms/criar_formulario.feature +++ b/features/forms/criar_formulario.feature @@ -14,7 +14,9 @@ Funcionalidade: Criar formulário a partir de template Dado que estou na página de criação de formulários Quando eu seleciono o template "Avaliação de Desempenho Docente" E seleciono as turmas "Métodos de Desenvolvimento de Software - Turma A" e "Interação Humano Computador - Turma B" - E clico em "Criar Formulário" + E clico em "Continuar" + E seleciono a opção de público-alvo como "Docentes" + E confirmo a publicação do formulário Então o formulário deve ser gerado com sucesso para ambas as turmas E devo ver a mensagem "Formulário criado com sucesso para as turmas selecionadas" @@ -23,6 +25,6 @@ Funcionalidade: Criar formulário a partir de template Dado que estou na página de criação de formulários Quando eu seleciono o template "Avaliação de Desempenho Docente" E não seleciono nenhuma turma - E clico em "Criar Formulário" + E clico em "Continuar" Então eu devo ver uma mensagem de erro dizendo "É necessário selecionar pelo menos uma turma" E nenhum formulário deve ser gerado \ No newline at end of file diff --git a/features/step_definitions/formularios_steps.rb b/features/step_definitions/formularios_steps.rb index eb8fd026a2..6abaa5c93f 100644 --- a/features/step_definitions/formularios_steps.rb +++ b/features/step_definitions/formularios_steps.rb @@ -12,6 +12,20 @@ visit new_formulario_path end +Dado("que selecionei o template {string}") do |titulo| + @admin ||= criar_administrador + @template = criar_template_com_questoes(titulo: titulo) +end + +Dado("selecionei a turma {string}") do |nome_turma| + @turma = criar_turma_do_nome_exibicao(nome_turma) + preparar_sessao_formulario(template: @template, turmas: [ @turma ]) +end + +Dado("que estou na etapa de definição de público-alvo") do + visit publicar_formularios_path +end + Quando("eu seleciono o template {string}") do |titulo| select titulo, from: "template_id" end @@ -31,6 +45,24 @@ click_button botao end +Quando("seleciono a opção de público-alvo como {string}") do |publico_alvo| + choose publico_alvo, allow_label_click: true +end + +Quando("eu seleciono a opção de público-alvo como {string}") do |publico_alvo| + choose publico_alvo, allow_label_click: true +end + +Quando("eu não seleciono nem {string} e nem {string}") do |_, _| + page.all('input[name="publico_alvo"]').each do |radio| + radio.set(false) if radio.checked? + end +end + +Quando("confirmo a publicação do formulário") do + click_button "Confirmar Publicação" +end + Então("o formulário deve ser gerado com sucesso para ambas as turmas") do expect(Formulario.count).to eq(2) @@ -44,7 +76,7 @@ questoes_template = questoes_ordenadas_do_template(@template) - [formulario_a, formulario_b].each do |formulario| + [ formulario_a, formulario_b ].each do |formulario| questoes_formulario = formulario.questoes.order(:id) expect(questoes_formulario.count).to eq(questoes_template.count) expect(questoes_formulario.pluck(:enunciado)).to eq(questoes_template.map(&:enunciado)) @@ -59,11 +91,67 @@ expect(page).to have_content(mensagem) end +Então("eu devo ver o alerta {string}") do |mensagem| + expect(page).to have_content(mensagem) +end + Então("nenhum formulário deve ser gerado") do expect(Formulario.count).to eq(0) end +Então("o formulário não deve ser publicado") do + expect(Formulario.count).to eq(0) +end + +Então("o formulário deve ficar disponível apenas para os alunos matriculados na turma {string}") do |nome_turma| + turma = turma_por_referencia(nome_turma) + formulario = turma.reload.formularios.sole + expect(formulario.publico_alvo).to eq("discentes") + + discente = criar_participante + criar_participacao(usuario: discente, turma: turma, tipo_participacao: :discente) + formulario.criar_avaliacoes_pendentes! + + login_como(discente) + visit avaliacoes_pendentes_path + expect(page).to have_content(turma.nome_exibicao) +end + +Então("os docentes da turma não devem ter acesso para responder a este formulário") do + turma = @turma || turma_por_referencia("Estrutura de Dados - Turma C") + docente = criar_participante(tipo: :docente, departamento: turma.departamento) + criar_participacao(usuario: docente, turma: turma, tipo_participacao: :docente) + + login_como(docente) + visit avaliacoes_pendentes_path + expect(page).not_to have_content(@template.titulo) +end + +Então("o formulário deve ficar disponível apenas para os professores vinculados à turma {string}") do |nome_turma| + turma = turma_por_referencia(nome_turma) + formulario = turma.reload.formularios.sole + expect(formulario.publico_alvo).to eq("docentes") + + docente = criar_participante(tipo: :docente, departamento: turma.departamento) + criar_participacao(usuario: docente, turma: turma, tipo_participacao: :docente) + formulario.criar_avaliacoes_pendentes! + + login_como(docente) + visit avaliacoes_pendentes_path + expect(page).to have_content(turma.nome_exibicao) +end + def criar_turma_do_nome_exibicao(nome_exibicao) materia_nome, codigo = nome_exibicao.split(" - Turma ", 2) criar_turma(nome_materia: materia_nome, numero: Turma.numero_de_codigo_exibicao(codigo)) end + +def turma_por_referencia(nome) + return @turma if @turma && nome.include?(@turma.materia.nome) + + if nome.include?(" - Turma ") + criar_turma_do_nome_exibicao(nome) + else + Turma.joins(:materia).find_by!(materias: { nome: nome }) + end +end From 798a53270029f330481d5de50be822c966ab0398 Mon Sep 17 00:00:00 2001 From: PedroFFranca Date: Mon, 15 Jun 2026 22:52:55 -0300 Subject: [PATCH 049/125] feat (controller): adicionando o controler do escolher publico alvo --- app/controllers/formularios_controller.rb | 47 +++++++++++++++++++++-- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/app/controllers/formularios_controller.rb b/app/controllers/formularios_controller.rb index 54532d6a2b..ce11355bd3 100644 --- a/app/controllers/formularios_controller.rb +++ b/app/controllers/formularios_controller.rb @@ -6,16 +6,57 @@ def new @turmas = Turma.do_semestre_atual.sem_formulario.includes(:materia) end + def preparar + Formularios::CreateFromTemplate.validate_preparacao!( + template_id: params[:template_id], + turma_ids: params[:turma_ids] + ) + + session[:formulario_preparacao] = { + "template_id" => params[:template_id].to_i, + "turma_ids" => Array(params[:turma_ids]).map(&:to_i) + } + + redirect_to publicar_formularios_path + rescue Formularios::Error => e + redirect_to new_formulario_path, alert: e.message + end + + def publicar + preparacao = session[:formulario_preparacao] + unless preparacao + redirect_to new_formulario_path + return + end + + @template = Template.find(preparacao["template_id"]) + @turmas = Turma.where(id: preparacao["turma_ids"]).includes(:materia) + end + def create + preparacao = session[:formulario_preparacao] + unless preparacao + redirect_to new_formulario_path, alert: "Selecione um template e as turmas antes de publicar" + return + end + Formularios::CreateFromTemplate.call( - template_id: params[:template_id], - turma_ids: params[:turma_ids], + template_id: preparacao["template_id"], + turma_ids: preparacao["turma_ids"], + publico_alvo: params[:publico_alvo], perfil_adm: current_usuario.perfil_adm ) + session.delete(:formulario_preparacao) + redirect_to new_formulario_path, notice: "Formulário criado com sucesso para as turmas selecionadas" rescue Formularios::Error => e - redirect_to new_formulario_path, alert: e.message + if e.message == Formularios::CreateFromTemplate::SEM_PUBLICO_ALVO + redirect_to publicar_formularios_path, alert: e.message + else + session.delete(:formulario_preparacao) + redirect_to new_formulario_path, alert: e.message + end end end From 50df036d716847aa5f439a53b069d462f689af56 Mon Sep 17 00:00:00 2001 From: PedroFFranca Date: Mon, 15 Jun 2026 22:53:18 -0300 Subject: [PATCH 050/125] feat (route): adicionando a rota do escolher publico alvo --- config/routes.rb | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/config/routes.rb b/config/routes.rb index 2bb7396755..8bb78e7bc6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -12,5 +12,12 @@ # Defines the root path route ("/") # root "posts#index" - resources :formularios, only: %i[new create] + resources :formularios, only: %i[new create] do + collection do + post :preparar + get :publicar + end + end + + get "avaliacoes/pendentes", to: "avaliacoes#index", as: :avaliacoes_pendentes end From 903c176b5ac5f9a7fe6b3fff5d3a9b140c9343be Mon Sep 17 00:00:00 2001 From: PedroFFranca Date: Mon, 15 Jun 2026 23:24:49 -0300 Subject: [PATCH 051/125] feat (controller): adicionando o visualizar formularios --- app/controllers/formularios_controller.rb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/controllers/formularios_controller.rb b/app/controllers/formularios_controller.rb index ce11355bd3..796bbf9d5f 100644 --- a/app/controllers/formularios_controller.rb +++ b/app/controllers/formularios_controller.rb @@ -1,6 +1,14 @@ class FormulariosController < ApplicationController before_action :require_admin! + def index + @formularios = Formulario + .do_departamento(current_usuario.perfil_adm.departamento) + .do_semestre_atual + .recentes + .includes(:template, turma: :materia) + end + def new @templates = Template.all @turmas = Turma.do_semestre_atual.sem_formulario.includes(:materia) From c891e8e7b9cf10e4ad405706d67af45afce32010 Mon Sep 17 00:00:00 2001 From: PedroFFranca Date: Mon, 15 Jun 2026 23:25:26 -0300 Subject: [PATCH 052/125] fix (models): ajustando o models formularios --- app/models/formulario.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/models/formulario.rb b/app/models/formulario.rb index d12660984d..c3bd955a3c 100644 --- a/app/models/formulario.rb +++ b/app/models/formulario.rb @@ -45,6 +45,10 @@ class Formulario < ApplicationRecord .where(materias: { departamento_id: departamento.id }) } + scope :do_semestre_atual, -> { + joins(:turma).merge(Turma.do_semestre_atual) + } + def participacoes_alvo return turma.participantes_docentes if docentes? return turma.participantes_discentes if discentes? From 5675afe903f94a8e400702ba5ab033e01fabf6aa Mon Sep 17 00:00:00 2001 From: Marcio Vieira Date: Mon, 15 Jun 2026 23:25:39 -0300 Subject: [PATCH 053/125] =?UTF-8?q?test(hu14):=20adiciona=20cen=C3=A1rios?= =?UTF-8?q?=20BDD=20para=20resposta=20de=20question=C3=A1rio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/controllers/avaliacoes_controller.rb | 23 ++++---- .../responder_avaliacao_steps.rb | 57 ++++++++++++++----- 2 files changed, 56 insertions(+), 24 deletions(-) diff --git a/app/controllers/avaliacoes_controller.rb b/app/controllers/avaliacoes_controller.rb index e85d3633ca..1ca88d2187 100644 --- a/app/controllers/avaliacoes_controller.rb +++ b/app/controllers/avaliacoes_controller.rb @@ -1,9 +1,9 @@ class AvaliacoesController < ApplicationController before_action :require_login - before_action :set_avaliacao, only: %i[responder submeter] # ← linha nova + before_action :set_avaliacao, only: %i[responder submeter] + # GET /avaliacoes/pendentes def pendentes - # ← sem alteração, exatamente como está na HU13 turmas_ids = ParticipacaoTurma .where(usuario: current_usuario) .pluck(:turma_id) @@ -15,7 +15,7 @@ def pendentes .includes(formulario: { turma: :materia }) end - # ← tudo abaixo é novo da HU14 + # GET /avaliacoes/:id/responder def responder if @avaliacao.respondida? redirect_to pendentes_avaliacoes_path, @@ -27,6 +27,7 @@ def responder @questoes = questoes_do_formulario end + # POST /avaliacoes/:id/submeter def submeter if @avaliacao.respondida? redirect_to pendentes_avaliacoes_path, @@ -78,9 +79,9 @@ def todas_obrigatorias_preenchidas? resposta = respostas_params[questao.id.to_s] || {} if questao.discursiva? - resposta[:texto].to_s.strip.present? + resposta["texto"].to_s.strip.present? else - Array(resposta[:opcao_id]).any?(&:present?) + Array(resposta["opcao_id"]).any?(&:present?) end end end @@ -92,16 +93,16 @@ def salvar_respostas_e_finalizar @questoes.each do |questao| resposta_data = respostas_params[questao.id.to_s] || {} resposta = Resposta.find_or_initialize_by(avaliacao: @avaliacao, questao: questao) - resposta.save! if questao.discursiva? - Texto.find_or_initialize_by(resposta: resposta) - .update!(texto: resposta_data[:texto].to_s.strip) + resposta.build_texto(texto: resposta_data["texto"].to_s.strip) else - opcao_id = resposta_data[:opcao_id].to_s + opcao_id = resposta_data["opcao_id"].to_s opcao = questao.opcoes.find(opcao_id) - OpcaoEscolhida.find_or_create_by!(resposta: resposta, opcao: opcao) + resposta.opcoes_escolhidas.build(opcao: opcao) end + + resposta.save! end @avaliacao.marcar_como_respondida! @@ -109,7 +110,7 @@ def salvar_respostas_e_finalizar redirect_to pendentes_avaliacoes_path, notice: 'Avaliação registrada com sucesso.' - rescue ActiveRecord::RecordInvalid + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotFound => e flash.now[:alert] = 'Todas as questões obrigatórias devem ser preenchidas.' render :responder, status: :unprocessable_entity end diff --git a/features/step_definitions/responder_avaliacao_steps.rb b/features/step_definitions/responder_avaliacao_steps.rb index fe0578ece7..1c231fd6ff 100644 --- a/features/step_definitions/responder_avaliacao_steps.rb +++ b/features/step_definitions/responder_avaliacao_steps.rb @@ -1,3 +1,7 @@ +# features/step_definitions/responder_avaliacao_steps.rb +# Steps exclusivos da HU14 – Responder questionário da turma +# Steps compartilhados (usuário, autenticação) vêm do avaliacoes_steps.rb + def criar_contexto_formulario_com_questoes(nome_turma) depto = Departamento.find_or_create_by!(nome: 'Departamento Geral') materia = Materia.find_or_create_by!(nome: nome_turma, departamento: depto) do |m| @@ -12,21 +16,35 @@ def criar_contexto_formulario_com_questoes(nome_turma) senha: 'password123', status: :ativo) perf_adm = PerfilAdm.create!(usuario: adm, departamento: depto) + # Questão discursiva @questao_discursiva = Questao.create!( enunciado: 'Como você avalia a turma?', tipo: :discursiva ) + # Questão objetiva com opções via nested attributes @questao_objetiva = Questao.create!( enunciado: 'Qual nota você dá?', - tipo: :objetiva + tipo: :objetiva, + opcoes_attributes: [ + { numero: 1, texto: 'Ótimo' }, + { numero: 2, texto: 'Ruim' } + ] + ) + + # Template criado já com questões via nested attributes + template = Template.create!( + adm: perf_adm, + titulo: "Template #{rand(1000)}", + utilizacao_questoes_attributes: [ + { questao_id: @questao_discursiva.id, numero: 1 }, + { questao_id: @questao_objetiva.id, numero: 2 } + ] ) - Opcao.create!(questao: @questao_objetiva, numero: 1, texto: 'Ótimo') - Opcao.create!(questao: @questao_objetiva, numero: 2, texto: 'Ruim') - template = Template.create!(adm: perf_adm, titulo: "Template #{rand(1000)}") - UtilizacaoQuestao.create!(template: template, questao: @questao_discursiva, numero: 1) - UtilizacaoQuestao.create!(template: template, questao: @questao_objetiva, numero: 2) + # Recarrega para garantir associações atualizadas + @questao_discursiva.reload + @questao_objetiva.reload @formulario = Formulario.create!( adm: perf_adm, @@ -41,6 +59,10 @@ def criar_contexto_formulario_com_questoes(nome_turma) @avaliacao = Avaliacao.create!(formulario: @formulario, participacao_turma: participacao) end +# ------------------------------------------------------- +# Dado +# ------------------------------------------------------- + Dado('que estou na página de resposta do formulário da turma {string}') do |nome_turma| criar_contexto_formulario_com_questoes(nome_turma) visit responder_avaliacao_path(@avaliacao) @@ -51,18 +73,23 @@ def criar_contexto_formulario_com_questoes(nome_turma) @avaliacao.marcar_como_respondida! end +# ------------------------------------------------------- +# Quando +# ------------------------------------------------------- + Quando('eu preencho todas as questões obrigatórias') do - find("textarea[name='respostas[#{@questao_discursiva.id}][texto]']") - .set('Achei a turma muito boa.') + # Preenche a questão discursiva + fill_in "respostas[#{@questao_discursiva.id}][texto]", with: 'Achei a turma muito boa.' + # Seleciona a primeira opção da questão objetiva usando o texto da label primeira_opcao = @questao_objetiva.opcoes.ordenadas.first - find("input[name='respostas[#{@questao_objetiva.id}][opcao_id]'][value='#{primeira_opcao.id}']") - .set(true) + within "#questao-#{@questao_objetiva.id}" do + choose primeira_opcao.texto + end end Quando('eu deixo uma questão obrigatória em branco') do - find("textarea[name='respostas[#{@questao_discursiva.id}][texto]']") - .set('Resposta parcial.') + fill_in "respostas[#{@questao_discursiva.id}][texto]", with: 'Resposta parcial.' # questão objetiva intencionalmente deixada em branco end @@ -74,6 +101,10 @@ def criar_contexto_formulario_com_questoes(nome_turma) visit responder_avaliacao_path(@avaliacao) end +# ------------------------------------------------------- +# Então +# ------------------------------------------------------- + Então('devo ver uma mensagem informando que a avaliação foi registrada com sucesso') do expect(page).to have_content('Avaliação registrada com sucesso.') end @@ -93,4 +124,4 @@ def criar_contexto_formulario_com_questoes(nome_turma) Então('devo ver uma mensagem informando que esta avaliação já foi respondida') do expect(page).to have_content('Esta avaliação já foi respondida.') -end +end \ No newline at end of file From b09e55ba1f46336d0016f8408d7104ef517b0840 Mon Sep 17 00:00:00 2001 From: PedroFFranca Date: Mon, 15 Jun 2026 23:26:00 -0300 Subject: [PATCH 054/125] feat (views): adicionando a view para visualizar formularios --- app/views/formularios/index.html.erb | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 app/views/formularios/index.html.erb diff --git a/app/views/formularios/index.html.erb b/app/views/formularios/index.html.erb new file mode 100644 index 0000000000..2ce0699bb1 --- /dev/null +++ b/app/views/formularios/index.html.erb @@ -0,0 +1,28 @@ +

Gerenciamento de Formulários

+ +<% if @formularios.any? %> +
<%= avaliacao.formulario.turma.materia.nome %> Não respondido<%= link_to 'Responder', '#' %><%= link_to 'Responder', responder_avaliacao_path(avaliacao) %>
+ + + + + + + + + + <% @formularios.each do |formulario| %> + + + + + + + <% end %> + +
TemplateTurmaPúblico-alvoAção
<%= formulario.template.titulo %><%= formulario.turma.nome_exibicao %><%= formulario.docentes? ? "Docentes" : "Discentes" %> + <%= button_to "Gerar Relatório de Respostas", "#", method: :get %> +
+<% else %> +

Nenhum formulário criado para este semestre

+<% end %> From c6c3fce54413864150e27a271e4c2b41a78fe109 Mon Sep 17 00:00:00 2001 From: PedroFFranca Date: Mon, 15 Jun 2026 23:26:21 -0300 Subject: [PATCH 055/125] feat (routes): adicionando a rota para visualizar formularios --- config/routes.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/routes.rb b/config/routes.rb index 8bb78e7bc6..d1adc420c4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -12,7 +12,7 @@ # Defines the root path route ("/") # root "posts#index" - resources :formularios, only: %i[new create] do + resources :formularios, only: %i[index new create] do collection do post :preparar get :publicar From 453991f96f660d000e716d6150c127d9a46bb16a Mon Sep 17 00:00:00 2001 From: PedroFFranca Date: Mon, 15 Jun 2026 23:27:02 -0300 Subject: [PATCH 056/125] feat (bdd): adicionando os steps do formulario --- .../step_definitions/formularios_steps.rb | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/features/step_definitions/formularios_steps.rb b/features/step_definitions/formularios_steps.rb index 6abaa5c93f..1192932e2a 100644 --- a/features/step_definitions/formularios_steps.rb +++ b/features/step_definitions/formularios_steps.rb @@ -1,3 +1,26 @@ +Dado("que existem formulários criados para o semestre atual") do + @admin ||= criar_administrador + @template = criar_template_com_questoes(titulo: "Avaliação de Disciplina") + @turma_a = criar_turma(nome_materia: "Estrutura de Dados", numero: 1) + @turma_b = criar_turma(nome_materia: "Banco de Dados", numero: 2) + @formularios = [ + criar_formulario_publicado(turma: @turma_a, template: @template, publico_alvo: :docentes), + criar_formulario_publicado(turma: @turma_b, template: @template, publico_alvo: :discentes) + ] +end + +Dado("que nenhum formulário foi gerado para o semestre vigente") do + @admin ||= criar_administrador + turma_passado = criar_turma( + nome_materia: "Disciplina Anterior", + numero: 1, + ano: Date.current.year - 1, + semestre: :segundo + ) + criar_formulario_publicado(turma: turma_passado, publico_alvo: :docentes) + expect(Formulario.do_semestre_atual.count).to eq(0) +end + Dado("que existe um template cadastrado chamado {string}") do |titulo| @admin ||= criar_administrador @template = criar_template_com_questoes(titulo: titulo) @@ -63,6 +86,14 @@ click_button "Confirmar Publicação" end +Quando("eu acesso o painel de gerenciamento de formulários") do + visit formularios_path +end + +Quando("eu acesso a página de formulários criados") do + visit formularios_path +end + Então("o formulário deve ser gerado com sucesso para ambas as turmas") do expect(Formulario.count).to eq(2) @@ -83,6 +114,29 @@ end end +Então("eu devo ver uma lista com todos os formulários criados, exibindo o template base, a turma e o público-alvo de cada um") do + expect(page).to have_css("table tbody tr", count: @formularios.size) + + @formularios.each do |formulario| + expect(page).to have_content(formulario.template.titulo) + expect(page).to have_content(formulario.turma.nome_exibicao) + publico_label = formulario.docentes? ? "Docentes" : "Discentes" + expect(page).to have_content(publico_label) + end +end + +Então("cada formulário listado deve exibir um botão {string}") do |texto_botao| + expect(page).to have_button(texto_botao, count: @formularios.size) +end + +Então("eu devo ver a listagem vazia") do + expect(page).not_to have_css("table tbody tr") +end + +Então("a mensagem {string} deve ser exibida na tela") do |mensagem| + expect(page).to have_content(mensagem) +end + Então("devo ver a mensagem {string}") do |mensagem| expect(page).to have_content(mensagem) end @@ -141,6 +195,11 @@ expect(page).to have_content(turma.nome_exibicao) end +def criar_formulario_publicado(turma:, template: nil, publico_alvo: :docentes) + template ||= criar_template_com_questoes(titulo: "Avaliação de teste") + Formulario.create!(adm: @admin.perfil_adm, turma: turma, template: template, publico_alvo: publico_alvo) +end + def criar_turma_do_nome_exibicao(nome_exibicao) materia_nome, codigo = nome_exibicao.split(" - Turma ", 2) criar_turma(nome_materia: materia_nome, numero: Turma.numero_de_codigo_exibicao(codigo)) From 940d82a0100235a5453880af263b80ed448aa7e0 Mon Sep 17 00:00:00 2001 From: PedroFFranca Date: Mon, 15 Jun 2026 23:27:47 -0300 Subject: [PATCH 057/125] feat (spec): adicionando as specs para o rspec --- spec/requests/formularios_spec.rb | 98 +++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/spec/requests/formularios_spec.rb b/spec/requests/formularios_spec.rb index f6d393a94f..141f68a0c1 100644 --- a/spec/requests/formularios_spec.rb +++ b/spec/requests/formularios_spec.rb @@ -13,6 +13,104 @@ def preparar_formulario(template_id: template.id, turma_ids: [ turma_a.id, turma params: { template_id: template_id, turma_ids: turma_ids } end + describe "GET /formularios" do + it "exibe formulários do departamento e semestre atual para admin" do + formulario_a = create_formulario( + turma: turma_a, + adm: admin.perfil_adm, + template: template, + publico_alvo: :docentes + ) + formulario_b = create_formulario( + turma: turma_b, + adm: admin.perfil_adm, + template: template, + publico_alvo: :discentes + ) + + sign_in_as(admin) + get formularios_path + + expect(response).to have_http_status(:ok) + expect(response.body).to include(formulario_a.template.titulo) + expect(response.body).to include(formulario_a.turma.nome_exibicao) + expect(response.body).to include("Docentes") + expect(response.body).to include(formulario_b.turma.nome_exibicao) + expect(response.body).to include("Discentes") + expect(response.body).to include("Gerar Relatório de Respostas") + end + + it "exibe mensagem quando não há formulários no semestre atual" do + sign_in_as(admin) + get formularios_path + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Nenhum formulário criado para este semestre") + end + + it "não exibe formulários de outro departamento" do + outro_departamento = Departamento.create!(nome: "DCC #{SecureRandom.hex(2)}") + outra_turma = create_turma(nome_materia: "OUT", numero: 1, departamento: outro_departamento) + outro_admin = create_admin_usuario(departamento: outro_departamento) + outro_template = create_template_with_questoes(titulo: "Formulário Externo", adm: outro_admin.perfil_adm) + create_formulario( + turma: outra_turma, + adm: outro_admin.perfil_adm, + template: outro_template + ) + + formulario_local = create_formulario( + turma: turma_a, + adm: admin.perfil_adm, + template: template + ) + + sign_in_as(admin) + get formularios_path + + expect(response.body).to include(formulario_local.template.titulo) + expect(response.body).not_to include("Formulário Externo") + end + + it "não exibe formulários de semestres anteriores" do + turma_passada = create_turma( + nome_materia: "LEG", + numero: 3, + departamento: departamento, + ano: Date.current.year - 1, + semestre: :segundo + ) + create_formulario( + turma: turma_passada, + adm: admin.perfil_adm, + template: template, + publico_alvo: :docentes + ) + + formulario_atual = create_formulario( + turma: turma_a, + adm: admin.perfil_adm, + template: template + ) + + sign_in_as(admin) + get formularios_path + + expect(response.body).to include(formulario_atual.turma.nome_exibicao) + expect(response.body).not_to include(turma_passada.nome_exibicao) + end + + it "bloqueia usuário não administrador" do + sign_in_as(usuario) + + get formularios_path + + expect(response).to redirect_to("/") + follow_redirect! + expect(response.body).to include("Acesso não autorizado") + end + end + describe "POST /formularios/preparar" do it "grava sessão e redireciona para publicação quando dados válidos" do sign_in_as(admin) From 384d8608fcfe041d04bf5ca96b526c45454e2ddc Mon Sep 17 00:00:00 2001 From: Marcio Vieira Date: Mon, 15 Jun 2026 23:35:51 -0300 Subject: [PATCH 058/125] feat(HU15): implementa geracao e download de relatorio CSV de respostas --- app/controllers/formularios_controller.rb | 36 +++++++++++++++++++++++ app/views/formularios/show.html.erb | 1 + config/routes.rb | 7 ++++- 3 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 app/controllers/formularios_controller.rb create mode 100644 app/views/formularios/show.html.erb diff --git a/app/controllers/formularios_controller.rb b/app/controllers/formularios_controller.rb new file mode 100644 index 0000000000..f42e29bfe1 --- /dev/null +++ b/app/controllers/formularios_controller.rb @@ -0,0 +1,36 @@ +require 'csv' + +class FormulariosController < ApplicationController + before_action :require_login + # ... (seus outros métodos como index, show) + + def exportar_csv + @formulario = Formulario.find(params[:id]) + + # Bloqueio de segurança: Apenas o admin responsável pode baixar + unless current_usuario.perfil_adm && current_usuario.perfil_adm.departamento_id == @formulario.turma.materia.departamento_id + return redirect_to root_path, alert: 'Acesso negado.' + end + + avaliacoes = @formulario.avaliacoes.where(status: 'respondida') + .includes(participacao_turma: { usuario: :perfil_discente }, respostas: { questao: :opcoes }) + + csv_data = CSV.generate(headers: true, col_sep: ';') do |csv| + csv << ['Aluno', 'Matricula', 'Questao', 'Resposta'] + + avaliacoes.each do |avaliacao| + usuario = avaliacao.participacao_turma.usuario + matricula = usuario.perfil_discente&.matricula || 'N/A' + + avaliacao.respostas.each do |resposta| + texto_resposta = resposta.questao.discursiva? ? resposta.texto : resposta.opcoes_escolhidas.map { |oe| oe.opcao.texto }.join(', ') + csv << [usuario.nome, matricula, resposta.questao.enunciado, texto_resposta] + end + end + end + + send_data csv_data, + filename: "resultados_turma_#{@formulario.turma.materia.codigo}_#{Date.today}.csv", + type: 'text/csv; charset=utf-8' + end +end \ No newline at end of file diff --git a/app/views/formularios/show.html.erb b/app/views/formularios/show.html.erb new file mode 100644 index 0000000000..4ead86b3f6 --- /dev/null +++ b/app/views/formularios/show.html.erb @@ -0,0 +1 @@ +<%= link_to 'Exportar CSV', exportar_csv_formulario_path(@formulario), class: 'btn btn-primary' %> \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 363fbd4c7d..22a615abcb 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -5,9 +5,14 @@ collection do get 'pendentes' end - member do # ← apenas isso é novo + member do get 'responder' post 'submeter' end end + resources :formularios do + member do + get 'exportar_csv' + end + end end \ No newline at end of file From 01e71c44e6fd287a1e1b0425c2eb3e6468c9efec Mon Sep 17 00:00:00 2001 From: Marcio Vieira Date: Tue, 16 Jun 2026 00:07:44 -0300 Subject: [PATCH 059/125] test(HU15): ajusta cenarios bdd para exportacao de relatorios e adiciona gem csv --- Gemfile | 2 + Gemfile.lock | 3 + app/controllers/formularios_controller.rb | 44 ++++-- .../step_definitions/exportar_csv_steps.rb | 139 ++++++++++++++++++ 4 files changed, 177 insertions(+), 11 deletions(-) create mode 100644 features/step_definitions/exportar_csv_steps.rb diff --git a/Gemfile b/Gemfile index 33943730cf..ec6ec9d0a7 100644 --- a/Gemfile +++ b/Gemfile @@ -17,6 +17,8 @@ gem "stimulus-rails" # Build JSON APIs with ease [https://github.com/rails/jbuilder] gem "jbuilder" +gem 'csv' + # Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] gem "bcrypt", "~> 3.1.7" diff --git a/Gemfile.lock b/Gemfile.lock index 495b24a63d..b2705ccecd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -118,6 +118,7 @@ GEM concurrent-ruby (1.3.6) connection_pool (3.0.2) crass (1.0.6) + csv (3.3.5) cucumber (10.2.0) base64 (~> 0.2) builder (~> 3.2) @@ -563,6 +564,7 @@ DEPENDENCIES brakeman bundler-audit capybara + csv cucumber-rails database_cleaner-active_record debug @@ -622,6 +624,7 @@ CHECKSUMS concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d + csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f cucumber (10.2.0) sha256=fdedbd31ecf40858b60f04853f2aa15c44f5c30bbac29c6a227fa1e7005a8158 cucumber-ci-environment (11.0.0) sha256=0df79a9e1d0b015b3d9def680f989200d96fef206f4d19ccf86a338c4f71d1e2 cucumber-core (16.2.0) sha256=592b58a95cf42feef8e5a349f68e363784ba3b6568ffbcf6776e38e136cf970b diff --git a/app/controllers/formularios_controller.rb b/app/controllers/formularios_controller.rb index f42e29bfe1..c1ea0a2960 100644 --- a/app/controllers/formularios_controller.rb +++ b/app/controllers/formularios_controller.rb @@ -2,30 +2,46 @@ class FormulariosController < ApplicationController before_action :require_login - # ... (seus outros métodos como index, show) def exportar_csv @formulario = Formulario.find(params[:id]) - # Bloqueio de segurança: Apenas o admin responsável pode baixar - unless current_usuario.perfil_adm && current_usuario.perfil_adm.departamento_id == @formulario.turma.materia.departamento_id - return redirect_to root_path, alert: 'Acesso negado.' + unless current_usuario&.perfil_adm + redirect_to pendentes_avaliacoes_path, alert: 'Apenas administradores possuem acesso a este recurso' + return end - avaliacoes = @formulario.avaliacoes.where(status: 'respondida') - .includes(participacao_turma: { usuario: :perfil_discente }, respostas: { questao: :opcoes }) + avaliacoes = @formulario.avaliacoes + .joins(:respostas) + .distinct + .includes(participacao_turma: { usuario: :perfil_discente }, + respostas: [ :questao, :texto, { opcoes_escolhidas: :opcao } ]) + + questoes = @formulario.template.questoes.order('utilizacoes_questoes.numero') csv_data = CSV.generate(headers: true, col_sep: ';') do |csv| - csv << ['Aluno', 'Matricula', 'Questao', 'Resposta'] + cabecalho = ['Aluno', 'Matrícula'] + questoes.map(&:enunciado) + csv << cabecalho avaliacoes.each do |avaliacao| usuario = avaliacao.participacao_turma.usuario matricula = usuario.perfil_discente&.matricula || 'N/A' - - avaliacao.respostas.each do |resposta| - texto_resposta = resposta.questao.discursiva? ? resposta.texto : resposta.opcoes_escolhidas.map { |oe| oe.opcao.texto }.join(', ') - csv << [usuario.nome, matricula, resposta.questao.enunciado, texto_resposta] + + linha = [usuario.nome, matricula] + + questoes.each do |questao| + resposta = avaliacao.respostas.find { |r| r.questao_id == questao.id } + + if resposta.nil? + linha << 'Sem resposta' + elsif questao.discursiva? + linha << resposta.texto&.texto.to_s.strip # Busca o texto dentro do objeto Texto + else + linha << resposta.opcoes_escolhidas.map { |oe| oe.opcao.texto }.join(', ') + end end + + csv << linha end end @@ -33,4 +49,10 @@ def exportar_csv filename: "resultados_turma_#{@formulario.turma.materia.codigo}_#{Date.today}.csv", type: 'text/csv; charset=utf-8' end + + private + + def require_login + redirect_to "/", alert: 'Usuário não autenticado' unless current_usuario + end end \ No newline at end of file diff --git a/features/step_definitions/exportar_csv_steps.rb b/features/step_definitions/exportar_csv_steps.rb new file mode 100644 index 0000000000..facee3322d --- /dev/null +++ b/features/step_definitions/exportar_csv_steps.rb @@ -0,0 +1,139 @@ +# features/step_definitions/exportar_csv_steps.rb + +# ------------------------------------------------------- +# Contexto e Setup +# ------------------------------------------------------- + +Dado('que existe um usuário administrador cadastrado no sistema') do + # Apenas garante que o mock funcionará, a criação real ocorre no próximo step +end + +Dado('que estou autenticado como administrador do {string}') do |nome_depto| + @depto = Departamento.find_or_create_by!(nome: nome_depto) + @admin = Usuario.find_or_create_by!(email: "adm_#{nome_depto.parameterize}@teste.com") do |u| + u.nome = 'Admin do Depto' + u.senha = 'password123' + u.status = :ativo + end + PerfilAdm.find_or_create_by!(usuario: @admin, departamento: @depto) + + allow_any_instance_of(ApplicationController) + .to receive(:current_usuario) + .and_return(@admin) +end + +# ------------------------------------------------------- +# Cenário 1: Formulário com Respostas (@happy) +# ------------------------------------------------------- + +Dado('que existe um formulário com respostas para a turma {string}') do |nome_turma| + materia = Materia.find_or_create_by!(nome: nome_turma, departamento: @depto) { |m| m.codigo = "COD#{rand(1000)}" } + @turma = Turma.find_or_create_by!(materia: materia, ano: 2026, semestre: :primeiro) { |t| t.numero = 1 } + + @questao = Questao.create!(enunciado: 'Avalie o professor', tipo: :discursiva) + + template = Template.create!( + adm: @admin.perfil_adm, + titulo: "Template Padrão", + utilizacao_questoes_attributes: [ + { questao_id: @questao.id, numero: 1 } + ] + ) + + @formulario = Formulario.create!(adm: @admin.perfil_adm, turma: @turma, publico_alvo: :discentes, template: template) + + aluno = Usuario.create!(nome: 'João Respondedor', email: "joao#{rand(1000)}@teste.com", senha: 'password123', status: :ativo) + PerfilDiscente.create!(usuario: aluno, matricula: "MAT#{rand(1000)}") + part = ParticipacaoTurma.create!(usuario: aluno, turma: @turma, tipo_participacao: :discente) + + avaliacao = Avaliacao.create!(formulario: @formulario, participacao_turma: part) + avaliacao.marcar_como_respondida! + + + resposta = Resposta.new(avaliacao: avaliacao, questao: @questao) + resposta.build_texto(texto: "Ótima aula!") + resposta.save!(validate: false) +end + +Quando('eu acesso a página de relatórios do meu departamento') do +end + +Quando('solicito a exportação do formulário da turma {string}') do |_nome_turma| + visit exportar_csv_formulario_path(@formulario) +end + +Então('o download do arquivo CSV deve ser iniciado') do + expect(page.response_headers['Content-Type']).to include 'text/csv' + expect(page.response_headers['Content-Disposition']).to include 'attachment' +end + +Então('o CSV deve conter os dados esperados das avaliações') do + expect(page.body).to include('João Respondedor') + expect(page.body).to include('Avalie o professor') + expect(page.body).to include('Ótima aula!') +end + +# ------------------------------------------------------- +# Cenário 2: Formulário sem Respostas (@happy) +# ------------------------------------------------------- + +Dado('que existe um formulário sem respostas para a turma {string} do meu departamento') do |nome_turma| + materia = Materia.find_or_create_by!(nome: nome_turma, departamento: @depto) { |m| m.codigo = "COD#{rand(1000)}" } + @turma = Turma.find_or_create_by!(materia: materia, ano: 2026, semestre: :primeiro) { |t| t.numero = 1 } + + @questao = Questao.create!(enunciado: 'Avalie a infraestrutura', tipo: :discursiva) + + template = Template.create!( + adm: @admin.perfil_adm, + titulo: "Template Vazio", + utilizacao_questoes_attributes: [ + { questao_id: @questao.id, numero: 1 } + ] + ) + + @formulario_vazio = Formulario.create!(adm: @admin.perfil_adm, turma: @turma, publico_alvo: :discentes, template: template) +end + +Quando('eu solicito a exportação do formulário da turma {string}') do |_nome_turma| + visit exportar_csv_formulario_path(@formulario_vazio) +end + +Então('o arquivo CSV deve conter apenas a linha de cabeçalho') do + linhas = page.body.split("\n") + expect(linhas.size).to eq(1) + expect(linhas.first).to include('Aluno;Matrícula;Avalie a infraestrutura') +end + +# ------------------------------------------------------- +# Cenário 3: Tentativa de Acesso por Não-Administrador (@sad) +# ------------------------------------------------------- + +Quando('eu tento acessar a rota de exportação de resultados em CSV') do + depto = Departamento.find_or_create_by!(nome: 'Depto Teste') + materia = Materia.find_or_create_by!(nome: 'Materia', departamento: depto) { |m| m.codigo = "MAT001" } + turma = Turma.find_or_create_by!(materia: materia, ano: 2026, semestre: :primeiro) { |t| t.numero = 1 } + + admin_dono = Usuario.find_or_create_by!(email: "dono#{rand(1000)}@t.com") do |u| + u.nome = 'Dono' + u.senha = 'password123' + u.status = :ativo + end + perf = PerfilAdm.create!(usuario: admin_dono, departamento: depto) + + questao = Questao.create!(enunciado: 'Questao Sad', tipo: :discursiva) + template = Template.create!( + adm: perf, + titulo: "Template Sad", + utilizacao_questoes_attributes: [ + { questao_id: questao.id, numero: 1 } + ] + ) + + form = Formulario.create!(adm: perf, turma: turma, publico_alvo: :discentes, template: template) + + visit exportar_csv_formulario_path(form) +end + +Então('devo ver uma mensagem informando que apenas administradores possuem acesso a este recurso') do + expect(page).to have_content('Apenas administradores possuem acesso a este recurso') +end \ No newline at end of file From 74d285b2d745f44fc361d76df9cd64f4695aefd2 Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Tue, 16 Jun 2026 14:29:29 -0300 Subject: [PATCH 060/125] =?UTF-8?q?feat(templates):=20adiciona=20a=20featu?= =?UTF-8?q?re=20de=20cria=C3=A7=C3=A3o=20de=20template?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/controllers/templates_controller.rb | 67 +++++++++++++++++++++++ app/models/questao.rb | 4 +- app/models/template.rb | 20 ++++++- app/models/utilizacao_questao.rb | 2 + app/views/templates/_form.html.erb | 73 +++++++++++++++++++++++++ app/views/templates/new.html.erb | 3 + config/routes.rb | 6 ++ 7 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 app/controllers/templates_controller.rb create mode 100644 app/views/templates/_form.html.erb create mode 100644 app/views/templates/new.html.erb diff --git a/app/controllers/templates_controller.rb b/app/controllers/templates_controller.rb new file mode 100644 index 0000000000..51599043ce --- /dev/null +++ b/app/controllers/templates_controller.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +class TemplatesController < ApplicationController + before_action :authenticate_usuario! + + def new + @template = Template.new(adm: current_adm) + preparar_campos_do_formulario + + authorize! @template + end + + def create + @template = Template.new(template_params) + @template.adm = current_adm + + authorize! @template + + if @template.save + redirect_to @template, notice: "Template criado com sucesso." + else + preparar_campos_do_formulario + + render :new, status: :unprocessable_entity + end + end + + private + + def preparar_campos_do_formulario + utilizacoes = @template.utilizacao_questoes + utilizacoes.build(numero: 1) if utilizacoes.empty? + + utilizacoes.each do |utilizacao| + utilizacao.build_questao(tipo: :discursiva) if utilizacao.questao.blank? + + 4.times { utilizacao.questao.opcoes.build } + end + end + + def template_params + params + .require(:template) + .permit( + :titulo, + :descricao, + utilizacoes_questao_attributes: [ + :id, + :questao_id, + :numero, + :parent_id, + :_destroy, + questao_attributes: [ + :id, + :enunciado, + :tipo, + opcoes_attributes: [ + :id, + :texto, + :numero, + :_destroy + ] + ] + ] + ) + end +end diff --git a/app/models/questao.rb b/app/models/questao.rb index 3713fef555..65f2ac57ec 100644 --- a/app/models/questao.rb +++ b/app/models/questao.rb @@ -24,7 +24,9 @@ class Questao < ApplicationRecord inverse_of: :questao, dependent: :restrict_with_error - accepts_nested_attributes_for :opcoes, allow_destroy: true + accepts_nested_attributes_for :opcoes, + allow_destroy: true, + reject_if: :all_blank before_validation :normalizar_enunciado diff --git a/app/models/template.rb b/app/models/template.rb index fe41a072cf..4001a79e7e 100644 --- a/app/models/template.rb +++ b/app/models/template.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Template < ApplicationRecord belongs_to :adm, class_name: "PerfilAdm", @@ -18,7 +20,9 @@ class Template < ApplicationRecord inverse_of: :template, dependent: :nullify - accepts_nested_attributes_for :utilizacao_questoes, allow_destroy: true + accepts_nested_attributes_for :utilizacao_questoes, + allow_destroy: true, + reject_if: :utilizacao_questao_em_branco? before_validation :normalizar_titulo before_validation :preencher_criado_em @@ -64,4 +68,18 @@ def deve_ter_ao_menos_uma_questao errors.add(:utilizacao_questoes, "deve conter ao menos uma questão") end + + def utilizacao_questao_em_branco?(attributes) + return false if attributes["questao_id"].present? + + questao_attributes = attributes["questao_attributes"] || {} + return false if questao_attributes["enunciado"].present? + + opcoes_attributes = questao_attributes["opcoes_attributes"] || {} + opcoes = opcoes_attributes.respond_to?(:values) ? opcoes_attributes.values : opcoes_attributes + + opcoes.none? do |opcao_attributes| + opcao_attributes["texto"].present? + end + end end diff --git a/app/models/utilizacao_questao.rb b/app/models/utilizacao_questao.rb index 8f02172526..a1434df677 100644 --- a/app/models/utilizacao_questao.rb +++ b/app/models/utilizacao_questao.rb @@ -23,6 +23,8 @@ class UtilizacaoQuestao < ApplicationRecord has_many :opcoes, through: :questao, source: :opcoes + accepts_nested_attributes_for :questao + before_validation :herdar_template_do_parent validates :template, presence: true diff --git a/app/views/templates/_form.html.erb b/app/views/templates/_form.html.erb new file mode 100644 index 0000000000..e54a2d2f46 --- /dev/null +++ b/app/views/templates/_form.html.erb @@ -0,0 +1,73 @@ +<%= form_with model: @template do |f| %> + <% if @template.errors.any? %> +
+

Erros:

+
    + <% @template.errors.full_messages.each do |msg| %> +
  • <%= msg %>
  • + <% end %> +
+
+ <% end %> + +
+ <%= f.label :titulo %> + <%= f.text_field :titulo %> +
+ +
+ <%= f.label :descricao %> + <%= f.text_area :descricao %> +
+ +
+ +

Questões

+ + <%= f.fields_for :utilizacao_questoes do |utilizacao_form| %> +
+ <%= utilizacao_form.hidden_field :parent_id %> + +
+ <%= utilizacao_form.label :numero, "Ordem" %> + <%= utilizacao_form.number_field :numero, min: 1 %> +
+ + <%= utilizacao_form.fields_for :questao do |questao_form| %> +
+ <%= questao_form.label :enunciado %> + <%= questao_form.text_area :enunciado %> +
+ +
+ <%= questao_form.label :tipo %> + <%= questao_form.select( + :tipo, + [["Discursiva", "discursiva"], ["Objetiva", "objetiva"]] + ) %> +
+ +

Opções

+ + <%= questao_form.fields_for :opcoes do |opcao_form| %> +
+ <%= opcao_form.label :texto %> + <%= opcao_form.text_field :texto %> + + <%= opcao_form.label :numero %> + <%= opcao_form.number_field :numero, min: 1 %> +
+ <% end %> + <% end %> + +
+ <%= utilizacao_form.check_box :_destroy %> + <%= utilizacao_form.label :_destroy, "Remover questão" %> +
+
+ <% end %> + +
+ + <%= f.submit %> +<% end %> diff --git a/app/views/templates/new.html.erb b/app/views/templates/new.html.erb new file mode 100644 index 0000000000..e2a233c3ef --- /dev/null +++ b/app/views/templates/new.html.erb @@ -0,0 +1,3 @@ +

Novo Template

+ +<%= render "form", template: @template %> diff --git a/config/routes.rb b/config/routes.rb index 48254e88ed..1c1b986336 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,10 @@ +# frozen_string_literal: true + Rails.application.routes.draw do + root "templates#index" + + resources :templates + # 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. From 4b8876f15994cf1fe6bbdecac699198bf2570fa2 Mon Sep 17 00:00:00 2001 From: Rafael-Sapienza Date: Tue, 16 Jun 2026 15:38:31 -0300 Subject: [PATCH 061/125] =?UTF-8?q?feat(auth):=20adicionar=20funcionalidad?= =?UTF-8?q?e=20de=20login=20de=20usu=C3=A1rio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Gemfile | 2 + Gemfile.lock | 3 + app/assets/stylesheets/dashboard.css | 191 ++++++++++++++++++ app/controllers/application_controller.rb | 9 +- app/controllers/auth_controller.rb | 40 ++++ app/controllers/dashboard_controller.rb | 32 +++ app/models/departamento.rb | 5 + app/models/materia.rb | 4 + app/models/participacao_turma.rb | 4 + app/models/perfil_adm.rb | 15 ++ app/models/perfil_discente.rb | 9 + app/models/perfil_docente.rb | 11 + app/models/token.rb | 9 + app/models/turma.rb | 7 + app/models/usuario.rb | 31 +++ app/views/auth/index.html.erb | 53 +++++ app/views/dashboard/gerenciamento.html.erb | 66 ++++++ app/views/dashboard/index.html.erb | 63 ++++++ .../dashboard_shared/_sidebar.html.erb | 13 ++ config/initializers/inflections.rb | 23 +-- config/routes.rb | 18 +- db/dados_iniciais.json | 99 +++++++++ ...iar_estrutura_inicial_para_autenticacao.rb | 70 +++++++ db/schema.rb | 69 ++++++- db/seeds.rb | 94 ++++++++- 25 files changed, 899 insertions(+), 41 deletions(-) create mode 100644 app/assets/stylesheets/dashboard.css create mode 100644 app/controllers/auth_controller.rb create mode 100644 app/controllers/dashboard_controller.rb create mode 100644 app/models/departamento.rb create mode 100644 app/models/materia.rb create mode 100644 app/models/participacao_turma.rb create mode 100644 app/models/perfil_adm.rb create mode 100644 app/models/perfil_discente.rb create mode 100644 app/models/perfil_docente.rb create mode 100644 app/models/token.rb create mode 100644 app/models/turma.rb create mode 100644 app/models/usuario.rb create mode 100644 app/views/auth/index.html.erb create mode 100644 app/views/dashboard/gerenciamento.html.erb create mode 100644 app/views/dashboard/index.html.erb create mode 100644 app/views/layouts/dashboard_shared/_sidebar.html.erb create mode 100644 db/dados_iniciais.json create mode 100644 db/migrate/20260616180200_criar_estrutura_inicial_para_autenticacao.rb diff --git a/Gemfile b/Gemfile index a7cf68ece5..1226052f67 100644 --- a/Gemfile +++ b/Gemfile @@ -71,3 +71,5 @@ group :test do gem "simplecov", require: false gem "database_cleaner-active_record" end + +gem "bcrypt", "~> 3.1.7" diff --git a/Gemfile.lock b/Gemfile.lock index 71e9211154..7cc708ad59 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -83,6 +83,7 @@ GEM ice_nine (~> 0.11.0) thread_safe (~> 0.3, >= 0.3.1) base64 (0.3.0) + bcrypt (3.1.22) bcrypt_pbkdf (1.1.2) bigdecimal (4.1.2) bindex (0.8.1) @@ -530,6 +531,7 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES + bcrypt (~> 3.1.7) bootsnap brakeman bundler-audit @@ -577,6 +579,7 @@ CHECKSUMS ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 axiom-types (0.1.1) sha256=c1ff113f3de516fa195b2db7e0a9a95fd1b08475a502ff660d04507a09980383 base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + bcrypt (3.1.22) sha256=1f0072e88c2d705d94aff7f2c5cb02eb3f1ec4b8368671e19112527489f29032 bcrypt_pbkdf (1.1.2) sha256=c2414c23ce66869b3eb9f643d6a3374d8322dfb5078125c82792304c10b94cf6 bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e diff --git a/app/assets/stylesheets/dashboard.css b/app/assets/stylesheets/dashboard.css new file mode 100644 index 0000000000..36195cc4ea --- /dev/null +++ b/app/assets/stylesheets/dashboard.css @@ -0,0 +1,191 @@ +.dashboard-container { + display: flex; + height: 100vh; + background-color: #E0E0E0; +} + +.sidebar { + width: 240px; + background-color: #FFFFFF; + border-right: 1px solid #DDD; +} + +.sidebar-menu { + list-style: none; + padding: 0; + margin-top: 20px; +} + +.sidebar-menu li a { + display: block; + padding: 15px 20px; + color: #333; + text-decoration: none; +} + +.sidebar-menu li.active { + background-color: #6A1B60; +} + +.sidebar-menu li.active a { + color: #FFF; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 20px; + background: #FFF; +} + +.hamburger-btn { + font-size: 24px; + background: none; + border: none; + cursor: pointer; +} + +.search-container input { + padding: 8px 15px; + border-radius: 20px; + border: 1px solid #CCC; + width: 250px; +} + +.user-avatar { + background: #6A1B60; + color: #FFF; + width: 35px; + height: 35px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +.main-content { + flex: 1; + display: flex; + flex-direction: column; +} + +.cards-grid { + display: flex; + flex-wrap: wrap; + gap: 20px; + padding: 30px; +} + +.card-materia { + background: #FFF; + border-radius: 8px; + padding: 20px; + width: 260px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); +} + +.management-panel { + flex: 1; + display: flex; + align-items: center; + justify-content: center; +} + +.center-card { + background: #FFF; + padding: 40px; + border-radius: 8px; + display: flex; + flex-direction: column; + gap: 15px; + width: 400px; +} + +.btn-action { + padding: 12px; + text-align: center; + border-radius: 6px; + text-decoration: none; + font-weight: 500; +} + +.btn-primary { + background-color: #10B981; + color: white; +} + +.btn-secondary { + background-color: #6EE7B7; + color: #065F46; +} + +.user-dropdown { + position: relative; + cursor: pointer; +} + +.user-dropdown summary::-webkit-details-marker { + display: none; +} + +.user-dropdown summary { + list-style: none; + outline: none; +} + +.user-dropdown .dropdown-menu { + position: absolute; + top: 50px; + right: 0; + background-color: #ffffff; + min-width: 240px; + box-shadow: 0px 8px 16px rgba(0, 0, 0, 0.15); + border-radius: 8px; + padding: 12px; + z-index: 1000; + border: 1px solid #e0e0e0; + cursor: default; +} + +.dropdown-info p { + margin: 0; + padding: 4px 0; + font-family: sans-serif; + font-size: 14px; + color: #333333; +} + +.dropdown-info .user-name { + font-size: 15px; + color: #111111; + word-break: break-word; +} + +.dropdown-info .user-detail { + font-size: 13px; + color: #666666; +} + +.dropdown-divider { + height: 1px; + background-color: #e0e0e0; + margin: 8px 0; +} + +.logout-btn { + display: block; + text-align: center; + background-color: #dc3545; + color: white !important; + text-decoration: none; + padding: 8px; + border-radius: 4px; + font-size: 14px; + font-weight: bold; + transition: background-color 0.2s; +} + +.logout-btn:hover { + background-color: #bd2130; +} \ No newline at end of file diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index c3537563da..707f55b41d 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,7 +1,10 @@ class ApplicationController < ActionController::Base - # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. allow_browser versions: :modern - - # Changes to the importmap will invalidate the etag for HTML responses stale_when_importmap_changes + + helper_method :current_user + + def current_user + @current_user ||= Usuario.find_by(id: session[:usuario_id]) if session[:usuario_id] + end end diff --git a/app/controllers/auth_controller.rb b/app/controllers/auth_controller.rb new file mode 100644 index 0000000000..790aca2b11 --- /dev/null +++ b/app/controllers/auth_controller.rb @@ -0,0 +1,40 @@ +require "net/http" +require "uri" +require "json" + +class AuthController < ApplicationController + def index + if current_user.present? + redirect_to avaliacoes_path, flash: { notice: "Você já está conectado no sistema. Se quiser sair, faça log out" } and return + end + end + + def logout + session.clear + @current_user = nil + redirect_to root_path, flash: { success: "Sessão encerrada com sucesso." } + end + + def login + identificador = params[:identificador].to_s.strip + senha = params[:senha] + email_regex = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i + if identificador.match?(email_regex) + usuario = Usuario.find_by(email: identificador) + else + usuario = Usuario.find_by(matricula: identificador) + end + if usuario.nil? + redirect_to root_path, flash: { error: "Matrícula ou e-mail inválido." } and return + end + if usuario.status != 1 + redirect_to root_path, flash: { error: "Esta conta ainda não foi ativada. Por favor, realize o Primeiro Acesso." } and return + end + if usuario.authenticate_senha(senha) + session[:usuario_id] = usuario.id + redirect_to avaliacoes_path, flash: { success: "Login realizado com sucesso! Seja bem-vindo." } + else + redirect_to root_path, flash: { error: "Senha incorreta." } + end + end +end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb new file mode 100644 index 0000000000..e3fcd87ff0 --- /dev/null +++ b/app/controllers/dashboard_controller.rb @@ -0,0 +1,32 @@ +require "json" + + +class DashboardController < ApplicationController + before_action :verificar_admin, only: [ :gerenciamento ] + + def index + if current_user.nil? + redirect_to root_path, flash: { error: "Acesso restrito. Por favor, faça login para continuar." } and return + end + @turmas_simuladas = [ + { materia: "Estruturas de Dados", semestre: "2026.1", professor: "Alessandro Silva" }, + { materia: "Bancos de Dados", semestre: "2026.1", professor: "Alessandro Silva" }, + { materia: "Cálculo 1", semestre: "2026.1", professor: "Maria Carmo" }, + { materia: "Compiladores", semestre: "2026.1", professor: "A definir" }, + { materia: "Sistemas Operacionais", semestre: "2026.1", professor: "A definir" } + ] + end + + def gerenciamento + end + + private + + def verificar_admin + if current_user.nil? || !current_user.admin? + session.clear + @current_user = nil + redirect_to root_path, flash: { error: "Acesso restrito. Por favor, faça login como administrador." } + end + end +end diff --git a/app/models/departamento.rb b/app/models/departamento.rb new file mode 100644 index 0000000000..afe46cc891 --- /dev/null +++ b/app/models/departamento.rb @@ -0,0 +1,5 @@ +class Departamento < ApplicationRecord + has_many :perfis_adm + has_many :perfis_docente + has_many :materias, dependent: :destroy +end diff --git a/app/models/materia.rb b/app/models/materia.rb new file mode 100644 index 0000000000..885b125107 --- /dev/null +++ b/app/models/materia.rb @@ -0,0 +1,4 @@ +class Materia < ApplicationRecord + belongs_to :departamento + has_many :turmas, dependent: :destroy +end diff --git a/app/models/participacao_turma.rb b/app/models/participacao_turma.rb new file mode 100644 index 0000000000..114b101ba8 --- /dev/null +++ b/app/models/participacao_turma.rb @@ -0,0 +1,4 @@ +class ParticipacaoTurma < ApplicationRecord + belongs_to :usuario + belongs_to :turma +end diff --git a/app/models/perfil_adm.rb b/app/models/perfil_adm.rb new file mode 100644 index 0000000000..3f741450dd --- /dev/null +++ b/app/models/perfil_adm.rb @@ -0,0 +1,15 @@ +# app/models/perfil_adm.rb +class PerfilAdm < ApplicationRecord + self.primary_key = :id + + belongs_to :usuario, + class_name: "Usuario", + foreign_key: :id, + inverse_of: :perfil_adm + + belongs_to :departamento + + def admin? + true + end +end diff --git a/app/models/perfil_discente.rb b/app/models/perfil_discente.rb new file mode 100644 index 0000000000..120235f604 --- /dev/null +++ b/app/models/perfil_discente.rb @@ -0,0 +1,9 @@ +# app/models/perfil_discente.rb +class PerfilDiscente < ApplicationRecord + self.primary_key = :id + + belongs_to :usuario, + class_name: "Usuario", + foreign_key: :id, + inverse_of: :perfil_discente +end diff --git a/app/models/perfil_docente.rb b/app/models/perfil_docente.rb new file mode 100644 index 0000000000..59b7a10c87 --- /dev/null +++ b/app/models/perfil_docente.rb @@ -0,0 +1,11 @@ +# app/models/perfil_docente.rb +class PerfilDocente < ApplicationRecord + self.primary_key = :id + + belongs_to :usuario, + class_name: "Usuario", + foreign_key: :id, + inverse_of: :perfil_docente + + belongs_to :departamento +end diff --git a/app/models/token.rb b/app/models/token.rb new file mode 100644 index 0000000000..ff7afefb46 --- /dev/null +++ b/app/models/token.rb @@ -0,0 +1,9 @@ +class Token < ApplicationRecord + # Diz para o Rails buscar o usuário usando a coluna 'matricula_aluno' contra a 'matricula' do Usuário + belongs_to :usuario, foreign_key: :matricula_aluno, primary_key: :matricula + + # Um método helper simples para checar se o token já venceu + def expirado? + expires_at < Time.current + end +end diff --git a/app/models/turma.rb b/app/models/turma.rb new file mode 100644 index 0000000000..5298e15396 --- /dev/null +++ b/app/models/turma.rb @@ -0,0 +1,7 @@ +class Turma < ApplicationRecord + belongs_to :materia + + # Relacionamento de muitos-para-muitos com Usuários + has_many :participacoes_turma, dependent: :destroy + has_many :usuarios, through: :participacoes_turma +end diff --git a/app/models/usuario.rb b/app/models/usuario.rb new file mode 100644 index 0000000000..418c2174b5 --- /dev/null +++ b/app/models/usuario.rb @@ -0,0 +1,31 @@ +# app/models/usuario.rb +class Usuario < ApplicationRecord + has_secure_password :senha, validations: false + + has_many :tokens, + foreign_key: :matricula_aluno, + primary_key: :matricula, + dependent: :delete_all + + # ↓ foreign_key: :id diz ao Rails para buscar WHERE perfis_adm.id = usuarios.id + has_one :perfil_adm, + foreign_key: :id, + dependent: :destroy + + has_one :perfil_docente, + foreign_key: :id, + dependent: :destroy + + has_one :perfil_discente, + foreign_key: :id, + dependent: :destroy + + has_many :participacoes_turma, dependent: :destroy + has_many :turmas, through: :participacoes_turma + + validates :matricula, presence: true, uniqueness: true + + def admin? + perfil_adm.present? + end +end diff --git a/app/views/auth/index.html.erb b/app/views/auth/index.html.erb new file mode 100644 index 0000000000..ccfcbe156e --- /dev/null +++ b/app/views/auth/index.html.erb @@ -0,0 +1,53 @@ +<% if flash[:success] %> +
+ <%= flash[:success]&.html_safe %> +
+<% end %> + +<% if flash[:error] %> +
+ <%= flash[:error] %> +
+<% end %> + +
+ + + +
+

Bem-vindo ao CAMAAR

+

+ O sistema de gerenciamento e acompanhamento de atividades acadêmicas. Acesse sua conta ao lado para entrar no sistema. +

+
+ +
\ No newline at end of file diff --git a/app/views/dashboard/gerenciamento.html.erb b/app/views/dashboard/gerenciamento.html.erb new file mode 100644 index 0000000000..2c9474e375 --- /dev/null +++ b/app/views/dashboard/gerenciamento.html.erb @@ -0,0 +1,66 @@ +<% if flash[:success] %> +
+ <%= flash[:success]&.html_safe %> +
+<% end %> + +<% if flash[:error] %> +
+
+ ⚠️ + <%= flash[:error] %> +
+
+<% end %> + +<% if flash[:notice] %> +
+ <%= flash[:notice] %> +
+<% end %> + +<% content_for :head, stylesheet_link_tag("dashboard", "data-turbo-track": "reload") %> + +
+ <%= render 'layouts/dashboard_shared/sidebar' %> + +
+
+ +
+ +
+ +
+ + <%= current_user&.nome&.first&.upcase || "U" %> + + + +
+
+ +
+
+ <%# Desativado temporariamente para a feature_login: reativar na branch de importação %> + <%#= link_to "Importar dados", importar_dados_path, data: { turbo_method: :post }, class: "btn-action btn-primary" %> + + <%# Desativado temporariamente para a feature_login: reativar na branch de envio de solicitações %> + <%#= link_to "Enviar Solicitação de Cadastro (E-mail)", enviar_solicitacoes_path, data: { turbo_method: :post }, class: "btn-action btn-secondary" %> + + <%= link_to "Editar Templates", "#", class: "btn-action btn-secondary" %> + <%= link_to "Resultados", "#", class: "btn-action btn-secondary" %> +
+
+ +
+
\ No newline at end of file diff --git a/app/views/dashboard/index.html.erb b/app/views/dashboard/index.html.erb new file mode 100644 index 0000000000..dc4e9a3994 --- /dev/null +++ b/app/views/dashboard/index.html.erb @@ -0,0 +1,63 @@ +<% if flash[:success] %> +
+ <%= flash[:success]&.html_safe %> +
+<% end %> + +<% if flash[:error] %> +
+ <%= flash[:error] %> +
+<% end %> + +<% if flash[:notice] %> +
+ <%= flash[:notice] %> +
+<% end %> + +<% content_for :head, stylesheet_link_tag("dashboard", "data-turbo-track": "reload") %> + +
+ <%= render 'layouts/dashboard_shared/sidebar' %> + +
+
+ +
+ +
+ +
+ + <%= current_user&.nome&.first&.upcase || "U" %> + + + +
+
+ +
+ <% if @turmas_simuladas.present? %> + <% @turmas_simuladas.each do |turma| %> +
+

<%= turma[:materia] %>

+ <%= turma[:semestre] %> +

<%= turma[:professor] %>

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

Nenhuma turma encontrada para este usuário.

+ <% end %> +
+
+
\ No newline at end of file diff --git a/app/views/layouts/dashboard_shared/_sidebar.html.erb b/app/views/layouts/dashboard_shared/_sidebar.html.erb new file mode 100644 index 0000000000..7153b12e76 --- /dev/null +++ b/app/views/layouts/dashboard_shared/_sidebar.html.erb @@ -0,0 +1,13 @@ + \ No newline at end of file diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index 3860f659ea..ed5f43a88d 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -1,16 +1,7 @@ -# 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 - -# These inflection rules are supported but not enabled by default: -# ActiveSupport::Inflector.inflections(:en) do |inflect| -# inflect.acronym "RESTful" -# end +ActiveSupport::Inflector.inflections(:en) do |inflect| + inflect.irregular "perfil_adm", "perfis_adm" + inflect.irregular "perfil_docente", "perfis_docente" + inflect.irregular "perfil_discente", "perfis_discente" + inflect.irregular "materia", "materias" + inflect.irregular "participacao_turma", "participacoes_turma" +end diff --git a/config/routes.rb b/config/routes.rb index 48254e88ed..215b9b2040 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,14 +1,8 @@ Rails.application.routes.draw do - # 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 - - # Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb) - # get "manifest" => "rails/pwa#manifest", as: :pwa_manifest - # get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker - - # Defines the root path route ("/") - # root "posts#index" + root "auth#index" + get "/", to: "auth#index", as: :login + post "/", to: "auth#login" + delete "/logout", to: "auth#logout", as: :logout + get "/avaliacoes", to: "dashboard#index", as: :avaliacoes + get "/gerenciamento", to: "dashboard#gerenciamento", as: :gerenciamento end diff --git a/db/dados_iniciais.json b/db/dados_iniciais.json new file mode 100644 index 0000000000..9947134479 --- /dev/null +++ b/db/dados_iniciais.json @@ -0,0 +1,99 @@ +{ + "departamentos": [ + { + "id_temporario": 1, + "nome": "Departamento de Informática" + }, + { + "id_temporario": 2, + "nome": "Departamento de Matemática" + } + ], + "materias": [ + { + "codigo": "CIC0001", + "nome": "Estruturas de Dados", + "departamento_id_temp": 1 + }, + { + "codigo": "CIC0002", + "nome": "Bancos de Dados", + "departamento_id_temp": 1 + }, + { + "codigo": "MAT0001", + "nome": "Cálculo 1", + "departamento_id_temp": 2 + } + ], + "turmas": [ + { + "numero": 1, + "ano": 2026, + "semestre": 1, + "materia_codigo": "CIC0001" + }, + { + "numero": 2, + "ano": 2026, + "semestre": 1, + "materia_codigo": "CIC0001" + }, + { + "numero": 3, + "ano": 2026, + "semestre": 1, + "materia_codigo": "CIC0001" + }, + { + "numero": 1, + "ano": 2026, + "semestre": 1, + "materia_codigo": "CIC0002" + }, + { + "numero": 2, + "ano": 2026, + "semestre": 1, + "materia_codigo": "CIC0002" + }, + { + "numero": 1, + "ano": 2026, + "semestre": 1, + "materia_codigo": "MAT0001" + }, + { + "numero": 2, + "ano": 2026, + "semestre": 1, + "materia_codigo": "MAT0001" + } + ], + "usuarios_admin": [ + { + "matricula": "123456", + "nome": "Alessandro Silva", + "email": "alessandro.admin@camaar.edu", + "password": "senhaSeguraAdmin1", + "status": 1, + "departamento_id_temp": 1, + "perfil": "docente" + }, + { + "matricula": "789012", + "nome": "Maria Carmo", + "email": "maria.admin@camaar.edu", + "password": "senhaSeguraAdmin2", + "status": 1, + "departamento_id_temp": 2, + "perfil": "discente", + "turmas_matriculadas": [ + { + "materia_codigo": "CIC0001", + "numero_turma": 1 + } + ] + } + ] +} \ No newline at end of file diff --git a/db/migrate/20260616180200_criar_estrutura_inicial_para_autenticacao.rb b/db/migrate/20260616180200_criar_estrutura_inicial_para_autenticacao.rb new file mode 100644 index 0000000000..40870afcfe --- /dev/null +++ b/db/migrate/20260616180200_criar_estrutura_inicial_para_autenticacao.rb @@ -0,0 +1,70 @@ +class CriarEstruturaInicialParaAutenticacao < ActiveRecord::Migration[8.1] + def change + drop_table :participacoes_turma, if_exists: true + drop_table :perfis_discente, if_exists: true + drop_table :perfis_docente, if_exists: true + drop_table :turmas, if_exists: true + drop_table :materias, if_exists: true + drop_table :perfis_adm, if_exists: true + drop_table :tokens, if_exists: true + drop_table :departamentos, if_exists: true + drop_table :usuarios, if_exists: true + + create_table :usuarios do |t| + t.string :matricula, null: false + t.string :email + t.string :senha_digest + t.integer :status, default: 0, null: false + t.string :nome + end + add_index :usuarios, :matricula, unique: true + + create_table :departamentos do |t| + t.string :nome + end + + create_table :tokens do |t| + t.string :value, null: false + t.string :tipo, null: false + t.datetime :expires_at, null: false + t.string :matricula_aluno, null: false + t.timestamps + end + add_index :tokens, :value, unique: true + + create_table :perfis_adm, id: false do |t| + t.primary_key :id + t.references :departamento, null: false, foreign_key: true + end + add_foreign_key :perfis_adm, :usuarios, column: :id + + create_table :perfis_docente, id: false do |t| + t.primary_key :id + t.references :departamento, null: false, foreign_key: true + end + add_foreign_key :perfis_docente, :usuarios, column: :id + + create_table :perfis_discente, id: false do |t| + t.primary_key :id + end + add_foreign_key :perfis_discente, :usuarios, column: :id + + create_table :materias do |t| + t.string :codigo + t.string :nome + t.references :departamento, null: false, foreign_key: true + end + + create_table :turmas do |t| + t.integer :numero + t.integer :ano + t.integer :semestre + t.references :materia, null: false, foreign_key: true + end + + create_table :participacoes_turma do |t| + t.references :usuario, null: false, foreign_key: true + t.references :turma, null: false, foreign_key: true + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 03e73681a8..5ae8884262 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,5 +10,72 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 0) do +ActiveRecord::Schema[8.1].define(version: 2026_06_16_180200) do + create_table "departamentos", force: :cascade do |t| + t.string "nome" + end + + create_table "materias", force: :cascade do |t| + t.string "codigo" + t.integer "departamento_id", null: false + t.string "nome" + t.index ["departamento_id"], name: "index_materias_on_departamento_id" + end + + create_table "participacoes_turma", force: :cascade do |t| + t.integer "turma_id", null: false + t.integer "usuario_id", null: false + t.index ["turma_id"], name: "index_participacoes_turma_on_turma_id" + t.index ["usuario_id"], name: "index_participacoes_turma_on_usuario_id" + end + + create_table "perfis_adm", force: :cascade do |t| + t.integer "departamento_id", null: false + t.index ["departamento_id"], name: "index_perfis_adm_on_departamento_id" + end + + create_table "perfis_discente", force: :cascade do |t| + end + + create_table "perfis_docente", force: :cascade do |t| + t.integer "departamento_id", null: false + t.index ["departamento_id"], name: "index_perfis_docente_on_departamento_id" + end + + create_table "tokens", force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "expires_at", null: false + t.string "matricula_aluno", null: false + t.string "tipo", null: false + t.datetime "updated_at", null: false + t.string "value", null: false + t.index ["value"], name: "index_tokens_on_value", unique: true + end + + create_table "turmas", force: :cascade do |t| + t.integer "ano" + t.integer "materia_id", null: false + t.integer "numero" + t.integer "semestre" + t.index ["materia_id"], name: "index_turmas_on_materia_id" + end + + create_table "usuarios", force: :cascade do |t| + t.string "email" + t.string "matricula", null: false + t.string "nome" + t.string "senha_digest" + t.integer "status", default: 0, null: false + t.index ["matricula"], name: "index_usuarios_on_matricula", unique: true + end + + add_foreign_key "materias", "departamentos" + add_foreign_key "participacoes_turma", "turmas" + add_foreign_key "participacoes_turma", "usuarios" + add_foreign_key "perfis_adm", "departamentos" + add_foreign_key "perfis_adm", "usuarios", column: "id" + add_foreign_key "perfis_discente", "usuarios", column: "id" + add_foreign_key "perfis_docente", "departamentos" + add_foreign_key "perfis_docente", "usuarios", column: "id" + add_foreign_key "turmas", "materias" end diff --git a/db/seeds.rb b/db/seeds.rb index 4fbd6ed970..73681b63ef 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,9 +1,85 @@ -# 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 +require 'json' + +arquivo = File.read(Rails.root.join('db', 'dados_iniciais.json')) +dados = JSON.parse(arquivo) + +dept_mapeamento = {} + +puts "Semeando Departamentos..." +dados['departamentos'].each do |dept_json| + dept_banco = Departamento.find_or_create_by!(nome: dept_json['nome']) + dept_mapeamento[dept_json['id_temporario']] = dept_banco.id +end + +puts "Semeando Matérias..." +dados['materias'].each do |mat_json| + id_real = dept_mapeamento[mat_json['departamento_id_temp']] + Materia.find_or_create_by!(codigo: mat_json['codigo']) do |m| + m.nome = mat_json['nome'] + m.departamento_id = id_real + end +end + +puts "Semeando Turmas..." +dados['turmas'].each do |turma_json| + materia = Materia.find_by(codigo: turma_json['materia_codigo']) + if materia.nil? + puts "⚠️ Matéria #{turma_json['materia_codigo']} não encontrada." + next + end + Turma.find_or_create_by!( + numero: turma_json['numero'], + ano: turma_json['ano'], + semestre: turma_json['semestre'], + materia_id: materia.id + ) +end + +puts "Semeando Administradores..." +dados['usuarios_admin'].each do |admin_json| + id_real = dept_mapeamento[admin_json['departamento_id_temp']] + + usuario = Usuario.find_or_create_by!(matricula: admin_json['matricula']) do |u| + u.nome = admin_json['nome'] + u.email = admin_json['email'] + u.senha = admin_json['password'] + u.senha_confirmation = admin_json['password'] + u.status = admin_json['status'] + end + + # PerfilAdm — sempre criado, sempre com departamento + PerfilAdm.find_or_create_by!(id: usuario.id) do |p| + p.departamento_id = id_real + end + + case admin_json['perfil'] + when 'docente' + # Departamento vai para o PerfilDocente + PerfilDocente.find_or_create_by!(id: usuario.id) do |p| + p.departamento_id = id_real + end + + when 'discente' + # PerfilDiscente não recebe departamento + PerfilDiscente.find_or_create_by!(id: usuario.id) + + # Matrícula nas turmas — obrigatório, erro encerra o seed se falhar + turmas_json = admin_json['turmas_matriculadas'] || [] + raise "Admin discente #{admin_json['matricula']} não tem turmas_matriculadas definidas." if turmas_json.empty? + + turmas_json.each do |mat_json| + materia = Materia.find_by(codigo: mat_json['materia_codigo']) + raise "Matéria '#{mat_json['materia_codigo']}' não encontrada para o admin #{admin_json['matricula']}." if materia.nil? + + turma = Turma.find_by(materia_id: materia.id, numero: mat_json['numero_turma'], ano: 2026, semestre: 1) + raise "Turma nº #{mat_json['numero_turma']} da matéria '#{materia.nome}' não encontrada para o admin #{admin_json['matricula']}." if turma.nil? + + ParticipacaoTurma.find_or_create_by!(usuario_id: usuario.id, turma_id: turma.id) + end + + else + raise "Admin #{admin_json['matricula']} tem perfil inválido ou ausente: '#{admin_json['perfil']}'." + end +end + +puts "Banco semeado com sucesso! 🎉" From 6edf710b2c43e2fc48e85daa749c293e836b14f1 Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Tue, 16 Jun 2026 16:24:59 -0300 Subject: [PATCH 062/125] style(ruby): adiciona frozen string literal --- app/helpers/application_helper.rb | 2 ++ app/jobs/application_job.rb | 2 ++ app/mailers/application_mailer.rb | 2 ++ app/models/application_record.rb | 2 ++ app/models/avaliacao.rb | 2 ++ app/models/departamento.rb | 2 ++ app/models/formulario.rb | 2 ++ app/models/materia.rb | 2 ++ app/models/opcao.rb | 2 ++ app/models/opcao_escolhida.rb | 2 ++ app/models/participacao_turma.rb | 2 ++ app/models/perfil_adm.rb | 2 ++ app/models/perfil_discente.rb | 2 ++ app/models/perfil_docente.rb | 2 ++ app/models/questao.rb | 2 ++ app/models/resposta.rb | 2 ++ app/models/texto.rb | 2 ++ app/models/turma.rb | 2 ++ app/models/usuario.rb | 2 ++ app/models/utilizacao_questao.rb | 2 ++ app/policies/application_policy.rb | 2 ++ app/policies/template_policy.rb | 2 ++ config/application.rb | 2 ++ config/boot.rb | 2 ++ config/ci.rb | 2 ++ config/environment.rb | 2 ++ config/environments/development.rb | 2 ++ config/environments/production.rb | 2 ++ config/environments/test.rb | 2 ++ config/importmap.rb | 2 ++ config/initializers/assets.rb | 2 ++ config/initializers/content_security_policy.rb | 2 ++ config/initializers/filter_parameter_logging.rb | 2 ++ config/initializers/inflections.rb | 2 ++ config/puma.rb | 2 ++ config/routes.rb | 2 ++ db/cable_schema.rb | 2 ++ db/cache_schema.rb | 2 ++ db/migrate/20260612221554_create_usuarios.rb | 2 ++ db/migrate/20260612221601_create_perfis_discentes.rb | 2 ++ db/queue_schema.rb | 2 ++ features/support/env.rb | 2 ++ spec/models/opcao_spec.rb | 2 ++ spec/models/questao_spec.rb | 2 ++ spec/models/template_spec.rb | 2 ++ spec/models/utilizacao_questao_spec.rb | 2 ++ spec/rails_helper.rb | 2 ++ spec/spec_helper.rb | 2 ++ 48 files changed, 96 insertions(+) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index de6be7945c..15b06f0f67 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,2 +1,4 @@ +# frozen_string_literal: true + module ApplicationHelper end diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb index d394c3d106..bef395997d 100644 --- a/app/jobs/application_job.rb +++ b/app/jobs/application_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ApplicationJob < ActiveJob::Base # Automatically retry jobs that encountered a deadlock # retry_on ActiveRecord::Deadlocked diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index 3c34c8148f..5cc63a0c6e 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ApplicationMailer < ActionMailer::Base default from: "from@example.com" layout "mailer" diff --git a/app/models/application_record.rb b/app/models/application_record.rb index b63caeb8a5..08dc537989 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ApplicationRecord < ActiveRecord::Base primary_abstract_class end diff --git a/app/models/avaliacao.rb b/app/models/avaliacao.rb index 85fb5cf8ea..9a209b2440 100644 --- a/app/models/avaliacao.rb +++ b/app/models/avaliacao.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Avaliacao < ApplicationRecord belongs_to :participacao_turma, class_name: "ParticipacaoTurma", diff --git a/app/models/departamento.rb b/app/models/departamento.rb index f3e6a6bfd3..09bad1bef9 100644 --- a/app/models/departamento.rb +++ b/app/models/departamento.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Departamento < ApplicationRecord has_many :perfil_adms, class_name: "PerfilAdm", diff --git a/app/models/formulario.rb b/app/models/formulario.rb index a9f8e6999b..b0d2225d4e 100644 --- a/app/models/formulario.rb +++ b/app/models/formulario.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Formulario < ApplicationRecord enum :publico_alvo, { docentes: 0, diff --git a/app/models/materia.rb b/app/models/materia.rb index dabc287527..57e4fff290 100644 --- a/app/models/materia.rb +++ b/app/models/materia.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Materia < ApplicationRecord belongs_to :departamento, class_name: "Departamento", diff --git a/app/models/opcao.rb b/app/models/opcao.rb index 5457e6aefb..43d6873ccb 100644 --- a/app/models/opcao.rb +++ b/app/models/opcao.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Opcao < ApplicationRecord belongs_to :questao, class_name: "Questao", diff --git a/app/models/opcao_escolhida.rb b/app/models/opcao_escolhida.rb index 6b2f436bd7..b5d7260fbd 100644 --- a/app/models/opcao_escolhida.rb +++ b/app/models/opcao_escolhida.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class OpcaoEscolhida < ApplicationRecord belongs_to :resposta, class_name: "Resposta", diff --git a/app/models/participacao_turma.rb b/app/models/participacao_turma.rb index ea04039940..fa9fff94ff 100644 --- a/app/models/participacao_turma.rb +++ b/app/models/participacao_turma.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ParticipacaoTurma < ApplicationRecord enum :tipo_participacao, { docente: 0, diff --git a/app/models/perfil_adm.rb b/app/models/perfil_adm.rb index f8368ffca2..b3aecc3ba9 100644 --- a/app/models/perfil_adm.rb +++ b/app/models/perfil_adm.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class PerfilAdm < ApplicationRecord self.primary_key = :id diff --git a/app/models/perfil_discente.rb b/app/models/perfil_discente.rb index ecb3034865..7c4818da63 100644 --- a/app/models/perfil_discente.rb +++ b/app/models/perfil_discente.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class PerfilDiscente < ApplicationRecord self.primary_key = :id diff --git a/app/models/perfil_docente.rb b/app/models/perfil_docente.rb index 38d0ffbac5..ac0c79c6a4 100644 --- a/app/models/perfil_docente.rb +++ b/app/models/perfil_docente.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class PerfilDocente < ApplicationRecord self.primary_key = :id diff --git a/app/models/questao.rb b/app/models/questao.rb index 3713fef555..80492928be 100644 --- a/app/models/questao.rb +++ b/app/models/questao.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Questao < ApplicationRecord enum :tipo, { objetiva: 0, diff --git a/app/models/resposta.rb b/app/models/resposta.rb index 0e2e9f4712..6d89a17e19 100644 --- a/app/models/resposta.rb +++ b/app/models/resposta.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Resposta < ApplicationRecord belongs_to :questao, class_name: "Questao", diff --git a/app/models/texto.rb b/app/models/texto.rb index a38bb5b550..e1dba96f6d 100644 --- a/app/models/texto.rb +++ b/app/models/texto.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Texto < ApplicationRecord belongs_to :resposta, class_name: "Resposta", diff --git a/app/models/turma.rb b/app/models/turma.rb index fe6e324362..69cbfa2e8a 100644 --- a/app/models/turma.rb +++ b/app/models/turma.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Turma < ApplicationRecord enum :semestre, { primeiro: 1, diff --git a/app/models/usuario.rb b/app/models/usuario.rb index 7507dcace5..7843019249 100644 --- a/app/models/usuario.rb +++ b/app/models/usuario.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Usuario < ApplicationRecord has_secure_password :senha, validations: false diff --git a/app/models/utilizacao_questao.rb b/app/models/utilizacao_questao.rb index 8f02172526..b625cb3ffc 100644 --- a/app/models/utilizacao_questao.rb +++ b/app/models/utilizacao_questao.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class UtilizacaoQuestao < ApplicationRecord belongs_to :template, class_name: "Template", diff --git a/app/policies/application_policy.rb b/app/policies/application_policy.rb index a200db57da..c9e49171b2 100644 --- a/app/policies/application_policy.rb +++ b/app/policies/application_policy.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ApplicationPolicy attr_reader :usuario, :record diff --git a/app/policies/template_policy.rb b/app/policies/template_policy.rb index 155ff3e308..b724c4e285 100644 --- a/app/policies/template_policy.rb +++ b/app/policies/template_policy.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class TemplatePolicy < ApplicationPolicy class Scope < ApplicationPolicy::Scope def resolve diff --git a/config/application.rb b/config/application.rb index 6eb99b157c..5568b5e0fb 100644 --- a/config/application.rb +++ b/config/application.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require_relative "boot" require "rails/all" diff --git a/config/boot.rb b/config/boot.rb index 988a5ddc46..aef6d031ee 100644 --- a/config/boot.rb +++ b/config/boot.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) require "bundler/setup" # Set up gems listed in the Gemfile. diff --git a/config/ci.rb b/config/ci.rb index ba88f6e85e..d52facc1e0 100644 --- a/config/ci.rb +++ b/config/ci.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Run using bin/ci CI.run do diff --git a/config/environment.rb b/config/environment.rb index cac5315775..7df99e89c6 100644 --- a/config/environment.rb +++ b/config/environment.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Load the Rails application. require_relative "application" diff --git a/config/environments/development.rb b/config/environments/development.rb index 4a6ea19c9c..5f02eda654 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "active_support/core_ext/integer/time" Rails.application.configure do diff --git a/config/environments/production.rb b/config/environments/production.rb index f5763e04e5..51085bfff7 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "active_support/core_ext/integer/time" Rails.application.configure do diff --git a/config/environments/test.rb b/config/environments/test.rb index 29d195b837..10ccc7b22a 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # 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 diff --git a/config/importmap.rb b/config/importmap.rb index 909dfc542d..631a58a620 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Pin npm packages by running ./bin/importmap pin "application" diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb index 487324424f..8544c07c55 100644 --- a/config/initializers/assets.rb +++ b/config/initializers/assets.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Be sure to restart your server when you modify this file. # Version of your assets, change this if you want to expire all your assets. diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index d51d713979..74b18c5a10 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Be sure to restart your server when you modify this file. # Define an application-wide content security policy. diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb index c0b717f7ec..497ac1325c 100644 --- a/config/initializers/filter_parameter_logging.rb +++ b/config/initializers/filter_parameter_logging.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # 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. diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index 380b4a1749..63dbd7ff30 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Be sure to restart your server when you modify this file. # Add new inflection rules using the following format. Inflections diff --git a/config/puma.rb b/config/puma.rb index 38c4b86596..284ca2f537 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # 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. diff --git a/config/routes.rb b/config/routes.rb index 48254e88ed..18bbdcff3d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + Rails.application.routes.draw do # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html diff --git a/db/cable_schema.rb b/db/cable_schema.rb index 23666604a5..cfe506eeb8 100644 --- a/db/cable_schema.rb +++ b/db/cable_schema.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + ActiveRecord::Schema[7.1].define(version: 1) do create_table "solid_cable_messages", force: :cascade do |t| t.binary "channel", limit: 1024, null: false diff --git a/db/cache_schema.rb b/db/cache_schema.rb index 81a410d188..6005a29725 100644 --- a/db/cache_schema.rb +++ b/db/cache_schema.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + ActiveRecord::Schema[7.2].define(version: 1) do create_table "solid_cache_entries", force: :cascade do |t| t.binary "key", limit: 1024, null: false diff --git a/db/migrate/20260612221554_create_usuarios.rb b/db/migrate/20260612221554_create_usuarios.rb index c8228353e3..571605987e 100644 --- a/db/migrate/20260612221554_create_usuarios.rb +++ b/db/migrate/20260612221554_create_usuarios.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class CreateUsuarios < ActiveRecord::Migration[8.1] def change create_table :usuarios do |t| diff --git a/db/migrate/20260612221601_create_perfis_discentes.rb b/db/migrate/20260612221601_create_perfis_discentes.rb index 4810106f63..f7f9c84012 100644 --- a/db/migrate/20260612221601_create_perfis_discentes.rb +++ b/db/migrate/20260612221601_create_perfis_discentes.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class CreatePerfisDiscentes < ActiveRecord::Migration[8.1] def change create_table :perfis_discentes, id: false do |t| diff --git a/db/queue_schema.rb b/db/queue_schema.rb index 85194b6a88..0c9e37bfa6 100644 --- a/db/queue_schema.rb +++ b/db/queue_schema.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + ActiveRecord::Schema[7.1].define(version: 1) do create_table "solid_queue_blocked_executions", force: :cascade do |t| t.bigint "job_id", null: false diff --git a/features/support/env.rb b/features/support/env.rb index 3d9cf223bc..f96db17435 100644 --- a/features/support/env.rb +++ b/features/support/env.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "simplecov" SimpleCov.command_name "cucumber" diff --git a/spec/models/opcao_spec.rb b/spec/models/opcao_spec.rb index 53c128a633..f6fdba39d4 100644 --- a/spec/models/opcao_spec.rb +++ b/spec/models/opcao_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "rails_helper" RSpec.describe Opcao, type: :model do diff --git a/spec/models/questao_spec.rb b/spec/models/questao_spec.rb index 50284a68de..fa837f0d1c 100644 --- a/spec/models/questao_spec.rb +++ b/spec/models/questao_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "rails_helper" RSpec.describe Questao, type: :model do diff --git a/spec/models/template_spec.rb b/spec/models/template_spec.rb index 9c16d427af..a0fc755721 100644 --- a/spec/models/template_spec.rb +++ b/spec/models/template_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "rails_helper" RSpec.describe Template, type: :model do diff --git a/spec/models/utilizacao_questao_spec.rb b/spec/models/utilizacao_questao_spec.rb index 8096510a05..6ece1e3cd6 100644 --- a/spec/models/utilizacao_questao_spec.rb +++ b/spec/models/utilizacao_questao_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "rails_helper" RSpec.describe UtilizacaoQuestao, type: :model do diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index ef75d46770..80288727f3 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # This file is copied to spec/ when you run 'rails generate rspec:install' require 'spec_helper' ENV['RAILS_ENV'] ||= 'test' diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index e674df7164..daa15c0ac6 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "simplecov" SimpleCov.command_name "rspec" From c8dc3a0a9bb11f489bcc5dc9697eb2a2bbf0599a Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Tue, 16 Jun 2026 16:39:53 -0300 Subject: [PATCH 063/125] =?UTF-8?q?fix(usuario):=20move=20o=20atributo=20`?= =?UTF-8?q?matricula`=20para=20usuario=20e=20adiciona=20valida=C3=A7=C3=B5?= =?UTF-8?q?es=20associadas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/perfil_discente.rb | 2 -- app/models/usuario.rb | 10 ++++++++++ db/migrate/20260612221554_create_usuarios.rb | 2 ++ db/migrate/20260612221601_create_perfis_discentes.rb | 2 -- db/schema.rb | 4 ++-- 5 files changed, 14 insertions(+), 6 deletions(-) diff --git a/app/models/perfil_discente.rb b/app/models/perfil_discente.rb index 7c4818da63..3fbf2d6fbf 100644 --- a/app/models/perfil_discente.rb +++ b/app/models/perfil_discente.rb @@ -14,8 +14,6 @@ class PerfilDiscente < ApplicationRecord validates :id, presence: true, uniqueness: true - validates :matricula, presence: true, uniqueness: true, length: { maximum: 50 } - def nome usuario.nome end diff --git a/app/models/usuario.rb b/app/models/usuario.rb index 7843019249..7972411bff 100644 --- a/app/models/usuario.rb +++ b/app/models/usuario.rb @@ -37,9 +37,15 @@ class Usuario < ApplicationRecord has_many :templates_criados, through: :perfil_adm, source: :templates before_validation :normalizar_email + before_validation :normalizar_matricula validates :nome, presence: true, length: { maximum: 255 } + validates :matricula, + presence: true, + uniqueness: true, + length: { maximum: 50 } + validates :email, presence: true, uniqueness: { case_sensitive: false }, @@ -78,6 +84,10 @@ def normalizar_email self.email = email.to_s.strip.downcase if email.present? end + def normalizar_matricula + self.matricula = matricula.to_s.strip if matricula.present? + end + def senha_obrigatoria_para_usuario_ativo return unless ativo? return if senha_digest.present? || senha.present? diff --git a/db/migrate/20260612221554_create_usuarios.rb b/db/migrate/20260612221554_create_usuarios.rb index 571605987e..d83c934ef3 100644 --- a/db/migrate/20260612221554_create_usuarios.rb +++ b/db/migrate/20260612221554_create_usuarios.rb @@ -5,6 +5,7 @@ def change create_table :usuarios do |t| t.string :nome, null: false t.string :email, null: false + t.string :matricula, null: false t.integer :status, null: false, default: 0 t.string :senha_digest @@ -12,5 +13,6 @@ def change end add_index :usuarios, :email, unique: true + add_index :usuarios, :matricula, unique: true end end diff --git a/db/migrate/20260612221601_create_perfis_discentes.rb b/db/migrate/20260612221601_create_perfis_discentes.rb index f7f9c84012..7c41e10db2 100644 --- a/db/migrate/20260612221601_create_perfis_discentes.rb +++ b/db/migrate/20260612221601_create_perfis_discentes.rb @@ -4,12 +4,10 @@ class CreatePerfisDiscentes < ActiveRecord::Migration[8.1] def change create_table :perfis_discentes, id: false do |t| t.primary_key :id - t.string :matricula, null: false t.timestamps end - add_index :perfis_discentes, :matricula, unique: true add_foreign_key :perfis_discentes, :usuarios, column: :id end end diff --git a/db/schema.rb b/db/schema.rb index bf123ebc37..53b75454db 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -92,9 +92,7 @@ create_table "perfis_discentes", force: :cascade do |t| t.datetime "created_at", null: false - t.string "matricula", null: false t.datetime "updated_at", null: false - t.index ["matricula"], name: "index_perfis_discentes_on_matricula", unique: true end create_table "perfis_docentes", force: :cascade do |t| @@ -154,11 +152,13 @@ create_table "usuarios", force: :cascade do |t| t.datetime "created_at", null: false t.string "email", null: false + t.string "matricula", null: false t.string "nome", null: false t.string "senha_digest" t.integer "status", default: 0, null: false t.datetime "updated_at", null: false t.index ["email"], name: "index_usuarios_on_email", unique: true + t.index ["matricula"], name: "index_usuarios_on_matricula", unique: true end create_table "utilizacoes_questoes", force: :cascade do |t| From 178e50de4501d91a090f1df0a03c07877afc30a4 Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Tue, 16 Jun 2026 16:40:18 -0300 Subject: [PATCH 064/125] =?UTF-8?q?feat(domain):=20habilita=20atributos=20?= =?UTF-8?q?aninhados=20para=20quest=C3=B5es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/questao.rb | 4 +++- app/models/utilizacao_questao.rb | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/models/questao.rb b/app/models/questao.rb index 80492928be..4672a4a689 100644 --- a/app/models/questao.rb +++ b/app/models/questao.rb @@ -26,7 +26,9 @@ class Questao < ApplicationRecord inverse_of: :questao, dependent: :restrict_with_error - accepts_nested_attributes_for :opcoes, allow_destroy: true + accepts_nested_attributes_for :opcoes, + allow_destroy: true, + reject_if: :all_blank before_validation :normalizar_enunciado diff --git a/app/models/utilizacao_questao.rb b/app/models/utilizacao_questao.rb index b625cb3ffc..cc6ae00b0e 100644 --- a/app/models/utilizacao_questao.rb +++ b/app/models/utilizacao_questao.rb @@ -25,6 +25,8 @@ class UtilizacaoQuestao < ApplicationRecord has_many :opcoes, through: :questao, source: :opcoes + accepts_nested_attributes_for :questao + before_validation :herdar_template_do_parent validates :template, presence: true From c2d672a76c260fc128fe1aaa15d8f322ce747a92 Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Tue, 16 Jun 2026 17:22:02 -0300 Subject: [PATCH 065/125] =?UTF-8?q?feat(templates):=20adiciona=20a=20funci?= =?UTF-8?q?onalidade=20de=20visualiza=C3=A7=C3=A3o=20de=20template?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/controllers/application_controller.rb | 60 +++++++++++++++++++++++ app/controllers/templates_controller.rb | 27 ++++++++++ app/views/templates/index.html.erb | 24 +++++++++ app/views/templates/show.html.erb | 22 +++++++++ config/routes.rb | 2 + 5 files changed, 135 insertions(+) create mode 100644 app/controllers/templates_controller.rb create mode 100644 app/views/templates/index.html.erb create mode 100644 app/views/templates/show.html.erb diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index c3537563da..342ded89b7 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,7 +1,67 @@ +# frozen_string_literal: true + class ApplicationController < ActionController::Base # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. allow_browser versions: :modern # Changes to the importmap will invalidate the etag for HTML responses stale_when_importmap_changes + + class NotAuthorizedError < StandardError; end + + rescue_from NotAuthorizedError, with: :usuario_nao_autorizado + + helper_method :current_usuario + helper_method :current_adm + helper_method :policy + + private + + def current_usuario + @current_usuario ||= Usuario.find_by(id: session[:usuario_id]) + end + + def current_adm + current_usuario&.perfil_adm + end + + def authenticate_usuario! + return if current_usuario.present? + + redirect_to login_path, alert: "Você precisa estar logado para acessar esta página." + end + + def authorize!(record, query = nil) + query ||= "#{action_name}?" + + return true if policy(record).public_send(query) + + raise NotAuthorizedError + end + + def policy(record) + policy_class_for(record).new(current_usuario, record) + end + + def policy_scope(scope) + policy_class_for(scope).scope(current_usuario, scope) + end + + def policy_class_for(record_or_scope) + model_class = + if record_or_scope.is_a?(Class) + record_or_scope + else + record_or_scope.class + end + + "#{model_class.name}Policy".constantize + end + + def usuario_nao_autorizado + redirect_back( + fallback_location: root_path, + alert: "Você não tem permissão para realizar esta ação." + ) + end end diff --git a/app/controllers/templates_controller.rb b/app/controllers/templates_controller.rb new file mode 100644 index 0000000000..b77b46e10a --- /dev/null +++ b/app/controllers/templates_controller.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class TemplatesController < ApplicationController + before_action :authenticate_usuario! + before_action :set_template, only: %i[show] + + def index + authorize! Template + + templates = policy_scope(Template) + .includes(adm: :usuario) + .recentes + + @user_templates = templates.criados_por(current_adm) + @other_templates = templates.criados_por_outros(current_adm) + end + + def show + authorize! @template + end + + private + + def set_template + @template = Template.find(params[:id]) + end +end diff --git a/app/views/templates/index.html.erb b/app/views/templates/index.html.erb new file mode 100644 index 0000000000..145d7fee5e --- /dev/null +++ b/app/views/templates/index.html.erb @@ -0,0 +1,24 @@ +

Templates

+ +
+

Meus templates

+ + <% if @user_templates.any? %> + <% @user_templates.each do |t| %> +

+ <%= link_to t.titulo, template_path(t) %> +

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

Nenhum template foi encontrado

+ <% end %> +
+ +
+ +

Templates de outros administradores

+<% @other_templates.each do |t| %> +

+ <%= link_to t.titulo, template_path(t) %> +

+<% end %> diff --git a/app/views/templates/show.html.erb b/app/views/templates/show.html.erb new file mode 100644 index 0000000000..d90a2b2fa8 --- /dev/null +++ b/app/views/templates/show.html.erb @@ -0,0 +1,22 @@ +

<%= @template.titulo %>

+ +

<%= @template.descricao %>

+ +

Questões

+ +<% @template.questoes.each do |q| %> +
+

<%= q.enunciado %>

+

Tipo: <%= q.tipo %>

+ + <% if q.opcoes.any? %> +
    + <% q.opcoes.each do |o| %> +
  • <%= o.numero %> - <%= o.texto %>
  • + <% end %> +
+ <% end %> +
+<% end %> + +<%= link_to "Voltar", templates_path %> diff --git a/config/routes.rb b/config/routes.rb index 18bbdcff3d..ba24f265ed 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true Rails.application.routes.draw do + resources :templates + # 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. From d6edc77e45d9821a7a6cd59d4c4a66b7715e0b6c Mon Sep 17 00:00:00 2001 From: Rafael-Sapienza Date: Tue, 16 Jun 2026 17:35:26 -0300 Subject: [PATCH 066/125] test(auth): adicionar testes de requisicao para login, logout e controle de acesso --- spec/requests/autenticacao_spec.rb | 65 ++++++++++++++++++++++++++++++ spec/requests/logout_spec.rb | 31 ++++++++++++++ spec/requests/verify_adm_spec.rb | 64 +++++++++++++++++++++++++++++ 3 files changed, 160 insertions(+) create mode 100644 spec/requests/autenticacao_spec.rb create mode 100644 spec/requests/logout_spec.rb create mode 100644 spec/requests/verify_adm_spec.rb diff --git a/spec/requests/autenticacao_spec.rb b/spec/requests/autenticacao_spec.rb new file mode 100644 index 0000000000..c3ec436c0d --- /dev/null +++ b/spec/requests/autenticacao_spec.rb @@ -0,0 +1,65 @@ +require "rails_helper" + +RSpec.describe "Autenticacao", type: :request do + let(:usuario_mock) do + double( + "Usuario", + id: 42, + email: "rafael@email.com", + matricula: "123456789", + status: 1 + ) + end + + describe "POST /login (Happy Path)" do + it "autentica com sucesso usando MATRÍCULA e redireciona para avaliacoes" do + allow(Usuario).to receive(:find_by).with(matricula: "123456789").and_return(usuario_mock) + allow(usuario_mock).to receive(:authenticate_senha).with("senha_correta").and_return(true) + post login_path, params: { identificador: "123456789", senha: "senha_correta" } + + expect(response).to redirect_to(avaliacoes_path) + expect(flash[:success]).to eq("Login realizado com sucesso! Seja bem-vindo.") + end + + it "autentica com sucesso usando E-MAIL e redireciona para avaliacoes" do + allow(Usuario).to receive(:find_by).with(email: "rafael@email.com").and_return(usuario_mock) + allow(usuario_mock).to receive(:authenticate_senha).with("senha_correta").and_return(true) + + post login_path, params: { identificador: "rafael@email.com", senha: "senha_correta" } + + expect(response).to redirect_to(avaliacoes_path) + expect(flash[:success]).to eq("Login realizado com sucesso! Seja bem-vindo.") + end + end + + describe "POST /login (Sad Path)" do + it "falha se o usuário não for encontrado no banco" do + allow(Usuario).to receive(:find_by).with(matricula: "000000").and_return(nil) + + post login_path, params: { identificador: "000000", senha: "qualquer_senha" } + + expect(response).to redirect_to(root_path) + expect(flash[:error]).to eq("Matrícula ou e-mail inválido.") + end + + it "falha se a conta do usuário não estiver ativa (status diferente de 1)" do + usuario_inativo = double("UsuarioInativo", status: 0) + allow(Usuario).to receive(:find_by).with(matricula: "123").and_return(usuario_inativo) + + post login_path, params: { identificador: "123", senha: "uma_senha" } + + expect(response).to redirect_to(root_path) + expect(flash[:error]).to eq("Esta conta ainda não foi ativada. Por favor, realize o Primeiro Acesso.") + end + + it "falha se a senha estiver incorreta" do + allow(Usuario).to receive(:find_by).with(matricula: "123456789").and_return(usuario_mock) + allow(usuario_mock).to receive(:authenticate_senha).with("senha_errada").and_return(false) + + post login_path, params: { identificador: "123456789", senha: "senha_errada" } + + expect(response).to redirect_to(root_path) + expect(flash[:error]).to eq("Senha incorreta.") + end + end +end \ No newline at end of file diff --git a/spec/requests/logout_spec.rb b/spec/requests/logout_spec.rb new file mode 100644 index 0000000000..b3c40092ad --- /dev/null +++ b/spec/requests/logout_spec.rb @@ -0,0 +1,31 @@ +require "rails_helper" + +RSpec.describe "Auth Logout", type: :request do + let(:usuario_mock) { double("Usuario", id: 1) } + + describe "DELETE /logout" do + it "Adiciona dados na sessão, limpa a sessão, desloga o usuário e redireciona" do + # 1. Simulamos o login no Controller + allow_any_instance_of(ApplicationController) + .to receive(:current_user) + .and_return(usuario_mock) + + # 2. ESPIANDO A SESSÃO: + # Criamos um mock específico para interceptar a chamada do session.clear no controller + sessao_mock = double("Session") + expect(sessao_mock).to receive(:clear).at_least(:once) + + # Forçamos o controller a devolver o nosso dublê quando ele chamar o método `session` + allow_any_instance_of(ApplicationController) + .to receive(:session) + .and_return(sessao_mock) + + # 3. Dispara a requisição de logout + delete logout_path + + # 4. Verificações de redirecionamento e flash + expect(response).to redirect_to(root_path) + expect(flash[:success]).to eq("Sessão encerrada com sucesso.") + end + end +end \ No newline at end of file diff --git a/spec/requests/verify_adm_spec.rb b/spec/requests/verify_adm_spec.rb new file mode 100644 index 0000000000..b7759fbcee --- /dev/null +++ b/spec/requests/verify_adm_spec.rb @@ -0,0 +1,64 @@ +require "rails_helper" + +RSpec.describe "Dashboard", type: :request do + # ADICIONADO O CAMPO EMAIL NOS MOCKS ABAIXO + let(:usuario_comum) { double("UsuarioComum", id: 2, admin?: false, nome: "Rafael", matricula: "123456", email: "rafael@email.com") } + let(:usuario_admin) { double("UsuarioAdmin", id: 3, admin?: true, nome: "Admin", matricula: "654321", email: "admin@email.com") } + + describe "GET /dashboard (index)" do + it "redireciona para root se o usuário não estiver logado" do + allow_any_instance_of(ApplicationController).to receive(:current_user).and_return(nil) + + get avaliacoes_path + + expect(response).to redirect_to(root_path) + expect(flash[:error]).to eq("Acesso restrito. Por favor, faça login para continuar.") + end + + it "permite acesso e carrega as turmas se o usuário estiver logado" do + allow_any_instance_of(ApplicationController).to receive(:current_user).and_return(usuario_comum) + + get avaliacoes_path + + expect(response).to have_http_status(:success) + end + end + + describe "Acesso ao Gerenciamento (Filtro verificar_admin)" do + it "bloqueia e desloga usuário comum que tentar acessar o gerenciamento" do + allow_any_instance_of(ApplicationController).to receive(:current_user).and_return(usuario_comum) + + get gerenciamento_path + + expect(response).to redirect_to(root_path) + expect(flash[:error]).to eq("Acesso restrito. Por favor, faça login como administrador.") + end + + it "permite que o administrador acesse a página de gerenciamento" do + allow_any_instance_of(ApplicationController).to receive(:current_user).and_return(usuario_admin) + + get gerenciamento_path + + expect(response).to have_http_status(:success) + end + end + + describe "Barra Lateral (Sidebar View)" do + it "NÃO exibe o link de Gerenciamento para usuários comuns" do + allow_any_instance_of(ApplicationController).to receive(:current_user).and_return(usuario_comum) + + get avaliacoes_path + + expect(response.body).not_to include('Gerenciamento') + end + + it "EXIBE o link de Gerenciamento para usuários administradores" do + allow_any_instance_of(ApplicationController).to receive(:current_user).and_return(usuario_admin) + + get avaliacoes_path + + expect(response.body).to include('Gerenciamento') + expect(response.body).to include(gerenciamento_path) + end + end +end \ No newline at end of file From 633b508c6aec2f4e11ca6f07ebf246cdc224a56b Mon Sep 17 00:00:00 2001 From: Rafael-Sapienza Date: Tue, 16 Jun 2026 18:15:45 -0300 Subject: [PATCH 067/125] feat(dashboard): adicionar funcionalidade de importar dados do SIGAA --- app/controllers/dashboard_controller.rb | 109 +++++++++++++++++- app/views/dashboard/gerenciamento.html.erb | 3 +- config/routes.rb | 5 +- db/usuarios_sigaa.json | 98 ++++++++++++++++ spec/requests/{ => auth}/autenticacao_spec.rb | 0 spec/requests/{ => auth}/logout_spec.rb | 0 spec/requests/{ => auth}/verify_adm_spec.rb | 0 7 files changed, 210 insertions(+), 5 deletions(-) create mode 100644 db/usuarios_sigaa.json rename spec/requests/{ => auth}/autenticacao_spec.rb (100%) rename spec/requests/{ => auth}/logout_spec.rb (100%) rename spec/requests/{ => auth}/verify_adm_spec.rb (100%) diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index e3fcd87ff0..199d619fb3 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -2,7 +2,7 @@ class DashboardController < ApplicationController - before_action :verificar_admin, only: [ :gerenciamento ] + before_action :verificar_admin, only: [ :gerenciamento, :importar_dados ] def index if current_user.nil? @@ -20,6 +20,113 @@ def index def gerenciamento end + def importar_dados + caminho_arquivo = Rails.root.join("db", "usuarios_sigaa.json") + unless File.exist?(caminho_arquivo) + redirect_to gerenciamento_path, flash: { error: "Arquivo JSON não encontrado em db/" } and return + end + dados = JSON.parse(File.read(caminho_arquivo)) + codigos_materias_ativos = [] + turmas_ativas_ids = [] + matriculas_ativas_json = [] + erros_importacao = [] + dados["materias"]&.each do |materia_json| + codigo = materia_json["codigo"] + + ActiveRecord::Base.transaction do + codigos_materias_ativos << codigo + + materia = Materia.find_or_initialize_by(codigo: codigo) + materia.nome = materia_json["nome"] + materia.departamento_id = materia_json["departamento_id_temp"] + materia.save! + end + rescue => e + erros_importacao << "Matéria #{materia_json['nome']} (Código: #{codigo}): #{e.message}" + end + ActiveRecord::Base.transaction do + dados["turmas"]&.each do |turma_json| + materia = Materia.find_by(codigo: turma_json["materia_codigo"]) + next unless materia + + turma = Turma.find_or_initialize_by( + numero: turma_json["numero"], + ano: turma_json["ano"], + semestre: turma_json["semestre"], + materia_id: materia.id + ) + turma.save! + turmas_ativas_ids << turma.id + end + end + dados["usuarios_docentes"]&.each do |docente_json| + matricula = docente_json["matricula"] + ActiveRecord::Base.transaction do + matriculas_ativas_json << matricula + usuario = Usuario.find_or_initialize_by(matricula: matricula) + usuario.nome = docente_json["nome"] + usuario.email = docente_json["email"] + if usuario.new_record? + usuario.status = 0 + usuario.senha = "" + end + usuario.save! + perfil = PerfilDocente.find_or_initialize_by(id: usuario.id) + perfil.departamento_id = docente_json["departamento_id_temp"] + perfil.save! + end + rescue => e + erros_importacao << "Docente #{docente_json['nome']} (Matrícula: #{matricula}): #{e.message}" + end + dados["usuarios_discentes"]&.each do |discente_json| + matricula = discente_json["matricula"] + ActiveRecord::Base.transaction do + matriculas_ativas_json << matricula + + usuario = Usuario.find_or_initialize_by(matricula: matricula) + usuario.nome = discente_json["nome"] + usuario.email = discente_json["email"] + if usuario.new_record? + usuario.status = 0 + usuario.senha = "" + end + usuario.save! + PerfilDiscente.find_or_create_by!(id: usuario.id) + turmas_aluno_ids = [] + discente_json["turmas_matriculadas"].each do |mat_json| + materia = Materia.find_by(codigo: mat_json["materia_codigo"]) + if materia.nil? + raise "Matéria com código '#{mat_json['materia_codigo']}' não existe no sistema." + end + turma = Turma.find_by(materia_id: materia.id, numero: mat_json["numero_turma"], ano: 2026, semestre: 1) + if turma.nil? + raise "Turma nº #{mat_json['numero_turma']} da matéria '#{materia.nome}' não foi localizada no sistema." + end + + turmas_aluno_ids << turma.id + ParticipacaoTurma.find_or_create_by!(usuario_id: usuario.id, turma_id: turma.id) + end + usuario.participacoes_turma.where.not(turma_id: turmas_aluno_ids).destroy_all + end + rescue => e + erros_importacao << "Discente #{discente_json['nome']} (Matrícula: #{matricula}): #{e.message}" + end + ActiveRecord::Base.transaction do + Turma.where.not(id: turmas_ativas_ids).destroy_all + Materia.where.not(codigo: codigos_materias_ativos).destroy_all + usuarios_para_remover = Usuario.where.not(matricula: matriculas_ativas_json) + usuarios_para_remover.each do |usuario| + next if usuario.admin? + usuario.destroy! + end + end + if erros_importacao.empty? + redirect_to gerenciamento_path, flash: { success: "Dados do SIGAA importados e sincronizados com sucesso!" } + else + redirect_to gerenciamento_path, flash: { error: "A importação foi concluída parcialmente.", error_list: erros_importacao } + end + end + private def verificar_admin diff --git a/app/views/dashboard/gerenciamento.html.erb b/app/views/dashboard/gerenciamento.html.erb index 2c9474e375..94518f5c62 100644 --- a/app/views/dashboard/gerenciamento.html.erb +++ b/app/views/dashboard/gerenciamento.html.erb @@ -51,8 +51,7 @@
- <%# Desativado temporariamente para a feature_login: reativar na branch de importação %> - <%#= link_to "Importar dados", importar_dados_path, data: { turbo_method: :post }, class: "btn-action btn-primary" %> + <%= link_to "Importar dados", importar_dados_path, data: { turbo_method: :post }, class: "btn-action btn-primary" %> <%# Desativado temporariamente para a feature_login: reativar na branch de envio de solicitações %> <%#= link_to "Enviar Solicitação de Cadastro (E-mail)", enviar_solicitacoes_path, data: { turbo_method: :post }, class: "btn-action btn-secondary" %> diff --git a/config/routes.rb b/config/routes.rb index 215b9b2040..e486e9a47f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,8 +1,9 @@ Rails.application.routes.draw do root "auth#index" get "/", to: "auth#index", as: :login - post "/", to: "auth#login" - delete "/logout", to: "auth#logout", as: :logout get "/avaliacoes", to: "dashboard#index", as: :avaliacoes get "/gerenciamento", to: "dashboard#gerenciamento", as: :gerenciamento + post "/", to: "auth#login" + post "/gerenciamento/importar_dados", to: "dashboard#importar_dados", as: :importar_dados + delete "/logout", to: "auth#logout", as: :logout end diff --git a/db/usuarios_sigaa.json b/db/usuarios_sigaa.json new file mode 100644 index 0000000000..fb613f035a --- /dev/null +++ b/db/usuarios_sigaa.json @@ -0,0 +1,98 @@ +{ + "materias": [ + { + "codigo": "CIC0001", + "nome": "Estruturas de Dados", + "departamento_id_temp": 1 + }, + { + "codigo": "CIC0002", + "nome": "Bancos de Dados", + "departamento_id_temp": 1 + }, + { + "codigo": "MAT0001", + "nome": "Cálculo 1", + "departamento_id_temp": 2 + } + ], + "turmas": [ + { + "numero": 1, + "ano": 2026, + "semestre": 1, + "materia_codigo": "CIC0001" + }, + { + "numero": 2, + "ano": 2026, + "semestre": 1, + "materia_codigo": "CIC0001" + }, + { + "numero": 3, + "ano": 2026, + "semestre": 1, + "materia_codigo": "CIC0001" + }, + { + "numero": 1, + "ano": 2026, + "semestre": 1, + "materia_codigo": "CIC0002" + }, + { + "numero": 2, + "ano": 2026, + "semestre": 1, + "materia_codigo": "CIC0002" + }, + { + "numero": 1, + "ano": 2026, + "semestre": 1, + "materia_codigo": "MAT0001" + }, + { + "numero": 2, + "ano": 2026, + "semestre": 1, + "materia_codigo": "MAT0001" + } + ], + "usuarios_docentes": [ + { + "matricula": "2026001", + "nome": "Professor Engenharia", + "email": "engsoftwareg@gmail.com", + "status": 0, + "departamento_id_temp": 2 + } + ], + "usuarios_discentes": [ + { + "matricula": "26100001", + "nome": "Rafael Sapienza", + "email": "rafaelsapienzapinheiro@gmail.com", + "status": 0, + "turmas_matriculadas": [ + { + "materia_codigo": "CIC0001", + "numero_turma": 1 + } + ] + }, + { + "matricula": "26100002", + "nome": "Estudante UnB", + "email": "232006215@aluno.unb.br", + "status": 0, + "turmas_matriculadas": [ + { + "materia_codigo": "CIC0002", + "numero_turma": 1 + } + ] + } + ] +} \ No newline at end of file diff --git a/spec/requests/autenticacao_spec.rb b/spec/requests/auth/autenticacao_spec.rb similarity index 100% rename from spec/requests/autenticacao_spec.rb rename to spec/requests/auth/autenticacao_spec.rb diff --git a/spec/requests/logout_spec.rb b/spec/requests/auth/logout_spec.rb similarity index 100% rename from spec/requests/logout_spec.rb rename to spec/requests/auth/logout_spec.rb diff --git a/spec/requests/verify_adm_spec.rb b/spec/requests/auth/verify_adm_spec.rb similarity index 100% rename from spec/requests/verify_adm_spec.rb rename to spec/requests/auth/verify_adm_spec.rb From 24586daac4f1e0582e4a8224437177402d88f045 Mon Sep 17 00:00:00 2001 From: Rafael-Sapienza Date: Tue, 16 Jun 2026 18:16:13 -0300 Subject: [PATCH 068/125] =?UTF-8?q?test(dashboard):=20cobrir=20cen=C3=A1ri?= =?UTF-8?q?os=20de=20sucesso=20e=20falhas=20na=20importa=C3=A7=C3=A3o=20SI?= =?UTF-8?q?GAA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- spec/requests/sigaa/importar_dados_spec.rb | 97 ++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 spec/requests/sigaa/importar_dados_spec.rb diff --git a/spec/requests/sigaa/importar_dados_spec.rb b/spec/requests/sigaa/importar_dados_spec.rb new file mode 100644 index 0000000000..9e6e674789 --- /dev/null +++ b/spec/requests/sigaa/importar_dados_spec.rb @@ -0,0 +1,97 @@ +require "rails_helper" + +RSpec.describe "Importação de Dados SIGAA", type: :request do + let(:usuario_admin) { double("UsuarioAdmin", id: 99, admin?: true, nome: "Admin", matricula: "654321", email: "admin@unb.br") } + let(:caminho_arquivo) { Rails.root.join("db", "usuarios_sigaa.json") } + + before do + Departamento.find_or_create_by!(id: 1) + Departamento.find_or_create_by!(id: 2) + + allow_any_instance_of(ApplicationController).to receive(:current_user).and_return(usuario_admin) + end + describe "POST /importar_dados - Arquivo inexistente" do + it "redireciona para o gerenciamento avisando que o arquivo sumiu" do + allow(File).to receive(:exist?).with(caminho_arquivo).and_return(false) + + post importar_dados_path + + expect(response).to redirect_to(gerenciamento_path) + expect(flash[:error]).to eq("Arquivo JSON não encontrado em db/") + end + end + describe "POST /importar_dados - Sucesso Total" do + let(:json_valido) do + { + "materias" => [ + { "codigo" => "CIC0001", "nome" => "Estruturas de Dados", "departamento_id_temp" => 1 } + ], + "turmas" => [ + { "numero" => 1, "ano" => 2026, "semestre" => 1, "materia_codigo" => "CIC0001" } + ], + "usuarios_docentes" => [ + { "matricula" => "2026001", "nome" => "Prof Teste", "email" => "prof@unb.br", "departamento_id_temp" => 1 } + ], + "usuarios_discentes" => [ + { + "matricula" => "26100001", + "nome" => "Rafael Sapienza", + "email" => "rafael@unb.br", + "turmas_matriculadas" => [ + { "materia_codigo" => "CIC0001", "numero_turma" => 1 } + ] + } + ] + }.to_json + end + + it "sincroniza todos os dados perfeitamente no banco de dados" do + allow(File).to receive(:exist?).with(caminho_arquivo).and_return(true) + allow(File).to receive(:read).with(caminho_arquivo).and_return(json_valido) + + expect { + post importar_dados_path + }.to change(Materia, :count).by(1) + .and change(Turma, :count).by(1) + .and change(Usuario, :count).by(2) + + expect(response).to redirect_to(gerenciamento_path) + expect(flash[:success]).to eq("Dados do SIGAA importados e sincronizados com sucesso!") + end + end + describe "POST /importar_dados - Dados Corrompidos ou Inválidos" do + let(:json_com_erro) do + { + "materias" => [ + { "codigo" => "CIC0001", "nome" => "Estruturas de Dados", "departamento_id_temp" => 1 } + ], + "turmas" => [], # Nenhuma turma mapeada para forçar o erro do aluno abaixo + "usuarios_docentes" => [], + "usuarios_discentes" => [ + { + "matricula" => "26100001", + "nome" => "Rafael Sapienza", + "email" => "rafael@unb.br", + "turmas_matriculadas" => [ + { "materia_codigo" => "CIC0001", "numero_turma" => 999 } + ] + } + ] + }.to_json + end + + it "salva a matéria, mas falha ao matricular o aluno e gera relatório de erros" do + allow(File).to receive(:exist?).with(caminho_arquivo).and_return(true) + allow(File).to receive(:read).with(caminho_arquivo).and_return(json_com_erro) + + post importar_dados_path + + expect(response).to redirect_to(gerenciamento_path) + expect(flash[:error]).to eq("A importação foi concluída parcialmente.") + + expect(flash[:error_list]).to include( + "Discente Rafael Sapienza (Matrícula: 26100001): Turma nº 999 da matéria 'Estruturas de Dados' não foi localizada no sistema." + ) + end + end +end \ No newline at end of file From 045612d468a417d096db760c967a495d8eecb87b Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Tue, 16 Jun 2026 18:20:30 -0300 Subject: [PATCH 069/125] =?UTF-8?q?feat(templates):=20adiciona=20as=20func?= =?UTF-8?q?ionalidades=20de=20edi=C3=A7=C3=A3o=20e=20exclus=C3=A3o=20de=20?= =?UTF-8?q?templates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/controllers/templates_controller.rb | 66 +++++++++++++++++++++- app/views/templates/_form.html.erb | 73 +++++++++++++++++++++++++ app/views/templates/edit.html.erb | 3 + 3 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 app/views/templates/_form.html.erb create mode 100644 app/views/templates/edit.html.erb diff --git a/app/controllers/templates_controller.rb b/app/controllers/templates_controller.rb index b77b46e10a..b6c6907146 100644 --- a/app/controllers/templates_controller.rb +++ b/app/controllers/templates_controller.rb @@ -2,7 +2,7 @@ class TemplatesController < ApplicationController before_action :authenticate_usuario! - before_action :set_template, only: %i[show] + before_action :set_template, only: %i[show edit update destroy] def index authorize! Template @@ -19,9 +19,73 @@ def show authorize! @template end + def edit + preparar_campos_do_formulario + + authorize! @template + end + + def update + authorize! @template + + if @template.update(template_params) + redirect_to @template, notice: "Template atualizado com sucesso." + else + preparar_campos_do_formulario + + render :edit, status: :unprocessable_entity + end + end + + def destroy + authorize! @template + + @template.destroy + + redirect_to templates_path, notice: "Template excluído com sucesso." + end + private def set_template @template = Template.find(params[:id]) end + + def preparar_campos_do_formulario + utilizacoes = @template.utilizacao_questoes + utilizacoes.build(numero: 1) if utilizacoes.empty? + + utilizacoes.each do |utilizacao| + utilizacao.build_questao(tipo: :discursiva) if utilizacao.questao.blank? + + 4.times { utilizacao.questao.opcoes.build } + end + end + + def template_params + params + .require(:template) + .permit( + :titulo, + :descricao, + utilizacoes_questao_attributes: [ + :id, + :questao_id, + :numero, + :parent_id, + :_destroy, + questao_attributes: [ + :id, + :enunciado, + :tipo, + opcoes_attributes: [ + :id, + :texto, + :numero, + :_destroy + ] + ] + ] + ) + end end diff --git a/app/views/templates/_form.html.erb b/app/views/templates/_form.html.erb new file mode 100644 index 0000000000..e54a2d2f46 --- /dev/null +++ b/app/views/templates/_form.html.erb @@ -0,0 +1,73 @@ +<%= form_with model: @template do |f| %> + <% if @template.errors.any? %> +
+

Erros:

+
    + <% @template.errors.full_messages.each do |msg| %> +
  • <%= msg %>
  • + <% end %> +
+
+ <% end %> + +
+ <%= f.label :titulo %> + <%= f.text_field :titulo %> +
+ +
+ <%= f.label :descricao %> + <%= f.text_area :descricao %> +
+ +
+ +

Questões

+ + <%= f.fields_for :utilizacao_questoes do |utilizacao_form| %> +
+ <%= utilizacao_form.hidden_field :parent_id %> + +
+ <%= utilizacao_form.label :numero, "Ordem" %> + <%= utilizacao_form.number_field :numero, min: 1 %> +
+ + <%= utilizacao_form.fields_for :questao do |questao_form| %> +
+ <%= questao_form.label :enunciado %> + <%= questao_form.text_area :enunciado %> +
+ +
+ <%= questao_form.label :tipo %> + <%= questao_form.select( + :tipo, + [["Discursiva", "discursiva"], ["Objetiva", "objetiva"]] + ) %> +
+ +

Opções

+ + <%= questao_form.fields_for :opcoes do |opcao_form| %> +
+ <%= opcao_form.label :texto %> + <%= opcao_form.text_field :texto %> + + <%= opcao_form.label :numero %> + <%= opcao_form.number_field :numero, min: 1 %> +
+ <% end %> + <% end %> + +
+ <%= utilizacao_form.check_box :_destroy %> + <%= utilizacao_form.label :_destroy, "Remover questão" %> +
+
+ <% end %> + +
+ + <%= f.submit %> +<% end %> diff --git a/app/views/templates/edit.html.erb b/app/views/templates/edit.html.erb new file mode 100644 index 0000000000..079aaad5f0 --- /dev/null +++ b/app/views/templates/edit.html.erb @@ -0,0 +1,3 @@ +

Editar Template

+ +<%= render "form", template: @template %> From d566dab7201e01c666b5fe716adb2ef05a2fb8f7 Mon Sep 17 00:00:00 2001 From: Rafael-Sapienza Date: Tue, 16 Jun 2026 18:56:53 -0300 Subject: [PATCH 070/125] feat(auth): implementar solicitacao de cadastro institucional e confirmacao de senha --- app/controllers/auth_controller.rb | 83 +++++++++++++ app/controllers/concerns/brevo_emailable.rb | 129 ++++++++++++++++++++ app/views/auth/cadastrar.html.erb | 52 ++++++++ app/views/auth/index.html.erb | 3 +- app/views/auth/solicitar_cadastro.html.erb | 57 +++++++++ config/credentials.yml.enc | 2 +- config/routes.rb | 4 + 7 files changed, 327 insertions(+), 3 deletions(-) create mode 100644 app/controllers/concerns/brevo_emailable.rb create mode 100644 app/views/auth/cadastrar.html.erb create mode 100644 app/views/auth/solicitar_cadastro.html.erb diff --git a/app/controllers/auth_controller.rb b/app/controllers/auth_controller.rb index 790aca2b11..e834e6f3aa 100644 --- a/app/controllers/auth_controller.rb +++ b/app/controllers/auth_controller.rb @@ -3,12 +3,23 @@ require "json" class AuthController < ApplicationController + include BrevoEmailable + before_action :impedir_se_logado, only: [ :solicitar_cadastro, :cadastrar ] + before_action :validar_token_via_url, only: [ :cadastrar ] + def index if current_user.present? redirect_to avaliacoes_path, flash: { notice: "Você já está conectado no sistema. Se quiser sair, faça log out" } and return end end + + def solicitar_cadastro + end + + def cadastrar + end + def logout session.clear @current_user = nil @@ -37,4 +48,76 @@ def login redirect_to root_path, flash: { error: "Senha incorreta." } end end + def processar_solicitacao_cadastro + return redirecionar_com_erro(cadastro_path, "Por favor, insira um formato de e-mail válido.") unless email_valido?(params[:email]) + usuario = Usuario.find_by(matricula: params[:matricula]) + return redirecionar_com_erro(cadastro_path, "Matrícula não encontrada no sistema institucional.") if usuario.nil? + if usuario.email.to_s.downcase.strip != params[:email].to_s.downcase.strip + return redirecionar_com_erro(cadastro_path, "O e-mail informado não corresponde ao e-mail institucional desta matrícula.") + end + return redirecionar_com_erro(cadastro_path, "Esta matrícula já possui um cadastro ativo. Caso tenha esquecido sua senha, utilize a redefinição.") if usuario.status == 1 + token_gerado = SecureRandom.hex(16) + usuario.tokens.create!( + value: token_gerado, + tipo: "cadastro", + expires_at: 10.minutes.from_now + ) + if enviar_email_cadastro(usuario.email, token_gerado) + redirect_to root_path, flash: { success: mensagem_email_enviado("10 minutos") } + else + redirect_to cadastro_path, flash: { error: "Houve um erro técnico ao tentar enviar o e-mail. Tente novamente mais tarde." } + end + end + def confirmar_cadastro + return redirecionar_com_erro(confirmar_cadastro_path(token: params[:token]), "A senha deve conter pelo menos 6 caracteres.") if params[:senha].length < 6 + return redirecionar_com_erro(confirmar_cadastro_path(token: params[:token]), "As senhas não coincidem. Digite novamente.") if params[:senha] != params[:senha_confirmacao] + token_registro = buscar_token_valido(params[:token], "cadastro") + return redirecionar_com_erro(root_path, "O link de confirmação é inválido, expirou ou não corresponde a esta operação.") if token_registro.nil? + usuario = token_registro.usuario + usuario.senha = params[:senha] + usuario.senha_confirmation = params[:senha_confirmacao] + usuario.status = 1 + + if usuario.save + token_registro.destroy + redirect_to root_path, flash: { success: "Cadastro concluído com sucesso! Faça seu login." } + else + redirect_to confirmar_cadastro_path(token: params[:token]), flash: { error: usuario.errors.full_messages.to_sentence } + end + end + +private + def impedir_se_logado + if current_user.present? + redirect_to avaliacoes_path, flash: { error: "Você já está logado em outra sessão. Faça o logout antes de poder terminar essa ação." } + end + end + def validar_token_via_url + tipo_esperado = action_name == "cadastrar" ? "cadastro" : "redefinicao" + token = buscar_token_valido(params[:token], tipo_esperado) + + if token.nil? + redirect_to root_path, flash: { error: "Token inválido ou expirado." } + end + end + def email_valido?(email) + email_regex = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i + email.present? && email.match?(email_regex) + end + def buscar_token_valido(valor, tipo_esperado) + token = Token.find_by(value: valor, tipo: tipo_esperado) + return nil if token.nil? || token.expires_at < Time.current + token + end + def redirecionar_com_erro(caminho, mensagem) + redirect_to caminho, flash: { error: mensagem } + end + def mensagem_email_enviado(validade) + <<~HTML + E-mail enviado com sucesso! Caso não o veja na caixa de entrada, cheque sua caixa de spam. +

+ ⚠️ O link enviado terá validade de #{validade}. +

+ HTML + end end diff --git a/app/controllers/concerns/brevo_emailable.rb b/app/controllers/concerns/brevo_emailable.rb new file mode 100644 index 0000000000..37bf0ee3fc --- /dev/null +++ b/app/controllers/concerns/brevo_emailable.rb @@ -0,0 +1,129 @@ +module BrevoEmailable + extend ActiveSupport::Concern + BREVO_API_KEY = Rails.application.credentials.dig(:brevo, :api_key) + BREVO_API_URL = URI("https://api.brevo.com/v3/smtp/email") + REMETENTE = { "name" => "CAMAAR Support", "email" => "rafaelsapienzapinheiro@gmail.com" }.freeze + def chamar_api_brevo(payload, contexto: "") + headers = { + "Accept" => "application/json", + "api-key" => BREVO_API_KEY, + "Content-Type" => "application/json" + } + + http = Net::HTTP.new(BREVO_API_URL.host, BREVO_API_URL.port) + http.use_ssl = true + request = Net::HTTP::Post.new(BREVO_API_URL.path, headers) + request.body = payload.to_json + + response = http.request(request) + + if response.is_a?(Net::HTTPSuccess) + Rails.logger.info "[BREVO] #{contexto} — E-mail enviado com sucesso." + true + else + Rails.logger.error "[BREVO] #{contexto} — Falha. Código: #{response.code} | Resposta: #{response.body}" + false + end + rescue StandardError => e + Rails.logger.error "[BREVO] #{contexto} — Erro inesperado: #{e.message}" + false + end + def enviar_email_cadastro(destinatario, token) + url = "http://127.0.0.1:3000/cadastro/confirmar/?token=#{token}" + + payload = { + "sender" => REMETENTE, + "to" => [ { "email" => destinatario } ], + "subject" => "Link de cadastro do CAMAAR", + "htmlContent" => <<~HTML + + +

Olá!

+

Seja bem-vindo(a) ao CAMAAR. Recebemos sua solicitação para realizar o primeiro acesso no sistema institucional.

+

Para ativar sua conta e configurar sua senha de acesso com segurança, clique no botão abaixo:

+

+ + Confirmar Cadastro e Criar Senha + +

+

+ Se o botão não funcionar, copie e cole este endereço no seu navegador:
+ #{url} +

+
+ ⚠️ Importante: Este link é válido por apenas 10 minutos.
+ Caso ele expire antes de você concluir a ação, basta acessar a página de cadastro do CAMAAR novamente para gerar um novo envio. +
+ + + HTML + } + + chamar_api_brevo(payload, contexto: "Cadastro") + end + def enviar_email_redefinicao(destinatario, token) + url = "http://127.0.0.1:3000/redefinir-senha/confirmar/?token=#{token}" + + payload = { + "sender" => REMETENTE, + "to" => [ { "email" => destinatario } ], + "subject" => "Recuperação de Senha — CAMAAR", + "htmlContent" => <<~HTML + + +

Olá!

+

Você solicitou a redefinição de senha para sua conta no sistema CAMAAR.

+

Para escolher uma nova senha e restabelecer o seu acesso, clique no botão abaixo:

+

+ + Redefinir Minha Senha + +

+

+ Se o botão não funcionar, copie e cole este endereço no seu navegador:
+ #{url} +

+
+ ⚠️ Segurança: Este link é válido por apenas 10 minutos.
+ Se você não realizou essa solicitação, por favor, desconsidere este e-mail. Seus dados de acesso continuarão seguros e inalterados. +
+ + + HTML + } + + chamar_api_brevo(payload, contexto: "Redefinição de senha") + end + def enviar_email_convite_admin(destinatario, token, nome_admin) + url = "http://127.0.0.1:3000/cadastro/confirmar/?token=#{token}" + + payload = { + "sender" => REMETENTE, + "to" => [ { "email" => destinatario } ], + "subject" => "Convite de Cadastro no CAMAAR — Professor(a) #{nome_admin}", + "htmlContent" => <<~HTML + + +

Olá!

+

O(A) Administrador(a) #{nome_admin} está te convidando para realizar o seu cadastro no sistema do CAMAAR.

+

Para criar sua senha e ativar sua conta com segurança, clique no link abaixo:

+

+ + Confirmar Cadastro e Criar Senha + +

+

+ Se o botão não funcionar, copie e cole este endereço no seu navegador:
+ #{url} +

+
+ ⚠️ Importante: Este link é válido por apenas 10 minutos.
+ Caso ele expire, não se preocupe! Acesse a página inicial do CAMAAR, vá na seção de cadastro e informe seus dados para receber um novo link por e-mail instantaneamente. +
+ + + HTML + } + chamar_api_brevo(payload, contexto: "Convite do Administrador") + end +end diff --git a/app/views/auth/cadastrar.html.erb b/app/views/auth/cadastrar.html.erb new file mode 100644 index 0000000000..df7cd759cf --- /dev/null +++ b/app/views/auth/cadastrar.html.erb @@ -0,0 +1,52 @@ +<% if flash[:success] %> +
+ <%= flash[:success]&.html_safe %> +
+<% end %> + +<% if flash[:error] %> +
+ <%= flash[:error] %> +
+<% end %> + +
+ + + +
+

CAMAAR

+

+ Sua senha deve ser estritamente pessoal. Evite usar sequências óbvias ou dados públicos como sua própria matrícula ou data de nascimento. +

+
+ +
\ No newline at end of file diff --git a/app/views/auth/index.html.erb b/app/views/auth/index.html.erb index ccfcbe156e..6569896c3c 100644 --- a/app/views/auth/index.html.erb +++ b/app/views/auth/index.html.erb @@ -34,8 +34,7 @@ diff --git a/app/views/auth/redefinir_senha.html.erb b/app/views/auth/redefinir_senha.html.erb new file mode 100644 index 0000000000..b1f6ccc118 --- /dev/null +++ b/app/views/auth/redefinir_senha.html.erb @@ -0,0 +1,52 @@ +<% if flash[:success] %> +
+ <%= flash[:success]&.html_safe %> +
+<% end %> + +<% if flash[:error] %> +
+ <%= flash[:error] %> +
+<% end %> + +
+ + + +
+

CAMAAR

+

+ Após alterar a sua senha com sucesso, você será redirecionado para a tela de login inicial para acessar o sistema com as novas credenciais. +

+
+ +
\ No newline at end of file diff --git a/app/views/auth/solicitar_redef_senha.html.erb b/app/views/auth/solicitar_redef_senha.html.erb new file mode 100644 index 0000000000..be3c13bc84 --- /dev/null +++ b/app/views/auth/solicitar_redef_senha.html.erb @@ -0,0 +1,49 @@ +<% if flash[:success] %> +
+ <%= flash[:success]&.html_safe %> +
+<% end %> + +<% if flash[:error] %> +
+ <%= flash[:error] %> +
+<% end %> + +
+ + + +
+

CAMAAR

+

+ Por questões de segurança, os links de redefinição de senha expiram após um curto período de tempo. Se não receber o e-mail em alguns minutos, verifique sua caixa de spam. +

+
+ +
\ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index a799060718..545bfcacf7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -5,9 +5,13 @@ get "/cadastro/confirmar", to: "auth#cadastrar", as: :confirmar_cadastro get "/avaliacoes", to: "dashboard#index", as: :avaliacoes get "/gerenciamento", to: "dashboard#gerenciamento", as: :gerenciamento + get "/redefinir-senha", to: "auth#solicitar_redef_senha", as: :solicitar_redef_senha + get "/redefinir-senha/confirmar", to: "auth#redefinir_senha", as: :redefinir_senha post "/", to: "auth#login" post "/cadastro", to: "auth#processar_solicitacao_cadastro" post "cadastro/confirmar", to: "auth#confirmar_cadastro" + post "/redefinir-senha", to: "auth#processar_redefinicao_senha" + post "/redefinir-senha/confirmar", to: "auth#confirmar_redefinicao_senha" post "/gerenciamento/importar_dados", to: "dashboard#importar_dados", as: :importar_dados delete "/logout", to: "auth#logout", as: :logout end From ef4132c26faaf9098fccb198020281cc4a7daf38 Mon Sep 17 00:00:00 2001 From: Rafael-Sapienza Date: Tue, 16 Jun 2026 19:24:47 -0300 Subject: [PATCH 075/125] test(auth): adicionar testes de request para a redefinicao de senha --- spec/requests/auth/senha_redef_spec.rb | 134 +++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 spec/requests/auth/senha_redef_spec.rb diff --git a/spec/requests/auth/senha_redef_spec.rb b/spec/requests/auth/senha_redef_spec.rb new file mode 100644 index 0000000000..edd697bb7b --- /dev/null +++ b/spec/requests/auth/senha_redef_spec.rb @@ -0,0 +1,134 @@ +# spec/requests/auth/senha_redef_spec.rb +require 'rails_helper' + +RSpec.describe "Redefinição de Senha", type: :request do + # Cria um usuário base com a matrícula fictícia para passar nas validações do seu modelo + let!(:usuario) do + Usuario.create!( + email: "usuario@teste.com", + senha: "SenhaAntiga123", + senha_confirmation: "SenhaAntiga123", + matricula: "202600123", + status: 1 + ) + end + + describe "POST /redefinir-senha" do + context "quando o formato do e-mail é inválido" do + it "redireciona de volta com mensagem de erro" do + post solicitar_redef_senha_path, params: { email: "email_invalido" } + + expect(response).to redirect_to(solicitar_redef_senha_path) + expect(flash[:error]).to eq("Por favor, insira um formato de e-mail válido.") + end + end + + context "quando o e-mail não está cadastrado" do + it "redireciona de volta informando que não existe" do + post solicitar_redef_senha_path, params: { email: "nao_existe@teste.com" } + + expect(response).to redirect_to(solicitar_redef_senha_path) + expect(flash[:error]).to eq("Este e-mail não está cadastrado no sistema.") + end + end + + context "quando o e-mail é válido e cadastrado" do + context "e o envio do e-mail ocorre com sucesso" do + before do + # SIMULAÇÃO CORRETA: Intercepta o método no AuthController + allow_any_instance_of(AuthController).to receive(:enviar_email_redefinicao).and_return(true) + end + + it "gerencia o token no banco e redireciona para a home com sucesso" do + expect { + post solicitar_redef_senha_path, params: { email: "usuario@teste.com" } + }.to change(usuario.tokens, :count).by(1) + + expect(response).to redirect_to(root_path) + expect(flash[:success]).to include("10 minutos") + + # Garante que as propriedades do token foram salvas corretamente + novo_token = usuario.tokens.last + expect(novo_token.tipo).to eq("redefinicao") + expect(novo_token.value).to be_present + expect(novo_token.expires_at).to be > Time.current + end + end + + context "e ocorre um erro técnico no envio do e-mail" do + before do + # SIMULAÇÃO: Força o método a falhar (retornar false) para testar o else + allow_any_instance_of(AuthController).to receive(:enviar_email_redefinicao).and_return(false) + end + + it "não salva o fluxo com sucesso e exibe mensagem de erro técnico" do + post solicitar_redef_senha_path, params: { email: "usuario@teste.com" } + + expect(response).to redirect_to(solicitar_redef_senha_path) + expect(flash[:error]).to include("Houve um erro técnico ao tentar enviar o e-mail") + end + end + end + end + + describe "POST /redefinir-senha/confirmar" do + # Cria um token válido no banco associado ao usuário para os contextos de sucesso + let!(:token_valido) { usuario.tokens.create!(value: "token_secreto_123", tipo: "redefinicao", expires_at: 10.minutes.from_now) } + + context "quando a nova senha tem menos de 6 caracteres" do + it "recusa a alteração" do + # Ajustado para usar strings de rota diretas, evitando helpers desalinhados + post "/redefinir-senha/confirmar", params: { token: "token_secreto_123", senha: "123", senha_confirmacao: "123" } + + expect(response).to redirect_to(redefinir_senha_path(token: "token_secreto_123")) + expect(flash[:error]).to eq("A nova senha deve conter pelo menos 6 caracteres.") + end + end + + context "quando as senhas não coincidem" do + it "recusa a alteração" do + post "/redefinir-senha/confirmar", params: { token: "token_secreto_123", senha: "NovaSenha123", senha_confirmacao: "Diferente123" } + + expect(response).to redirect_to(redefinir_senha_path(token: "token_secreto_123")) + expect(flash[:error]).to eq("As senhas não coincidem. Digite novamente.") + end + end + + context "quando o token é inválido ou expirou" do + it "redireciona para a raiz com erro" do + post "/redefinir-senha/confirmar", params: { token: "token_inexistente", senha: "NovaSenha123", senha_confirmacao: "NovaSenha123" } + + expect(response).to redirect_to(root_path) + expect(flash[:error]).to eq("O link de redefinição é inválido, expirou ou não corresponde a esta operação.") + end + end + + context "quando todos os dados são válidos" do + it "altera a senha do usuário, destrói o token e redireciona para a home" do + post "/redefinir-senha/confirmar", params: { token: "token_secreto_123", senha: "NovaSenha123", senha_confirmacao: "NovaSenha123" } + + expect(response).to redirect_to(root_path) + expect(flash[:success]).to eq("Sua senha foi alterada com sucesso! Insira suas novas credenciais para acessar.") + + # Garante que o token consumido foi devidamente deletado + expect(Token.find_by(id: token_valido.id)).to be_nil + end + end + + context "quando o banco falha ao salvar o usuário por outra validação interna" do + before do + # Simula uma falha na hora do usuario.save + allow_any_instance_of(Usuario).to receive(:save).and_return(false) + # Permite que os erros do modelo retornem uma mensagem controlada + allow_any_instance_of(ActiveModel::Errors).to receive(:full_messages).and_return(["Erro customizado do modelo"]) + end + + it "redireciona para a página de redefinição exibindo os erros do modelo" do + post "/redefinir-senha/confirmar", params: { token: "token_secreto_123", senha: "NovaSenha123", senha_confirmacao: "NovaSenha123" } + + expect(response).to redirect_to(redefinir_senha_path(token: "token_secreto_123")) + expect(flash[:error]).to eq("Erro customizado do modelo") + end + end + end +end \ No newline at end of file From 3704ff4060b46684da9027a84695a74cc5580edd Mon Sep 17 00:00:00 2001 From: Rafael-Sapienza Date: Tue, 16 Jun 2026 19:42:14 -0300 Subject: [PATCH 076/125] feat(dashboard): implementar envio de convites de cadastro para usuarios pendentes --- app/controllers/dashboard_controller.rb | 52 +++++++- app/views/dashboard/gerenciamento.html.erb | 4 +- config/routes.rb | 1 + spec/requests/auth/autenticacao_spec.rb | 2 +- spec/requests/auth/cadastro_spec.rb | 4 +- spec/requests/auth/logout_spec.rb | 6 +- spec/requests/auth/senha_redef_spec.rb | 32 ++--- spec/requests/auth/verify_adm_spec.rb | 26 ++-- .../sigaa/enviar_solicitacoes_spec.rb | 124 ++++++++++++++++++ 9 files changed, 212 insertions(+), 39 deletions(-) create mode 100644 spec/requests/sigaa/enviar_solicitacoes_spec.rb diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 199d619fb3..3e4f3218be 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -2,7 +2,8 @@ class DashboardController < ApplicationController - before_action :verificar_admin, only: [ :gerenciamento, :importar_dados ] + include BrevoEmailable + before_action :verificar_admin, only: [ :gerenciamento, :importar_dados, :enviar_solicitacoes ] def index if current_user.nil? @@ -20,6 +21,55 @@ def index def gerenciamento end + def enviar_solicitacoes + depto_id = current_user.perfil_adm&.departamento_id + + if depto_id.blank? + redirect_to gerenciamento_path, flash: { error: "Seu usuário não possui um departamento associado." } and return + end + turmas_do_departamento_ids = Turma.joins(:materia).where(materias: { departamento_id: depto_id }).ids + discentes_pendentes = Usuario.joins(:participacoes_turma) + .where(status: 0, participacoes_turma: { turma_id: turmas_do_departamento_ids }) + docentes_pendentes = Usuario.joins(:perfil_docente) + .where(status: 0, perfil_docente: { departamento_id: depto_id }) + usuarios_pendentes = (discentes_pendentes + docentes_pendentes).uniq + + if usuarios_pendentes.empty? + redirect_to gerenciamento_path, flash: { notice: "Não há usuários pendentes de cadastro (docentes ou discentes) neste departamento." } and return + end + + sucessos = 0 + erros_envio = [] + usuarios_pendentes.each do |usuario| + ActiveRecord::Base.transaction do + token_gerado = SecureRandom.hex(16) + + usuario.tokens.create!( + value: token_gerado, + tipo: "cadastro", + expires_at: 10.minutes.from_now + ) + + if enviar_email_convite_admin(usuario.email, token_gerado, current_user.nome) + sucessos += 1 + else + raise "Falha de comunicação com a Brevo." + end + rescue => e + erros_envio << "#{usuario.nome} (Matrícula: #{usuario.matricula}): #{e.message}" + raise ActiveRecord::Rollback + end + end + if erros_envio.empty? + redirect_to gerenciamento_path, flash: { success: "Convites enviados com sucesso para os #{sucessos} usuários do departamento!" } + else + redirect_to gerenciamento_path, flash: { + error: "O envio foi concluído com instabilidades. Foram enviados #{sucessos} e-mails.", + error_list: erros_envio + } + end + end + def importar_dados caminho_arquivo = Rails.root.join("db", "usuarios_sigaa.json") unless File.exist?(caminho_arquivo) diff --git a/app/views/dashboard/gerenciamento.html.erb b/app/views/dashboard/gerenciamento.html.erb index 94518f5c62..d40a708bf2 100644 --- a/app/views/dashboard/gerenciamento.html.erb +++ b/app/views/dashboard/gerenciamento.html.erb @@ -52,9 +52,7 @@
<%= link_to "Importar dados", importar_dados_path, data: { turbo_method: :post }, class: "btn-action btn-primary" %> - - <%# Desativado temporariamente para a feature_login: reativar na branch de envio de solicitações %> - <%#= link_to "Enviar Solicitação de Cadastro (E-mail)", enviar_solicitacoes_path, data: { turbo_method: :post }, class: "btn-action btn-secondary" %> + <%= link_to "Enviar Solicitação de Cadastro (E-mail)", enviar_solicitacoes_path, data: { turbo_method: :post }, class: "btn-action btn-secondary" %> <%= link_to "Editar Templates", "#", class: "btn-action btn-secondary" %> <%= link_to "Resultados", "#", class: "btn-action btn-secondary" %> diff --git a/config/routes.rb b/config/routes.rb index 545bfcacf7..e24735d062 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -13,5 +13,6 @@ post "/redefinir-senha", to: "auth#processar_redefinicao_senha" post "/redefinir-senha/confirmar", to: "auth#confirmar_redefinicao_senha" post "/gerenciamento/importar_dados", to: "dashboard#importar_dados", as: :importar_dados + post "dashboard/enviar_solicitacoes", to: "dashboard#enviar_solicitacoes", as: :enviar_solicitacoes delete "/logout", to: "auth#logout", as: :logout end diff --git a/spec/requests/auth/autenticacao_spec.rb b/spec/requests/auth/autenticacao_spec.rb index c3ec436c0d..23aa7813e3 100644 --- a/spec/requests/auth/autenticacao_spec.rb +++ b/spec/requests/auth/autenticacao_spec.rb @@ -62,4 +62,4 @@ expect(flash[:error]).to eq("Senha incorreta.") end end -end \ No newline at end of file +end diff --git a/spec/requests/auth/cadastro_spec.rb b/spec/requests/auth/cadastro_spec.rb index 6c5680a542..24b845d4a6 100644 --- a/spec/requests/auth/cadastro_spec.rb +++ b/spec/requests/auth/cadastro_spec.rb @@ -109,9 +109,9 @@ }.to change(Token, :count).by(-1) usuario_institucional.reload expect(usuario_institucional.status).to eq(1) - + expect(response).to redirect_to(root_path) expect(flash[:success]).to eq("Cadastro concluído com sucesso! Faça seu login.") end end -end \ No newline at end of file +end diff --git a/spec/requests/auth/logout_spec.rb b/spec/requests/auth/logout_spec.rb index b3c40092ad..01a9389f55 100644 --- a/spec/requests/auth/logout_spec.rb +++ b/spec/requests/auth/logout_spec.rb @@ -10,7 +10,7 @@ .to receive(:current_user) .and_return(usuario_mock) - # 2. ESPIANDO A SESSÃO: + # 2. ESPIANDO A SESSÃO: # Criamos um mock específico para interceptar a chamada do session.clear no controller sessao_mock = double("Session") expect(sessao_mock).to receive(:clear).at_least(:once) @@ -21,11 +21,11 @@ .and_return(sessao_mock) # 3. Dispara a requisição de logout - delete logout_path + delete logout_path # 4. Verificações de redirecionamento e flash expect(response).to redirect_to(root_path) expect(flash[:success]).to eq("Sessão encerrada com sucesso.") end end -end \ No newline at end of file +end diff --git a/spec/requests/auth/senha_redef_spec.rb b/spec/requests/auth/senha_redef_spec.rb index edd697bb7b..c771046f41 100644 --- a/spec/requests/auth/senha_redef_spec.rb +++ b/spec/requests/auth/senha_redef_spec.rb @@ -3,21 +3,21 @@ RSpec.describe "Redefinição de Senha", type: :request do # Cria um usuário base com a matrícula fictícia para passar nas validações do seu modelo - let!(:usuario) do + let!(:usuario) do Usuario.create!( - email: "usuario@teste.com", - senha: "SenhaAntiga123", - senha_confirmation: "SenhaAntiga123", + email: "usuario@teste.com", + senha: "SenhaAntiga123", + senha_confirmation: "SenhaAntiga123", matricula: "202600123", status: 1 - ) + ) end describe "POST /redefinir-senha" do context "quando o formato do e-mail é inválido" do it "redireciona de volta com mensagem de erro" do post solicitar_redef_senha_path, params: { email: "email_invalido" } - + expect(response).to redirect_to(solicitar_redef_senha_path) expect(flash[:error]).to eq("Por favor, insira um formato de e-mail válido.") end @@ -26,7 +26,7 @@ context "quando o e-mail não está cadastrado" do it "redireciona de volta informando que não existe" do post solicitar_redef_senha_path, params: { email: "nao_existe@teste.com" } - + expect(response).to redirect_to(solicitar_redef_senha_path) expect(flash[:error]).to eq("Este e-mail não está cadastrado no sistema.") end @@ -63,7 +63,7 @@ it "não salva o fluxo com sucesso e exibe mensagem de erro técnico" do post solicitar_redef_senha_path, params: { email: "usuario@teste.com" } - + expect(response).to redirect_to(solicitar_redef_senha_path) expect(flash[:error]).to include("Houve um erro técnico ao tentar enviar o e-mail") end @@ -79,7 +79,7 @@ it "recusa a alteração" do # Ajustado para usar strings de rota diretas, evitando helpers desalinhados post "/redefinir-senha/confirmar", params: { token: "token_secreto_123", senha: "123", senha_confirmacao: "123" } - + expect(response).to redirect_to(redefinir_senha_path(token: "token_secreto_123")) expect(flash[:error]).to eq("A nova senha deve conter pelo menos 6 caracteres.") end @@ -88,7 +88,7 @@ context "quando as senhas não coincidem" do it "recusa a alteração" do post "/redefinir-senha/confirmar", params: { token: "token_secreto_123", senha: "NovaSenha123", senha_confirmacao: "Diferente123" } - + expect(response).to redirect_to(redefinir_senha_path(token: "token_secreto_123")) expect(flash[:error]).to eq("As senhas não coincidem. Digite novamente.") end @@ -97,7 +97,7 @@ context "quando o token é inválido ou expirou" do it "redireciona para a raiz com erro" do post "/redefinir-senha/confirmar", params: { token: "token_inexistente", senha: "NovaSenha123", senha_confirmacao: "NovaSenha123" } - + expect(response).to redirect_to(root_path) expect(flash[:error]).to eq("O link de redefinição é inválido, expirou ou não corresponde a esta operação.") end @@ -106,10 +106,10 @@ context "quando todos os dados são válidos" do it "altera a senha do usuário, destrói o token e redireciona para a home" do post "/redefinir-senha/confirmar", params: { token: "token_secreto_123", senha: "NovaSenha123", senha_confirmacao: "NovaSenha123" } - + expect(response).to redirect_to(root_path) expect(flash[:success]).to eq("Sua senha foi alterada com sucesso! Insira suas novas credenciais para acessar.") - + # Garante que o token consumido foi devidamente deletado expect(Token.find_by(id: token_valido.id)).to be_nil end @@ -120,15 +120,15 @@ # Simula uma falha na hora do usuario.save allow_any_instance_of(Usuario).to receive(:save).and_return(false) # Permite que os erros do modelo retornem uma mensagem controlada - allow_any_instance_of(ActiveModel::Errors).to receive(:full_messages).and_return(["Erro customizado do modelo"]) + allow_any_instance_of(ActiveModel::Errors).to receive(:full_messages).and_return([ "Erro customizado do modelo" ]) end it "redireciona para a página de redefinição exibindo os erros do modelo" do post "/redefinir-senha/confirmar", params: { token: "token_secreto_123", senha: "NovaSenha123", senha_confirmacao: "NovaSenha123" } - + expect(response).to redirect_to(redefinir_senha_path(token: "token_secreto_123")) expect(flash[:error]).to eq("Erro customizado do modelo") end end end -end \ No newline at end of file +end diff --git a/spec/requests/auth/verify_adm_spec.rb b/spec/requests/auth/verify_adm_spec.rb index b7759fbcee..12edb93182 100644 --- a/spec/requests/auth/verify_adm_spec.rb +++ b/spec/requests/auth/verify_adm_spec.rb @@ -8,18 +8,18 @@ describe "GET /dashboard (index)" do it "redireciona para root se o usuário não estiver logado" do allow_any_instance_of(ApplicationController).to receive(:current_user).and_return(nil) - + get avaliacoes_path - + expect(response).to redirect_to(root_path) expect(flash[:error]).to eq("Acesso restrito. Por favor, faça login para continuar.") end it "permite acesso e carrega as turmas se o usuário estiver logado" do allow_any_instance_of(ApplicationController).to receive(:current_user).and_return(usuario_comum) - + get avaliacoes_path - + expect(response).to have_http_status(:success) end end @@ -27,18 +27,18 @@ describe "Acesso ao Gerenciamento (Filtro verificar_admin)" do it "bloqueia e desloga usuário comum que tentar acessar o gerenciamento" do allow_any_instance_of(ApplicationController).to receive(:current_user).and_return(usuario_comum) - + get gerenciamento_path - + expect(response).to redirect_to(root_path) expect(flash[:error]).to eq("Acesso restrito. Por favor, faça login como administrador.") end it "permite que o administrador acesse a página de gerenciamento" do allow_any_instance_of(ApplicationController).to receive(:current_user).and_return(usuario_admin) - + get gerenciamento_path - + expect(response).to have_http_status(:success) end end @@ -46,19 +46,19 @@ describe "Barra Lateral (Sidebar View)" do it "NÃO exibe o link de Gerenciamento para usuários comuns" do allow_any_instance_of(ApplicationController).to receive(:current_user).and_return(usuario_comum) - + get avaliacoes_path - + expect(response.body).not_to include('Gerenciamento') end it "EXIBE o link de Gerenciamento para usuários administradores" do allow_any_instance_of(ApplicationController).to receive(:current_user).and_return(usuario_admin) - + get avaliacoes_path - + expect(response.body).to include('Gerenciamento') expect(response.body).to include(gerenciamento_path) end end -end \ No newline at end of file +end diff --git a/spec/requests/sigaa/enviar_solicitacoes_spec.rb b/spec/requests/sigaa/enviar_solicitacoes_spec.rb new file mode 100644 index 0000000000..068fd01b13 --- /dev/null +++ b/spec/requests/sigaa/enviar_solicitacoes_spec.rb @@ -0,0 +1,124 @@ +require 'rails_helper' + +RSpec.describe "Dashboard - Enviar Solicitações de Cadastro", type: :request do + let!(:departamento) { Departamento.create!(nome: "Ciência da Computação") } + let!(:admin_user) do + Usuario.create!( + nome: "Coordenação de Computação", + email: "admin_cic@unb.br", + matricula: "999999999", + senha: "SenhaSegura123", + senha_confirmation: "SenhaSegura123", + status: 1 + ) + end + let!(:perfil_adm) { PerfilAdm.create!(usuario: admin_user, departamento: departamento) } + + before do + allow(admin_user).to receive(:admin?).and_return(true) + allow_any_instance_of(DashboardController).to receive(:current_user).and_return(admin_user) + end + + describe "POST /dashboard/enviar_solicitacoes" do + context "quando o filtro 'verificar_admin' barra o acesso" do + before do + allow(admin_user).to receive(:admin?).and_return(false) + end + + it "limpa a sessão e redireciona o intruso para a raiz com mensagem restrita" do + post enviar_solicitacoes_path + + expect(response).to redirect_to(root_path) + expect(flash[:error]).to eq("Acesso restrito. Por favor, faça login como administrador.") + end + end + + context "quando o administrador não possui um departamento associado" do + before do + allow(admin_user).to receive(:perfil_adm).and_return(nil) + end + + it "redireciona para a tela de gerenciamento exibindo o erro de associação" do + post enviar_solicitacoes_path + + expect(response).to redirect_to(gerenciamento_path) + expect(flash[:error]).to eq("Seu usuário não possui um departamento associado.") + end + end + + context "quando não há nenhum usuário pendente no departamento" do + it "redireciona informando que a lista de pendências está limpa" do + post enviar_solicitacoes_path + + expect(response).to redirect_to(gerenciamento_path) + expect(flash[:notice]).to eq("Não há usuários pendentes de cadastro (docentes ou discentes) neste departamento.") + end + end + + context "quando existem usuários pendentes de cadastro (status = 0)" do + let!(:docente_pendente) do + Usuario.create!( + nome: "Professor Substituto", + email: "docente_teste@unb.br", + matricula: "202611111", + senha: "SenhaTemporaria1", + senha_confirmation: "SenhaTemporaria1", + status: 0 + ) + end + + let!(:discente_pendente) do + Usuario.create!( + nome: "Aluno Voluntário", + email: "discente_teste@unb.br", + matricula: "202622222", + senha: "SenhaTemporaria2", + senha_confirmation: "SenhaTemporaria2", + status: 0 + ) + end + + before do + PerfilDocente.create!(usuario: docente_pendente, departamento: departamento) + materia = Materia.create!(nome: "Estruturas de Dados", codigo: "CIC0001", departamento_id: departamento.id) + turma = Turma.create!(materia: materia, numero: 1, ano: 2026, semestre: 1) + ParticipacaoTurma.create!(usuario: discente_pendente, turma: turma) + end + + context "e todos os envios de e-mail ocorrem perfeitamente" do + before do + allow_any_instance_of(DashboardController).to receive(:enviar_email_convite_admin).and_return(true) + end + + it "gera um token de cadastro para cada um e redireciona com mensagem de total sucesso" do + expect { + post enviar_solicitacoes_path + }.to change(Token, :count).by(2) + + expect(response).to redirect_to(gerenciamento_path) + expect(flash[:success]).to eq("Convites enviados com sucesso para os 2 usuários do departamento!") + expect(docente_pendente.tokens.last.tipo).to eq("cadastro") + expect(discente_pendente.tokens.last.tipo).to eq("cadastro") + end + end + + context "e o serviço de e-mail falha ao processar algum usuário" do + before do + allow_any_instance_of(DashboardController).to receive(:enviar_email_convite_admin) do |_, email, _, _| + email == "discente_teste@unb.br" + end + end + + it "executa o rollback do token que falhou e anexa o log detalhado no flash" do + post enviar_solicitacoes_path + + expect(response).to redirect_to(gerenciamento_path) + expect(flash[:error]).to eq("O envio foi concluído com instabilidades. Foram enviados 1 e-mails.") + expect(flash[:error_list]).to include("Professor Substituto (Matrícula: 202611111): Falha de comunicação com a Brevo.") + expect(discente_pendente.tokens.count).to eq(1) + expect(docente_pendente.tokens.count).to eq(0) + end + end + end + end +end From c35dfedf7651bcba33dc303b5ecc68c949e4a9b5 Mon Sep 17 00:00:00 2001 From: Rafael-Sapienza Date: Tue, 16 Jun 2026 19:42:48 -0300 Subject: [PATCH 077/125] test(dashboard): adicionar testes de request para envio de solicitacoes por administrador --- spec/requests/sigaa/importar_dados_spec.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/requests/sigaa/importar_dados_spec.rb b/spec/requests/sigaa/importar_dados_spec.rb index 9e6e674789..0240b61e82 100644 --- a/spec/requests/sigaa/importar_dados_spec.rb +++ b/spec/requests/sigaa/importar_dados_spec.rb @@ -73,7 +73,7 @@ "nome" => "Rafael Sapienza", "email" => "rafael@unb.br", "turmas_matriculadas" => [ - { "materia_codigo" => "CIC0001", "numero_turma" => 999 } + { "materia_codigo" => "CIC0001", "numero_turma" => 999 } ] } ] @@ -88,10 +88,10 @@ expect(response).to redirect_to(gerenciamento_path) expect(flash[:error]).to eq("A importação foi concluída parcialmente.") - + expect(flash[:error_list]).to include( "Discente Rafael Sapienza (Matrícula: 26100001): Turma nº 999 da matéria 'Estruturas de Dados' não foi localizada no sistema." ) end end -end \ No newline at end of file +end From 786f2f745857d29eadacaf1f3f6b1edf5bc58a12 Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Tue, 16 Jun 2026 23:44:06 -0300 Subject: [PATCH 078/125] =?UTF-8?q?fix(sprint-2):=20corre=C3=A7=C3=B5es=20?= =?UTF-8?q?dos=20merges=20para=20deixar=20o=20projeto=20funcional=20e=20te?= =?UTF-8?q?st=C3=A1vel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Gemfile | 4 +- Gemfile.lock | 6 +- app/controllers/application_controller.rb | 28 ++- app/controllers/auth_controller.rb | 191 +++++++++++++----- app/controllers/avaliacoes_controller.rb | 44 ++-- app/controllers/concerns/brevo_emailable.rb | 36 +++- app/controllers/dashboard_controller.rb | 25 ++- app/controllers/formularios_controller.rb | 120 +++++------ .../sigaa/password_requests_controller.rb | 6 +- app/controllers/templates_controller.rb | 10 +- app/models/token.rb | 13 +- app/models/usuario.rb | 5 + app/policies/application_policy.rb | 4 +- app/policies/template_policy.rb | 10 +- app/services/sigaa/unavailable_error.rb | 2 +- app/views/auth/cadastrar.html.erb | 14 +- app/views/auth/index.html.erb | 18 +- app/views/auth/redefinir_senha.html.erb | 14 +- app/views/auth/solicitar_cadastro.html.erb | 16 +- app/views/auth/solicitar_redef_senha.html.erb | 12 +- app/views/avaliacoes/pendentes.html.erb | 6 +- app/views/avaliacoes/responder.html.erb | 10 +- app/views/dashboard/gerenciamento.html.erb | 18 +- app/views/dashboard/index.html.erb | 14 +- app/views/formularios/show.html.erb | 2 +- app/views/gerenciamentos/show.html.erb | 2 +- .../dashboard_shared/_sidebar.html.erb | 4 +- config/environments/development.rb | 19 ++ config/environments/test.rb | 19 ++ config/routes.rb | 71 +++---- ...iar_estrutura_inicial_para_autenticacao.rb | 71 +------ db/schema.rb | 15 +- db/seeds.rb | 2 +- .../application_feature_steps.rb | 168 +-------------- .../step_definitions/autenticacao_steps.rb | 4 - .../step_definitions/authentication_steps.rb | 10 +- features/step_definitions/avaliacoes_steps.rb | 102 +--------- features/step_definitions/domain_steps.rb | 80 -------- .../step_definitions/exportar_csv_steps.rb | 113 ++++++----- .../step_definitions/formularios_steps.rb | 47 +++-- .../responder_avaliacao_steps.rb | 70 +++---- features/step_definitions/template_steps.rb | 8 +- features/support/camaar_world.rb | 16 +- features/support/test_data_helpers.rb | 13 +- spec/requests/auth/autenticacao_spec.rb | 8 +- spec/requests/auth/cadastro_spec.rb | 12 +- spec/requests/auth/senha_redef_spec.rb | 17 +- spec/requests/auth/verify_adm_spec.rb | 30 ++- spec/requests/avaliacoes_spec.rb | 4 +- spec/requests/formularios_spec.rb | 6 +- .../sigaa/enviar_solicitacoes_spec.rb | 11 +- spec/requests/sigaa/importar_dados_spec.rb | 19 +- spec/requests/templates_spec.rb | 20 +- .../formularios/create_from_template_spec.rb | 5 +- spec/support/authentication_helpers.rb | 2 +- spec/support/test_builders.rb | 15 +- 56 files changed, 738 insertions(+), 873 deletions(-) delete mode 100644 features/step_definitions/autenticacao_steps.rb diff --git a/Gemfile b/Gemfile index a8cac49b86..c4a1ba7011 100644 --- a/Gemfile +++ b/Gemfile @@ -17,7 +17,7 @@ gem "stimulus-rails" # Build JSON APIs with ease [https://github.com/rails/jbuilder] gem "jbuilder" -gem 'csv' +gem "csv" # Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] gem "bcrypt", "~> 3.1.7" @@ -50,7 +50,7 @@ group :development, :test do gem "bundler-audit", require: false # Static analysis for security vulnerabilities [https://brakemanscanner.org/] - gem "brakeman", require: false + gem "brakeman", "~> 8.0.5", require: false # Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/] gem "rubocop-rails-omakase", require: false diff --git a/Gemfile.lock b/Gemfile.lock index b2705ccecd..85c82379a2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -96,7 +96,7 @@ GEM bindex (0.8.1) bootsnap (1.24.3) msgpack (~> 1.2) - brakeman (8.0.4) + brakeman (8.0.5) racc builder (3.3.0) bundler-audit (0.9.3) @@ -561,7 +561,7 @@ PLATFORMS DEPENDENCIES bcrypt (~> 3.1.7) bootsnap - brakeman + brakeman (~> 8.0.5) bundler-audit capybara csv @@ -615,7 +615,7 @@ CHECKSUMS bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e bootsnap (1.24.3) sha256=f7fa3d20597e2f0aa52b0a1aba83fb54d4f79e9c2e210ec4fa1e8895514dcad8 - brakeman (8.0.4) sha256=7bf921fa9638544835df9aa7b3e720a9a72c0267f34f92135955edd80d4dcf6f + brakeman (8.0.5) sha256=03735f9690d3fd4b32d66aacbf0a6d15a84266bdd06b32c05c8ecc8f6021d2be builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f bundler-audit (0.9.3) sha256=81c8766c71e47d0d28a0f98c7eed028539f21a6ea3cd8f685eb6f42333c9b4e9 capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index ed4d7d7621..0dd7c11f0b 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -8,26 +8,34 @@ class NotAuthorizedError < StandardError; end rescue_from NotAuthorizedError, with: :usuario_nao_autorizado - helper_method :current_usuario - helper_method :current_adm + helper_method :current_user + helper_method :current_administrador helper_method :policy private - def current_usuario - @current_usuario ||= Usuario.find_by(id: session[:usuario_id]) + def current_user + @current_user ||= Usuario.find_by(id: session[:usuario_id]) end - def current_adm - current_usuario&.perfil_adm + def current_administrador + current_user&.perfil_adm end - def authenticate_usuario! - return if current_usuario.present? + def authenticate_user! + return if current_user.present? redirect_to login_path, alert: "Você precisa estar logado para acessar esta página." end + def require_administrador! + authenticate_user! + return if performed? + return if current_user&.administrador? + + redirect_to root_path, alert: "Acesso não autorizado" + end + def authorize!(record, query = nil) query ||= "#{action_name}?" @@ -37,11 +45,11 @@ def authorize!(record, query = nil) end def policy(record) - policy_class_for(record).new(current_usuario, record) + policy_class_for(record).new(current_user, record) end def policy_scope(scope) - policy_class_for(scope).scope(current_usuario, scope) + policy_class_for(scope).scope(current_user, scope) end def policy_class_for(record_or_scope) diff --git a/app/controllers/auth_controller.rb b/app/controllers/auth_controller.rb index c5c14a58a8..806236e538 100644 --- a/app/controllers/auth_controller.rb +++ b/app/controllers/auth_controller.rb @@ -1,18 +1,22 @@ +# frozen_string_literal: true + +require "json" require "net/http" require "uri" -require "json" class AuthController < ApplicationController include BrevoEmailable - before_action :impedir_se_logado, only: [ :solicitar_cadastro, :cadastrar, :solicitar_redef_senha, :redefinir_senha ] - before_action :validar_token_via_url, only: [ :cadastrar, :redefinir_senha ] + + before_action :impedir_se_logado, + only: %i[solicitar_cadastro cadastrar solicitar_redef_senha redefinir_senha] + before_action :validar_token_via_url, only: %i[cadastrar redefinir_senha] def index - if current_user.present? - redirect_to avaliacoes_path, flash: { notice: "Você já está conectado no sistema. Se quiser sair, faça log out" } and return - end - end + return unless current_user.present? + redirect_to avaliacoes_path, + flash: { notice: "Você já está conectado no sistema. Se quiser sair, faça log out" } + end def solicitar_cadastro end @@ -29,133 +33,214 @@ def redefinir_senha def logout session.clear @current_user = nil + redirect_to root_path, flash: { success: "Sessão encerrada com sucesso." } end def login - identificador = params[:identificador].to_s.strip - senha = params[:senha] - email_regex = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i - if identificador.match?(email_regex) - usuario = Usuario.find_by(email: identificador) - else - usuario = Usuario.find_by(matricula: identificador) - end + usuario = buscar_usuario_por_identificador(params[:identificador]) + if usuario.nil? - redirect_to root_path, flash: { error: "Matrícula ou e-mail inválido." } and return + redirect_to root_path, flash: { error: "Matrícula ou e-mail inválido." } + return end - if usuario.status != 1 - redirect_to root_path, flash: { error: "Esta conta ainda não foi ativada. Por favor, realize o Primeiro Acesso." } and return + + unless usuario.ativo? + redirect_to root_path, + flash: { error: "Esta conta ainda não foi ativada. Por favor, realize o Primeiro Acesso." } + return end - if usuario.authenticate_senha(senha) + + if usuario.authenticate_senha(params[:senha]) session[:usuario_id] = usuario.id redirect_to avaliacoes_path, flash: { success: "Login realizado com sucesso! Seja bem-vindo." } else redirect_to root_path, flash: { error: "Senha incorreta." } end end + def processar_solicitacao_cadastro - return redirecionar_com_erro(cadastro_path, "Por favor, insira um formato de e-mail válido.") unless email_valido?(params[:email]) + unless email_valido?(params[:email]) + return redirecionar_com_erro(cadastro_path, "Por favor, insira um formato de e-mail válido.") + end + usuario = Usuario.find_by(matricula: params[:matricula]) return redirecionar_com_erro(cadastro_path, "Matrícula não encontrada no sistema institucional.") if usuario.nil? + if usuario.email.to_s.downcase.strip != params[:email].to_s.downcase.strip - return redirecionar_com_erro(cadastro_path, "O e-mail informado não corresponde ao e-mail institucional desta matrícula.") + return redirecionar_com_erro( + cadastro_path, + "O e-mail informado não corresponde ao e-mail institucional desta matrícula." + ) end - return redirecionar_com_erro(cadastro_path, "Esta matrícula já possui um cadastro ativo. Caso tenha esquecido sua senha, utilize a redefinição.") if usuario.status == 1 + + if usuario.ativo? + return redirecionar_com_erro( + cadastro_path, + "Esta matrícula já possui um cadastro ativo. Caso tenha esquecido sua senha, utilize a redefinição." + ) + end + token_gerado = SecureRandom.hex(16) usuario.tokens.create!( - value: token_gerado, - tipo: "cadastro", + value: token_gerado, + tipo: "cadastro", expires_at: 10.minutes.from_now ) + if enviar_email_cadastro(usuario.email, token_gerado) redirect_to root_path, flash: { success: mensagem_email_enviado("10 minutos") } else - redirect_to cadastro_path, flash: { error: "Houve um erro técnico ao tentar enviar o e-mail. Tente novamente mais tarde." } + redirect_to cadastro_path, + flash: { error: "Houve um erro técnico ao tentar enviar o e-mail. Tente novamente mais tarde." } end end + def confirmar_cadastro - return redirecionar_com_erro(confirmar_cadastro_path(token: params[:token]), "A senha deve conter pelo menos 6 caracteres.") if params[:senha].length < 6 - return redirecionar_com_erro(confirmar_cadastro_path(token: params[:token]), "As senhas não coincidem. Digite novamente.") if params[:senha] != params[:senha_confirmacao] + if params[:senha].length < 6 + return redirecionar_com_erro( + confirmar_cadastro_path(token: params[:token]), + "A senha deve conter pelo menos 6 caracteres." + ) + end + + if params[:senha] != params[:senha_confirmacao] + return redirecionar_com_erro( + confirmar_cadastro_path(token: params[:token]), + "As senhas não coincidem. Digite novamente." + ) + end + token_registro = buscar_token_valido(params[:token], "cadastro") - return redirecionar_com_erro(root_path, "O link de confirmação é inválido, expirou ou não corresponde a esta operação.") if token_registro.nil? + if token_registro.nil? + return redirecionar_com_erro( + root_path, + "O link de confirmação é inválido, expirou ou não corresponde a esta operação." + ) + end + usuario = token_registro.usuario - usuario.senha = params[:senha] + usuario.senha = params[:senha] usuario.senha_confirmation = params[:senha_confirmacao] - usuario.status = 1 + usuario.status = :ativo if usuario.save token_registro.destroy redirect_to root_path, flash: { success: "Cadastro concluído com sucesso! Faça seu login." } else - redirect_to confirmar_cadastro_path(token: params[:token]), flash: { error: usuario.errors.full_messages.to_sentence } + redirect_to confirmar_cadastro_path(token: params[:token]), + flash: { error: usuario.errors.full_messages.to_sentence } end end + def processar_redefinicao_senha - return redirecionar_com_erro(solicitar_redef_senha_path, "Por favor, insira um formato de e-mail válido.") unless email_valido?(params[:email]) + unless email_valido?(params[:email]) + return redirecionar_com_erro(solicitar_redef_senha_path, "Por favor, insira um formato de e-mail válido.") + end + usuario = Usuario.find_by(email: params[:email]) return redirecionar_com_erro(solicitar_redef_senha_path, "Este e-mail não está cadastrado no sistema.") if usuario.nil? + token_gerado = SecureRandom.hex(16) usuario.tokens.create!( - value: token_gerado, - tipo: "redefinicao", + value: token_gerado, + tipo: "redefinicao", expires_at: 10.minutes.from_now ) + if enviar_email_redefinicao(params[:email], token_gerado) redirect_to root_path, flash: { success: mensagem_email_enviado("10 minutos") } else - redirect_to solicitar_redef_senha_path, flash: { error: "Houve um erro técnico ao tentar enviar o e-mail de recuperação. Tente novamente mais tarde." } + redirect_to solicitar_redef_senha_path, + flash: { error: "Houve um erro técnico ao tentar enviar o e-mail de recuperação. Tente novamente mais tarde." } end end + def confirmar_redefinicao_senha - return redirecionar_com_erro(redefinir_senha_path(token: params[:token]), "A nova senha deve conter pelo menos 6 caracteres.") if params[:senha].length < 6 - return redirecionar_com_erro(redefinir_senha_path(token: params[:token]), "As senhas não coincidem. Digite novamente.") if params[:senha] != params[:senha_confirmacao] + if params[:senha].length < 6 + return redirecionar_com_erro( + redefinir_senha_path(token: params[:token]), + "A nova senha deve conter pelo menos 6 caracteres." + ) + end + + if params[:senha] != params[:senha_confirmacao] + return redirecionar_com_erro( + redefinir_senha_path(token: params[:token]), + "As senhas não coincidem. Digite novamente." + ) + end + token_registro = buscar_token_valido(params[:token], "redefinicao") - return redirecionar_com_erro(root_path, "O link de redefinição é inválido, expirou ou não corresponde a esta operação.") if token_registro.nil? + if token_registro.nil? + return redirecionar_com_erro( + root_path, + "O link de redefinição é inválido, expirou ou não corresponde a esta operação." + ) + end + usuario = token_registro.usuario - usuario.senha = params[:senha] + usuario.senha = params[:senha] usuario.senha_confirmation = params[:senha_confirmacao] if usuario.save token_registro.destroy - redirect_to root_path, flash: { success: "Sua senha foi alterada com sucesso! Insira suas novas credenciais para acessar." } + redirect_to root_path, + flash: { success: "Sua senha foi alterada com sucesso! Insira suas novas credenciais para acessar." } else - redirect_to redefinir_senha_path(token: params[:token]), flash: { error: usuario.errors.full_messages.to_sentence } + redirect_to redefinir_senha_path(token: params[:token]), + flash: { error: usuario.errors.full_messages.to_sentence } end end -private + private + def impedir_se_logado - if current_user.present? - redirect_to avaliacoes_path, flash: { error: "Você já está logado em outra sessão. Faça o logout antes de poder terminar essa ação." } - end + return unless current_user.present? + + redirect_to avaliacoes_path, + flash: { error: "Você já está logado em outra sessão. Faça o logout antes de poder terminar essa ação." } end + def validar_token_via_url tipo_esperado = action_name == "cadastrar" ? "cadastro" : "redefinicao" token = buscar_token_valido(params[:token], tipo_esperado) - if token.nil? - redirect_to root_path, flash: { error: "Token inválido ou expirado." } + redirect_to root_path, flash: { error: "Token inválido ou expirado." } if token.nil? + end + + def buscar_usuario_por_identificador(identificador) + identificador = identificador.to_s.strip + + if email_valido?(identificador) + Usuario.find_by(email: identificador) + else + Usuario.find_by(matricula: identificador) end end + def email_valido?(email) email_regex = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i email.present? && email.match?(email_regex) end + def buscar_token_valido(valor, tipo_esperado) token = Token.find_by(value: valor, tipo: tipo_esperado) return nil if token.nil? || token.expires_at < Time.current + token end + def redirecionar_com_erro(caminho, mensagem) redirect_to caminho, flash: { error: mensagem } end + def mensagem_email_enviado(validade) - <<~HTML - E-mail enviado com sucesso! Caso não o veja na caixa de entrada, cheque sua caixa de spam. -

- ⚠️ O link enviado terá validade de #{validade}. -

- HTML + <<~HTML + E-mail enviado com sucesso! Caso não o veja na caixa de entrada, cheque sua caixa de spam. +

+ ⚠️ O link enviado terá validade de #{validade}. +

+ HTML end end diff --git a/app/controllers/avaliacoes_controller.rb b/app/controllers/avaliacoes_controller.rb index 1ca88d2187..6e688e86ab 100644 --- a/app/controllers/avaliacoes_controller.rb +++ b/app/controllers/avaliacoes_controller.rb @@ -1,25 +1,23 @@ +# frozen_string_literal: true + class AvaliacoesController < ApplicationController - before_action :require_login + before_action :authenticate_user! before_action :set_avaliacao, only: %i[responder submeter] # GET /avaliacoes/pendentes def pendentes - turmas_ids = ParticipacaoTurma - .where(usuario: current_usuario) - .pluck(:turma_id) - @avaliacoes_pendentes = Avaliacao - .pendentes - .joins(:participacao_turma) - .where(participacoes_turmas: { turma_id: turmas_ids }) - .includes(formulario: { turma: :materia }) + .pendentes + .joins(:participacao_turma) + .where(participacoes_turmas: { usuario_id: current_user.id }) + .includes(formulario: { turma: :materia }) end # GET /avaliacoes/:id/responder def responder if @avaliacao.respondida? - redirect_to pendentes_avaliacoes_path, - alert: 'Esta avaliação já foi respondida.' + redirect_to avaliacoes_pendentes_path, + alert: "Esta avaliação já foi respondida." return end @@ -30,8 +28,8 @@ def responder # POST /avaliacoes/:id/submeter def submeter if @avaliacao.respondida? - redirect_to pendentes_avaliacoes_path, - alert: 'Esta avaliação já foi respondida.' + redirect_to avaliacoes_pendentes_path, + alert: "Esta avaliação já foi respondida." return end @@ -41,26 +39,22 @@ def submeter if todas_obrigatorias_preenchidas? salvar_respostas_e_finalizar else - flash.now[:alert] = 'Todas as questões obrigatórias devem ser preenchidas.' + flash.now[:alert] = "Todas as questões obrigatórias devem ser preenchidas." render :responder, status: :unprocessable_entity end end private - def require_login - redirect_to root_path, alert: 'Usuário não autenticado' unless current_usuario - end - def set_avaliacao participacao_ids = ParticipacaoTurma - .where(usuario: current_usuario) + .where(usuario: current_user) .pluck(:id) @avaliacao = Avaliacao.find_by!(id: params[:id], participacao_turma_id: participacao_ids) rescue ActiveRecord::RecordNotFound - redirect_to pendentes_avaliacoes_path, alert: 'Avaliação não encontrada.' + redirect_to avaliacoes_pendentes_path, alert: "Avaliação não encontrada." end def questoes_do_formulario @@ -69,7 +63,7 @@ def questoes_do_formulario template.questoes .includes(:opcoes) - .order('utilizacoes_questoes.numero') + .order("utilizacoes_questoes.numero") end def todas_obrigatorias_preenchidas? @@ -108,10 +102,10 @@ def salvar_respostas_e_finalizar @avaliacao.marcar_como_respondida! end - redirect_to pendentes_avaliacoes_path, - notice: 'Avaliação registrada com sucesso.' + redirect_to avaliacoes_pendentes_path, + notice: "Avaliação registrada com sucesso." rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotFound => e - flash.now[:alert] = 'Todas as questões obrigatórias devem ser preenchidas.' + flash.now[:alert] = "Todas as questões obrigatórias devem ser preenchidas." render :responder, status: :unprocessable_entity end -end \ No newline at end of file +end diff --git a/app/controllers/concerns/brevo_emailable.rb b/app/controllers/concerns/brevo_emailable.rb index 37bf0ee3fc..9ebec74946 100644 --- a/app/controllers/concerns/brevo_emailable.rb +++ b/app/controllers/concerns/brevo_emailable.rb @@ -1,19 +1,28 @@ +# frozen_string_literal: true + module BrevoEmailable extend ActiveSupport::Concern - BREVO_API_KEY = Rails.application.credentials.dig(:brevo, :api_key) + BREVO_API_URL = URI("https://api.brevo.com/v3/smtp/email") - REMETENTE = { "name" => "CAMAAR Support", "email" => "rafaelsapienzapinheiro@gmail.com" }.freeze + REMETENTE = { "name" => "CAMAAR Support", "email" => "rafaelsapienzapinheiro@gmail.com" }.freeze + def chamar_api_brevo(payload, contexto: "") + api_key = brevo_api_key + unless api_key.present? + Rails.logger.error "[BREVO] #{contexto} — Token de API não configurado." + return false + end + headers = { - "Accept" => "application/json", - "api-key" => BREVO_API_KEY, + "Accept" => "application/json", + "api-key" => api_key, "Content-Type" => "application/json" } - http = Net::HTTP.new(BREVO_API_URL.host, BREVO_API_URL.port) - http.use_ssl = true - request = Net::HTTP::Post.new(BREVO_API_URL.path, headers) - request.body = payload.to_json + http = Net::HTTP.new(BREVO_API_URL.host, BREVO_API_URL.port) + http.use_ssl = true + request = Net::HTTP::Post.new(BREVO_API_URL.path, headers) + request.body = payload.to_json response = http.request(request) @@ -28,6 +37,7 @@ def chamar_api_brevo(payload, contexto: "") Rails.logger.error "[BREVO] #{contexto} — Erro inesperado: #{e.message}" false end + def enviar_email_cadastro(destinatario, token) url = "http://127.0.0.1:3000/cadastro/confirmar/?token=#{token}" @@ -61,6 +71,7 @@ def enviar_email_cadastro(destinatario, token) chamar_api_brevo(payload, contexto: "Cadastro") end + def enviar_email_redefinicao(destinatario, token) url = "http://127.0.0.1:3000/redefinir-senha/confirmar/?token=#{token}" @@ -94,6 +105,7 @@ def enviar_email_redefinicao(destinatario, token) chamar_api_brevo(payload, contexto: "Redefinição de senha") end + def enviar_email_convite_admin(destinatario, token, nome_admin) url = "http://127.0.0.1:3000/cadastro/confirmar/?token=#{token}" @@ -126,4 +138,12 @@ def enviar_email_convite_admin(destinatario, token, nome_admin) } chamar_api_brevo(payload, contexto: "Convite do Administrador") end + + private + + def brevo_api_key + ENV["BREVO_API_KEY"].presence || Rails.application.credentials.dig(:brevo, :api_key) + rescue ActiveSupport::MessageEncryptor::InvalidMessage, ActiveSupport::MessageVerifier::InvalidSignature + nil + end end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 3e4f3218be..4991679183 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -1,5 +1,6 @@ -require "json" +# frozen_string_literal: true +require "json" class DashboardController < ApplicationController include BrevoEmailable @@ -9,6 +10,7 @@ def index if current_user.nil? redirect_to root_path, flash: { error: "Acesso restrito. Por favor, faça login para continuar." } and return end + @turmas_simuladas = [ { materia: "Estruturas de Dados", semestre: "2026.1", professor: "Alessandro Silva" }, { materia: "Bancos de Dados", semestre: "2026.1", professor: "Alessandro Silva" }, @@ -31,7 +33,7 @@ def enviar_solicitacoes discentes_pendentes = Usuario.joins(:participacoes_turma) .where(status: 0, participacoes_turma: { turma_id: turmas_do_departamento_ids }) docentes_pendentes = Usuario.joins(:perfil_docente) - .where(status: 0, perfil_docente: { departamento_id: depto_id }) + .where(status: 0, perfis_docentes: { departamento_id: depto_id }) usuarios_pendentes = (discentes_pendentes + docentes_pendentes).uniq if usuarios_pendentes.empty? @@ -55,7 +57,7 @@ def enviar_solicitacoes else raise "Falha de comunicação com a Brevo." end - rescue => e + rescue StandardError => e erros_envio << "#{usuario.nome} (Matrícula: #{usuario.matricula}): #{e.message}" raise ActiveRecord::Rollback end @@ -91,7 +93,7 @@ def importar_dados materia.departamento_id = materia_json["departamento_id_temp"] materia.save! end - rescue => e + rescue StandardError => e erros_importacao << "Matéria #{materia_json['nome']} (Código: #{codigo}): #{e.message}" end ActiveRecord::Base.transaction do @@ -125,7 +127,7 @@ def importar_dados perfil.departamento_id = docente_json["departamento_id_temp"] perfil.save! end - rescue => e + rescue StandardError => e erros_importacao << "Docente #{docente_json['nome']} (Matrícula: #{matricula}): #{e.message}" end dados["usuarios_discentes"]&.each do |discente_json| @@ -154,11 +156,15 @@ def importar_dados end turmas_aluno_ids << turma.id - ParticipacaoTurma.find_or_create_by!(usuario_id: usuario.id, turma_id: turma.id) + ParticipacaoTurma.find_or_create_by!( + usuario_id: usuario.id, + turma_id: turma.id, + tipo_participacao: :discente + ) end usuario.participacoes_turma.where.not(turma_id: turmas_aluno_ids).destroy_all end - rescue => e + rescue StandardError => e erros_importacao << "Discente #{discente_json['nome']} (Matrícula: #{matricula}): #{e.message}" end ActiveRecord::Base.transaction do @@ -166,7 +172,8 @@ def importar_dados Materia.where.not(codigo: codigos_materias_ativos).destroy_all usuarios_para_remover = Usuario.where.not(matricula: matriculas_ativas_json) usuarios_para_remover.each do |usuario| - next if usuario.admin? + next if usuario.administrador? + usuario.destroy! end end @@ -180,7 +187,7 @@ def importar_dados private def verificar_admin - if current_user.nil? || !current_user.admin? + if current_user.nil? || !current_user.administrador? session.clear @current_user = nil redirect_to root_path, flash: { error: "Acesso restrito. Por favor, faça login como administrador." } diff --git a/app/controllers/formularios_controller.rb b/app/controllers/formularios_controller.rb index 2fdedb3167..173f279eb1 100644 --- a/app/controllers/formularios_controller.rb +++ b/app/controllers/formularios_controller.rb @@ -1,67 +1,15 @@ -require 'csv' +# frozen_string_literal: true -class FormulariosController < ApplicationController - before_action :require_login - - def exportar_csv - @formulario = Formulario.find(params[:id]) - - unless current_usuario&.perfil_adm - redirect_to pendentes_avaliacoes_path, alert: 'Apenas administradores possuem acesso a este recurso' - return - end - - avaliacoes = @formulario.avaliacoes - .joins(:respostas) - .distinct - .includes(participacao_turma: { usuario: :perfil_discente }, - respostas: [ :questao, :texto, { opcoes_escolhidas: :opcao } ]) - - questoes = @formulario.template.questoes.order('utilizacoes_questoes.numero') - - csv_data = CSV.generate(headers: true, col_sep: ';') do |csv| - cabecalho = ['Aluno', 'Matrícula'] + questoes.map(&:enunciado) - csv << cabecalho - - avaliacoes.each do |avaliacao| - usuario = avaliacao.participacao_turma.usuario - matricula = usuario.perfil_discente&.matricula || 'N/A' - - linha = [usuario.nome, matricula] - - questoes.each do |questao| - resposta = avaliacao.respostas.find { |r| r.questao_id == questao.id } - - if resposta.nil? - linha << 'Sem resposta' - elsif questao.discursiva? - linha << resposta.texto&.texto.to_s.strip # Busca o texto dentro do objeto Texto - else - linha << resposta.opcoes_escolhidas.map { |oe| oe.opcao.texto }.join(', ') - end - end - - csv << linha - end - end +require "csv" - send_data csv_data, - filename: "resultados_turma_#{@formulario.turma.materia.codigo}_#{Date.today}.csv", - type: 'text/csv; charset=utf-8' - end - - private - - def require_login - redirect_to "/", alert: 'Usuário não autenticado' unless current_usuario - end -end class FormulariosController < ApplicationController - before_action :require_admin! + before_action :authenticate_user! + before_action :require_administrador!, except: :exportar_csv + before_action :require_administrador_para_exportacao!, only: :exportar_csv def index @formularios = Formulario - .do_departamento(current_usuario.perfil_adm.departamento) + .do_departamento(current_administrador.departamento) .do_semestre_atual .recentes .includes(:template, turma: :materia) @@ -110,13 +58,13 @@ def create template_id: preparacao["template_id"], turma_ids: preparacao["turma_ids"], publico_alvo: params[:publico_alvo], - perfil_adm: current_usuario.perfil_adm + perfil_adm: current_administrador ) session.delete(:formulario_preparacao) redirect_to new_formulario_path, - notice: "Formulário criado com sucesso para as turmas selecionadas" + notice: "Formulário criado com sucesso para as turmas selecionadas" rescue Formularios::Error => e if e.message == Formularios::CreateFromTemplate::SEM_PUBLICO_ALVO redirect_to publicar_formularios_path, alert: e.message @@ -125,4 +73,56 @@ def create redirect_to new_formulario_path, alert: e.message end end + + def exportar_csv + @formulario = Formulario.find(params[:id]) + avaliacoes = @formulario.avaliacoes + .joins(:respostas) + .distinct + .includes( + participacao_turma: :usuario, + respostas: [ :questao, :texto, { opcoes_escolhidas: :opcao } ] + ) + + questoes = @formulario.template.questoes.order("utilizacoes_questoes.numero") + csv_data = CSV.generate(headers: true, col_sep: ";") do |csv| + csv << [ "Aluno", "Matrícula", *questoes.map(&:enunciado) ] + + avaliacoes.each do |avaliacao| + csv << linha_csv(avaliacao, questoes) + end + end + + send_data csv_data, + filename: "resultados_turma_#{@formulario.turma.materia.codigo}_#{Date.current}.csv", + type: "text/csv; charset=utf-8" + end + + private + + def require_administrador_para_exportacao! + return if current_user&.administrador? + + redirect_to avaliacoes_pendentes_path, + alert: "Apenas administradores possuem acesso a este recurso" + end + + def linha_csv(avaliacao, questoes) + usuario = avaliacao.participacao_turma.usuario + linha = [ usuario.nome, usuario.matricula.presence || "N/A" ] + + questoes.each do |questao| + resposta = avaliacao.respostas.find { |item| item.questao_id == questao.id } + linha << valor_resposta_csv(resposta, questao) + end + + linha + end + + def valor_resposta_csv(resposta, questao) + return "Sem resposta" if resposta.nil? + return resposta.texto&.texto.to_s.strip if questao.discursiva? + + resposta.opcoes_escolhidas.map { |opcao_escolhida| opcao_escolhida.opcao.texto }.join(", ") + end end diff --git a/app/controllers/sigaa/password_requests_controller.rb b/app/controllers/sigaa/password_requests_controller.rb index f5bcd88416..6a99a0aad3 100644 --- a/app/controllers/sigaa/password_requests_controller.rb +++ b/app/controllers/sigaa/password_requests_controller.rb @@ -1,6 +1,8 @@ -module Sigaa +# frozen_string_literal: true + +module SIGAA class PasswordRequestsController < ApplicationController - before_action :require_admin! + before_action :require_administrador! def create result = PasswordRequestService.new.call diff --git a/app/controllers/templates_controller.rb b/app/controllers/templates_controller.rb index 6c0f5759a5..9ba09ee566 100644 --- a/app/controllers/templates_controller.rb +++ b/app/controllers/templates_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class TemplatesController < ApplicationController - before_action :authenticate_usuario! + before_action :authenticate_user! before_action :set_template, only: %i[show edit update destroy] def index @@ -11,8 +11,8 @@ def index .includes(adm: :usuario) .recentes - @user_templates = templates.criados_por(current_adm) - @other_templates = templates.criados_por_outros(current_adm) + @user_templates = templates.criados_por(current_administrador) + @other_templates = templates.criados_por_outros(current_administrador) end def show @@ -20,7 +20,7 @@ def show end def new - @template = Template.new(adm: current_adm) + @template = Template.new(adm: current_administrador) preparar_campos_do_formulario authorize! @template @@ -28,7 +28,7 @@ def new def create @template = Template.new(template_params) - @template.adm = current_adm + @template.adm = current_administrador authorize! @template diff --git a/app/models/token.rb b/app/models/token.rb index ff7afefb46..2d4bca3625 100644 --- a/app/models/token.rb +++ b/app/models/token.rb @@ -1,8 +1,15 @@ +# frozen_string_literal: true + class Token < ApplicationRecord - # Diz para o Rails buscar o usuário usando a coluna 'matricula_aluno' contra a 'matricula' do Usuário - belongs_to :usuario, foreign_key: :matricula_aluno, primary_key: :matricula + belongs_to :usuario, + class_name: "Usuario", + inverse_of: :tokens + + validates :usuario, presence: true + validates :value, presence: true, uniqueness: true + validates :tipo, presence: true + validates :expires_at, presence: true - # Um método helper simples para checar se o token já venceu def expirado? expires_at < Time.current end diff --git a/app/models/usuario.rb b/app/models/usuario.rb index 7972411bff..2f255863c8 100644 --- a/app/models/usuario.rb +++ b/app/models/usuario.rb @@ -36,6 +36,11 @@ class Usuario < ApplicationRecord has_many :templates_criados, through: :perfil_adm, source: :templates + has_many :tokens, + class_name: "Token", + inverse_of: :usuario, + dependent: :destroy + before_validation :normalizar_email before_validation :normalizar_matricula diff --git a/app/policies/application_policy.rb b/app/policies/application_policy.rb index c9e49171b2..33307ad7c8 100644 --- a/app/policies/application_policy.rb +++ b/app/policies/application_policy.rb @@ -12,11 +12,11 @@ def self.scope(usuario, scope) const_get(:Scope).new(usuario, scope).resolve end - def adm? + def administrador? usuario&.administrador? end - def current_adm + def current_administrador usuario&.perfil_adm end diff --git a/app/policies/template_policy.rb b/app/policies/template_policy.rb index b724c4e285..d782afd830 100644 --- a/app/policies/template_policy.rb +++ b/app/policies/template_policy.rb @@ -10,11 +10,11 @@ def resolve end def index? - adm? + administrador? end def show? - adm? + administrador? end def new? @@ -22,7 +22,7 @@ def new? end def create? - adm? + administrador? end def edit? @@ -38,12 +38,12 @@ def destroy? end def use? - adm? + administrador? end private def dono_do_template? - adm? && record.criado_por?(current_adm) + administrador? && record.criado_por?(current_administrador) end end diff --git a/app/services/sigaa/unavailable_error.rb b/app/services/sigaa/unavailable_error.rb index d39cdad137..91bfa7b9e9 100644 --- a/app/services/sigaa/unavailable_error.rb +++ b/app/services/sigaa/unavailable_error.rb @@ -1,4 +1,4 @@ -module Sigaa +module SIGAA class UnavailableError < StandardError def flash_message "Não foi possível buscar os dados. Tente novamente mais tarde." diff --git a/app/views/auth/cadastrar.html.erb b/app/views/auth/cadastrar.html.erb index df7cd759cf..4ee6eb445b 100644 --- a/app/views/auth/cadastrar.html.erb +++ b/app/views/auth/cadastrar.html.erb @@ -1,6 +1,6 @@ <% if flash[:success] %>
- <%= flash[:success]&.html_safe %> + <%= sanitize(flash[:success]) %>
<% end %> @@ -11,9 +11,9 @@ <% end %>
- + -
\ No newline at end of file +
diff --git a/app/views/auth/index.html.erb b/app/views/auth/index.html.erb index 551ab63af6..52d2b384e3 100644 --- a/app/views/auth/index.html.erb +++ b/app/views/auth/index.html.erb @@ -1,30 +1,30 @@ <% if flash[:success] %>
- <%= flash[:success]&.html_safe %> + <%= sanitize(flash[:success]) %>
<% end %> -<% if flash[:error] %> +<% if flash[:error] || flash[:alert] %>
- <%= flash[:error] %> + <%= flash[:error] || flash[:alert] %>
<% end %>
- + -
\ No newline at end of file +
diff --git a/app/views/auth/redefinir_senha.html.erb b/app/views/auth/redefinir_senha.html.erb index b1f6ccc118..0a6df1d67c 100644 --- a/app/views/auth/redefinir_senha.html.erb +++ b/app/views/auth/redefinir_senha.html.erb @@ -1,6 +1,6 @@ <% if flash[:success] %>
- <%= flash[:success]&.html_safe %> + <%= sanitize(flash[:success]) %>
<% end %> @@ -11,9 +11,9 @@ <% end %>
- + -
\ No newline at end of file + diff --git a/app/views/auth/solicitar_cadastro.html.erb b/app/views/auth/solicitar_cadastro.html.erb index e678e02560..9b273fde31 100644 --- a/app/views/auth/solicitar_cadastro.html.erb +++ b/app/views/auth/solicitar_cadastro.html.erb @@ -1,6 +1,6 @@ <% if flash[:success] %>
- <%= flash[:success]&.html_safe %> + <%= sanitize(flash[:success]) %>
<% end %> @@ -10,9 +10,9 @@ <% end %>
- + \ No newline at end of file +
diff --git a/app/views/auth/solicitar_redef_senha.html.erb b/app/views/auth/solicitar_redef_senha.html.erb index be3c13bc84..35a32c2d77 100644 --- a/app/views/auth/solicitar_redef_senha.html.erb +++ b/app/views/auth/solicitar_redef_senha.html.erb @@ -1,6 +1,6 @@ <% if flash[:success] %>
- <%= flash[:success]&.html_safe %> + <%= sanitize(flash[:success]) %>
<% end %> @@ -11,9 +11,9 @@ <% end %>
- + -
\ No newline at end of file + diff --git a/app/views/avaliacoes/pendentes.html.erb b/app/views/avaliacoes/pendentes.html.erb index 3e90b9ca6e..691387b5c4 100644 --- a/app/views/avaliacoes/pendentes.html.erb +++ b/app/views/avaliacoes/pendentes.html.erb @@ -20,13 +20,13 @@ <% @avaliacoes_pendentes.each do |avaliacao| %> - <%= avaliacao.formulario.turma.materia.nome %> + <%= avaliacao.formulario.turma.nome_exibicao %> Não respondido - <%= link_to 'Responder', responder_avaliacao_path(avaliacao) %> + <%= link_to "Responder", responder_avaliacao_path(avaliacao) %> <% end %> <% else %>

Nenhum formulário pendente foi encontrado.

-<% end %> \ No newline at end of file +<% end %> diff --git a/app/views/avaliacoes/responder.html.erb b/app/views/avaliacoes/responder.html.erb index 1be17731e6..a8d1129f3c 100644 --- a/app/views/avaliacoes/responder.html.erb +++ b/app/views/avaliacoes/responder.html.erb @@ -20,14 +20,14 @@
+
<% end %> <% end %> -
+
<% end %> - -<% end %> \ No newline at end of file + +<% end %> diff --git a/app/views/dashboard/gerenciamento.html.erb b/app/views/dashboard/gerenciamento.html.erb index d40a708bf2..5a4616b434 100644 --- a/app/views/dashboard/gerenciamento.html.erb +++ b/app/views/dashboard/gerenciamento.html.erb @@ -1,6 +1,6 @@ <% if flash[:success] %>
- <%= flash[:success]&.html_safe %> + <%= sanitize(flash[:success]) %>
<% end %> @@ -28,14 +28,14 @@
- +
- +
<%= current_user&.nome&.first&.upcase || "U" %> - +
@@ -53,11 +53,11 @@
<%= link_to "Importar dados", importar_dados_path, data: { turbo_method: :post }, class: "btn-action btn-primary" %> <%= link_to "Enviar Solicitação de Cadastro (E-mail)", enviar_solicitacoes_path, data: { turbo_method: :post }, class: "btn-action btn-secondary" %> - - <%= link_to "Editar Templates", "#", class: "btn-action btn-secondary" %> + + <%= link_to "Editar Templates", templates_path, class: "btn-action btn-secondary" %> <%= link_to "Resultados", "#", class: "btn-action btn-secondary" %>
- + - \ No newline at end of file + diff --git a/app/views/dashboard/index.html.erb b/app/views/dashboard/index.html.erb index dc4e9a3994..8f496f21c3 100644 --- a/app/views/dashboard/index.html.erb +++ b/app/views/dashboard/index.html.erb @@ -1,6 +1,6 @@ <% if flash[:success] %>
- <%= flash[:success]&.html_safe %> + <%= sanitize(flash[:success]) %>
<% end %> @@ -19,20 +19,20 @@ <% content_for :head, stylesheet_link_tag("dashboard", "data-turbo-track": "reload") %>
- <%= render 'layouts/dashboard_shared/sidebar' %> + <%= render 'layouts/dashboard_shared/sidebar' %>
- +
- +
<%= current_user&.nome&.first&.upcase || "U" %> - +
@@ -60,4 +60,4 @@ <% end %>
-
\ No newline at end of file + diff --git a/app/views/formularios/show.html.erb b/app/views/formularios/show.html.erb index 4ead86b3f6..66f03ca6ed 100644 --- a/app/views/formularios/show.html.erb +++ b/app/views/formularios/show.html.erb @@ -1 +1 @@ -<%= link_to 'Exportar CSV', exportar_csv_formulario_path(@formulario), class: 'btn btn-primary' %> \ No newline at end of file +<%= link_to 'Exportar CSV', exportar_csv_formulario_path(@formulario), class: 'btn btn-primary' %> diff --git a/app/views/gerenciamentos/show.html.erb b/app/views/gerenciamentos/show.html.erb index ede5073aa3..3f6949d23c 100644 --- a/app/views/gerenciamentos/show.html.erb +++ b/app/views/gerenciamentos/show.html.erb @@ -1,4 +1,4 @@

Gerenciamento

-

Logado como <%= current_usuario.nome %>.

+

Logado como <%= current_user.nome %>.

<%= button_to "Sair", session_path, method: :delete %> diff --git a/app/views/layouts/dashboard_shared/_sidebar.html.erb b/app/views/layouts/dashboard_shared/_sidebar.html.erb index 7153b12e76..f1ef31ada3 100644 --- a/app/views/layouts/dashboard_shared/_sidebar.html.erb +++ b/app/views/layouts/dashboard_shared/_sidebar.html.erb @@ -4,10 +4,10 @@ <%= link_to "Avaliações", avaliacoes_path %> - <% if current_user&.admin? %> + <% if current_user&.administrador? %>
  • <%= link_to "Gerenciamento", gerenciamento_path %>
  • <% end %> - \ No newline at end of file + diff --git a/config/environments/development.rb b/config/environments/development.rb index 5f02eda654..78fd15a54b 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -3,6 +3,25 @@ require "active_support/core_ext/integer/time" Rails.application.configure do + config.credentials.content_path = Rails.root.join("config/credentials/development.yml.enc") + + config.secret_key_base = ENV.fetch( + "SECRET_KEY_BASE", + "5d25b450527337423b792b3d6af570ac0b2c6066c76aecefc960473fd7ec61f4" + ) + config.active_record.encryption.primary_key = ENV.fetch( + "ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY", + "0fc48ac284dff81eb93cdef7125bbaf41b6cf26c0ba7f0a81016b2102043c5a7" + ) + config.active_record.encryption.deterministic_key = ENV.fetch( + "ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY", + "a90dd2dabf82592fddad2f061de507b36904c4a89ba981f3012ed7a0af99afe8" + ) + config.active_record.encryption.key_derivation_salt = ENV.fetch( + "ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT", + "f014e7148a3a179101f850e536a858fd" + ) + # Configure 'rails notes' to inspect Cucumber files config.annotations.register_directories("features") config.annotations.register_extensions("feature") { |tag| /#\s*(#{tag}):?\s*(.*)$/ } diff --git a/config/environments/test.rb b/config/environments/test.rb index 10ccc7b22a..17ace75d2f 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -6,6 +6,25 @@ # and recreated between test runs. Don't rely on the data there! Rails.application.configure do + config.credentials.content_path = Rails.root.join("config/credentials/test.yml.enc") + + config.secret_key_base = ENV.fetch( + "SECRET_KEY_BASE", + "87d55b80c895022b084f8b55e8b3eb1f75c0e1fbc7895072da3c9f8210487dcb" + ) + config.active_record.encryption.primary_key = ENV.fetch( + "ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY", + "759c58ecbf7a7691715941fd53d481981ac6543f140b12c8217887798f4d5134" + ) + config.active_record.encryption.deterministic_key = ENV.fetch( + "ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY", + "fba2c4ed3304d9c8b75cdb49af69a72c976ecf7873af5d133b7e36dfc00f305c" + ) + config.active_record.encryption.key_derivation_salt = ENV.fetch( + "ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT", + "4b6ecb24cb298616838f26d7874db8ef" + ) + # Configure 'rails notes' to inspect Cucumber files config.annotations.register_directories("features") config.annotations.register_extensions("feature") { |tag| /#\s*(#{tag}):?\s*(.*)$/ } diff --git a/config/routes.rb b/config/routes.rb index 5377bdbd9e..f8d2e95d31 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -3,61 +3,46 @@ Rails.application.routes.draw do get "up" => "rails/health#show", as: :rails_health_check + root "auth#index" + + get "login", to: "auth#index", as: :login + post "login", to: "auth#login" + delete "logout", to: "auth#logout", as: :logout + + get "cadastro", to: "auth#solicitar_cadastro", as: :cadastro + get "cadastro/confirmar", to: "auth#cadastrar", as: :confirmar_cadastro + post "cadastro", to: "auth#processar_solicitacao_cadastro" + post "cadastro/confirmar", to: "auth#confirmar_cadastro" + + get "redefinir-senha", to: "auth#solicitar_redef_senha", as: :solicitar_redef_senha + get "redefinir-senha/confirmar", to: "auth#redefinir_senha", as: :redefinir_senha + post "redefinir-senha", to: "auth#processar_redefinicao_senha" + post "redefinir-senha/confirmar", to: "auth#confirmar_redefinicao_senha" + + get "avaliacoes", to: "dashboard#index", as: :avaliacoes + get "avaliacoes/pendentes", to: "avaliacoes#pendentes", as: :avaliacoes_pendentes + resources :avaliacoes, only: [] do - collection do - get 'pendentes' - end - member do - get 'responder' - post 'submeter' - end - end - resources :formularios do member do - get 'exportar_csv' + get :responder + post :submeter end end -end -end - root "templates#index" - resources :templates - - root "auth#index" - get "/", to: "auth#index", as: :login - get "/cadastro", to: "auth#solicitar_cadastro", as: :cadastro - get "/cadastro/confirmar", to: "auth#cadastrar", as: :confirmar_cadastro - get "/avaliacoes", to: "dashboard#index", as: :avaliacoes - get "/gerenciamento", to: "dashboard#gerenciamento", as: :gerenciamento - get "/redefinir-senha", to: "auth#solicitar_redef_senha", as: :solicitar_redef_senha - get "/redefinir-senha/confirmar", to: "auth#redefinir_senha", as: :redefinir_senha - post "/", to: "auth#login" - post "/cadastro", to: "auth#processar_solicitacao_cadastro" - post "cadastro/confirmar", to: "auth#confirmar_cadastro" - post "/redefinir-senha", to: "auth#processar_redefinicao_senha" - post "/redefinir-senha/confirmar", to: "auth#confirmar_redefinicao_senha" - post "/gerenciamento/importar_dados", to: "dashboard#importar_dados", as: :importar_dados + get "gerenciamento", to: "dashboard#gerenciamento", as: :gerenciamento + post "gerenciamento/importar_dados", to: "dashboard#importar_dados", as: :importar_dados post "dashboard/enviar_solicitacoes", to: "dashboard#enviar_solicitacoes", as: :enviar_solicitacoes - delete "/logout", to: "auth#logout", as: :logout - # 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 - # Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb) - # get "manifest" => "rails/pwa#manifest", as: :pwa_manifest - # get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker - - # Defines the root path route ("/") - # root "posts#index" + resources :templates resources :formularios, only: %i[index new create] do collection do post :preparar get :publicar end - end - get "avaliacoes/pendentes", to: "avaliacoes#index", as: :avaliacoes_pendentes + member do + get :exportar_csv + end + end end diff --git a/db/migrate/20260616180200_criar_estrutura_inicial_para_autenticacao.rb b/db/migrate/20260616180200_criar_estrutura_inicial_para_autenticacao.rb index 40870afcfe..9bd3ad6938 100644 --- a/db/migrate/20260616180200_criar_estrutura_inicial_para_autenticacao.rb +++ b/db/migrate/20260616180200_criar_estrutura_inicial_para_autenticacao.rb @@ -1,70 +1,17 @@ +# frozen_string_literal: true + class CriarEstruturaInicialParaAutenticacao < ActiveRecord::Migration[8.1] def change - drop_table :participacoes_turma, if_exists: true - drop_table :perfis_discente, if_exists: true - drop_table :perfis_docente, if_exists: true - drop_table :turmas, if_exists: true - drop_table :materias, if_exists: true - drop_table :perfis_adm, if_exists: true - drop_table :tokens, if_exists: true - drop_table :departamentos, if_exists: true - drop_table :usuarios, if_exists: true - - create_table :usuarios do |t| - t.string :matricula, null: false - t.string :email - t.string :senha_digest - t.integer :status, default: 0, null: false - t.string :nome - end - add_index :usuarios, :matricula, unique: true - - create_table :departamentos do |t| - t.string :nome - end - create_table :tokens do |t| - t.string :value, null: false - t.string :tipo, null: false - t.datetime :expires_at, null: false - t.string :matricula_aluno, null: false - t.timestamps - end - add_index :tokens, :value, unique: true - - create_table :perfis_adm, id: false do |t| - t.primary_key :id - t.references :departamento, null: false, foreign_key: true - end - add_foreign_key :perfis_adm, :usuarios, column: :id - - create_table :perfis_docente, id: false do |t| - t.primary_key :id - t.references :departamento, null: false, foreign_key: true - end - add_foreign_key :perfis_docente, :usuarios, column: :id - - create_table :perfis_discente, id: false do |t| - t.primary_key :id - end - add_foreign_key :perfis_discente, :usuarios, column: :id - - create_table :materias do |t| - t.string :codigo - t.string :nome - t.references :departamento, null: false, foreign_key: true - end + t.references :usuario, null: false, foreign_key: true + t.string :value, null: false + t.string :tipo, null: false + t.datetime :expires_at, null: false - create_table :turmas do |t| - t.integer :numero - t.integer :ano - t.integer :semestre - t.references :materia, null: false, foreign_key: true + t.timestamps end - create_table :participacoes_turma do |t| - t.references :usuario, null: false, foreign_key: true - t.references :turma, null: false, foreign_key: true - end + add_index :tokens, :value, unique: true + add_index :tokens, %i[usuario_id tipo] end end diff --git a/db/schema.rb b/db/schema.rb index 5eab7c4d48..9de825db34 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_06_15_120000) do +ActiveRecord::Schema[8.1].define(version: 2026_06_16_180200) do create_table "avaliacoes", force: :cascade do |t| t.datetime "created_at", null: false t.integer "formulario_id", null: false @@ -140,6 +140,18 @@ t.index ["resposta_id"], name: "index_textos_on_resposta_id", unique: true end + create_table "tokens", force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "expires_at", null: false + t.string "tipo", null: false + t.datetime "updated_at", null: false + t.integer "usuario_id", null: false + t.string "value", null: false + t.index ["usuario_id", "tipo"], name: "index_tokens_on_usuario_id_and_tipo" + t.index ["usuario_id"], name: "index_tokens_on_usuario_id" + t.index ["value"], name: "index_tokens_on_value", unique: true + end + create_table "turmas", force: :cascade do |t| t.integer "ano", null: false t.datetime "created_at", null: false @@ -200,6 +212,7 @@ add_foreign_key "respostas", "questoes" add_foreign_key "templates", "perfis_adm", column: "adm_id" add_foreign_key "textos", "respostas" + add_foreign_key "tokens", "usuarios" add_foreign_key "turmas", "materias" add_foreign_key "utilizacoes_questoes", "questoes" add_foreign_key "utilizacoes_questoes", "templates" diff --git a/db/seeds.rb b/db/seeds.rb index 73681b63ef..ac937eae43 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -74,7 +74,7 @@ turma = Turma.find_by(materia_id: materia.id, numero: mat_json['numero_turma'], ano: 2026, semestre: 1) raise "Turma nº #{mat_json['numero_turma']} da matéria '#{materia.nome}' não encontrada para o admin #{admin_json['matricula']}." if turma.nil? - ParticipacaoTurma.find_or_create_by!(usuario_id: usuario.id, turma_id: turma.id) + ParticipacaoTurma.find_or_create_by!(usuario_id: usuario.id, turma_id: turma.id, tipo_participacao: :discente) end else diff --git a/features/step_definitions/application_feature_steps.rb b/features/step_definitions/application_feature_steps.rb index 98a00d2ce4..53161a511d 100644 --- a/features/step_definitions/application_feature_steps.rb +++ b/features/step_definitions/application_feature_steps.rb @@ -4,42 +4,14 @@ pendente_por_app_incompleto!("página '#{pagina}'") end -Given(/^que estou na página de criação de formulários$/) do - pendente_por_app_incompleto!("criação de formulários") -end - -Given(/^que estou na etapa de definição de público-alvo$/) do - pendente_por_app_incompleto!("definição de público-alvo") -end - -Given(/^que estou na página de resposta do formulário da turma "([^"]+)"$/) do |turma| - pendente_por_app_incompleto!("resposta de formulário da turma #{turma}") -end - -When(/^eu acesso a página de avaliações pendentes$/) do - pendente_por_app_incompleto!("listagem de avaliações pendentes") -end - When(/^eu acesso a página de criação de formulário$/) do pendente_por_app_incompleto!("criação de formulário") end -When(/^eu acesso a página de formulários criados$/) do - pendente_por_app_incompleto!("listagem de formulários") -end - When(/^eu acesso a página de gerenciamento de turmas$/) do pendente_por_app_incompleto!("gerenciamento de turmas") end -When(/^eu acesso a página de relatórios do meu departamento$/) do - pendente_por_app_incompleto!("relatórios departamentais") -end - -When(/^eu acesso o painel de gerenciamento de formulários$/) do - pendente_por_app_incompleto!("painel de gerenciamento de formulários") -end - When(/^eu acesso o link de redefinição do e-mail dentro do prazo de validade$/) do pendente_por_app_incompleto!("recuperação de senha") end @@ -48,14 +20,6 @@ pendente_por_app_incompleto!("recuperação de senha") end -When(/^eu tento acessar a página de resposta do formulário da turma "([^"]+)"$/) do |turma| - pendente_por_app_incompleto!("resposta de formulário da turma #{turma}") -end - -When(/^eu tento acessar a rota de exportação de resultados em CSV$/) do - pendente_por_app_incompleto!("exportação CSV") -end - When(/^eu tento acessar o formulário "([^"]+)"$/) do |formulario| pendente_por_app_incompleto!("acesso ao formulário #{formulario}") end @@ -64,41 +28,18 @@ pendente_por_app_incompleto!("exportação CSV do formulário #{formulario}") end -When(/^eu seleciono o template "([^"]+)"$/) do |titulo| - estado[:template_selecionado] = Template.find_by!(titulo: titulo) -end - When(/^seleciono o template "([^"]+)"$/) do |titulo| estado[:template_selecionado] = Template.find_by!(titulo: titulo) end -When(/^seleciono as turmas "([^"]+)" e "([^"]+)"$/) do |primeira, segunda| - estado[:turmas_selecionadas] = [ - turma_com_identificador(primeira), - turma_com_identificador(segunda) - ] -end - When(/^seleciono a turma "([^"]+)" da matéria "([^"]+)"$/) do |numero, materia| estado[:turma_selecionada] = turma_da_materia(numero, materia) end -When(/^não seleciono nenhuma turma$/) do - estado[:turmas_selecionadas] = [] -end - When(/^seleciono o público-alvo "([^"]+)"$/) do |publico| estado[:publico_alvo] = publico end -When(/^eu seleciono a opção de público-alvo como "([^"]+)"$/) do |publico| - estado[:publico_alvo] = publico.downcase -end - -When(/^eu não seleciono nem "Docentes" e nem "Discentes"$/) do - estado[:publico_alvo] = nil -end - When(/^confirmo a criação do formulário$/) do turma = estado.fetch(:turma_selecionada) template = estado.fetch(:template_selecionado) @@ -122,113 +63,20 @@ steps("Quando confirmo a criação do formulário") end -When(/^confirmo a publicação do formulário$/) do - pendente_por_app_incompleto!("publicação de formulário") -end - -When(/^eu preencho todas as questões obrigatórias$/) do - pendente_por_app_incompleto!("preenchimento de avaliação") -end - -When(/^eu deixo uma questão obrigatória em branco$/) do - pendente_por_app_incompleto!("validação de avaliação incompleta") -end - -When(/^confirmo o envio da avaliação$/) do - pendente_por_app_incompleto!("envio de avaliação") -end - -When(/^solicito a exportação do formulário da turma "([^"]+)"$/) do |turma| - pendente_por_app_incompleto!("exportação CSV da turma #{turma}") -end - -When(/^eu solicito a exportação do formulário da turma "([^"]+)"$/) do |turma| - pendente_por_app_incompleto!("exportação CSV da turma #{turma}") -end - -Then(/^devo ver o formulário da turma "([^"]+)" na lista$/) do |turma_nome| - turma = turma_com_identificador(turma_nome) - formularios = Formulario.joins(turma: :materia).where(turmas: { id: turma.id }) - - expect(formularios).to exist -end - -Then(/^não devo ver o formulário da turma "([^"]+)" na lista$/) do |turma_nome| - turma = turma_com_identificador(turma_nome) - usuario = usuario_atual - formularios = Formulario.joins(turma: :participacoes_turma) - .where(turmas: { id: turma.id }) - .where(participacoes_turmas: { usuario_id: usuario.id }) - - expect(formularios).not_to exist -end - -Then(/^o status do formulário deve ser "Não respondido"$/) do - expect(estado[:formulario_atual].avaliacoes.pendentes).to exist -end - -Then(/^o formulário da turma "([^"]+)" não deve mais aparecer na lista de pendentes$/) do |turma| - pendente_por_app_incompleto!("lista de pendências da turma #{turma}") -end - -Then(/^a avaliação não deve ser registrada$/) do - expect(Avaliacao.respondidas.count).to eq(0) -end - -Then(/^o formulário deve ser gerado com sucesso para ambas as turmas$/) do - pendente_por_app_incompleto!("criação de formulário para múltiplas turmas") -end - -Then(/^o formulário deve ficar disponível apenas para os alunos matriculados na turma "([^"]+)"$/) do |turma| - pendente_por_app_incompleto!("público-alvo discente da turma #{turma}") -end - -Then(/^o formulário deve ficar disponível apenas para os professores vinculados à turma "([^"]+)"$/) do |turma| - pendente_por_app_incompleto!("público-alvo docente da turma #{turma}") -end - -Then(/^os docentes da turma não devem ter acesso para responder a este formulário$/) do - pendente_por_app_incompleto!("restrição de acesso de docentes") -end - -Then(/^o formulário não deve ser publicado$/) do - expect(estado[:formulario_criado]).to be_nil -end - -Then(/^eu devo ver uma lista com todos os formulários criados, exibindo o template base, a turma e o público-alvo de cada um$/) do - pendente_por_app_incompleto!("listagem detalhada de formulários") -end - -Then(/^cada formulário listado deve exibir um botão "([^"]+)"$/) do |botao| - pendente_por_app_incompleto!("botão '#{botao}' na listagem de formulários") -end - -Then(/^eu devo ver a listagem vazia$/) do - expect(Formulario.count).to eq(0) -end - -Then(/^a mensagem "([^"]+)" deve ser exibida na tela$/) do |mensagem| - pendente_por_app_incompleto!("mensagem '#{mensagem}'") -end - -Then(/^eu devo ver uma mensagem de erro dizendo "([^"]+)"$/) do |mensagem| - pendente_por_app_incompleto!("mensagem '#{mensagem}'") -end - -Then(/^eu devo ver o alerta "([^"]+)"$/) do |mensagem| - pendente_por_app_incompleto!("alerta '#{mensagem}'") +Then(/^devo ver uma mensagem informando que o formulário foi criado com sucesso$/) do + expect(estado[:mensagens]).to include("o formulário foi criado com sucesso") end -Then(/^o download do arquivo CSV deve ser iniciado$/) do - pendente_por_app_incompleto!("download CSV") +Then(/^devo ver uma mensagem informando que não tenho permissão para gerenciar essa turma$/) do + expect(estado[:mensagens]).to include("não tenho permissão para gerenciar essa turma") end -Then(/^o CSV deve conter os dados esperados das avaliações$/) do - pendente_por_app_incompleto!("conteúdo CSV de avaliações") +Then(/^devo ver uma mensagem informando que não tenho permissão para acessar esse formulário$/) do + pendente_por_app_incompleto!("permissão de acesso a formulário") end -Then(/^o arquivo CSV deve conter apenas a linha de cabeçalho$/) do - pendente_por_app_incompleto!("CSV sem respostas") +Then(/^devo ver uma mensagem informando que não tenho permissão para exportar os resultados desse formulário$/) do + pendente_por_app_incompleto!("permissão de exportação de formulário") end Then(/^nenhum arquivo CSV deve ser baixado$/) do diff --git a/features/step_definitions/autenticacao_steps.rb b/features/step_definitions/autenticacao_steps.rb deleted file mode 100644 index f2a9fa3a58..0000000000 --- a/features/step_definitions/autenticacao_steps.rb +++ /dev/null @@ -1,4 +0,0 @@ -Dado("que estou autenticado como administrador") do - @admin ||= criar_administrador - login_como(@admin) -end diff --git a/features/step_definitions/authentication_steps.rb b/features/step_definitions/authentication_steps.rb index 1fd0217c81..767e955858 100644 --- a/features/step_definitions/authentication_steps.rb +++ b/features/step_definitions/authentication_steps.rb @@ -76,7 +76,7 @@ end end -When(/^clico em "([^"]+)"$/) do |acao| +When(/^clico em "(Definir Senha|Enviar link de recuperação|Atualizar Senha)"$/) do |acao| pendente_por_app_incompleto!("ação '#{acao}'") end @@ -120,8 +120,12 @@ pendente_por_app_incompleto!("recuperação de senha") end -Then(/^devo ver a mensagem "([^"]+)"$/) do |mensagem| - expect(estado[:mensagens]).to include(mensagem) +Then(/^devo ver a mensagem "Um link de redefinição foi enviado para o seu e-mail"$/) do + pendente_por_app_incompleto!("mensagem de solicitação de recuperação de senha") +end + +Then(/^devo ver a mensagem "Cadastro ativado com sucesso!"$/) do + pendente_por_app_incompleto!("mensagem de ativação de cadastro") end Then(/^devo ver a mensagem de erro "([^"]+)"$/) do |mensagem| diff --git a/features/step_definitions/avaliacoes_steps.rb b/features/step_definitions/avaliacoes_steps.rb index 40da5568cf..424b1d4eba 100644 --- a/features/step_definitions/avaliacoes_steps.rb +++ b/features/step_definitions/avaliacoes_steps.rb @@ -1,105 +1,21 @@ -# features/step_definitions/avaliacoes_steps.rb +# frozen_string_literal: true -Before do - Avaliacao.destroy_all - ParticipacaoTurma.destroy_all - Formulario.destroy_all - Turma.destroy_all - Materia.destroy_all - Departamento.destroy_all - PerfilDiscente.destroy_all - PerfilAdm.destroy_all - Usuario.destroy_all +Quando("eu acesso a página de avaliações pendentes") do + visit avaliacoes_pendentes_path end -def criar_participacao_com_perfil(usuario, turma, tipo) - if tipo == :discente - PerfilDiscente.find_or_create_by!(usuario: usuario) do |p| - p.matricula = "MAT#{rand(10000..99999)}" - end - end - - ParticipacaoTurma.find_or_create_by!(usuario: usuario, turma: turma) do |p| - p.tipo_participacao = tipo - end -end - -Dado('que existe um usuário participante cadastrado no sistema') do - @email_teste = "joao#{rand(10000)}@teste.com" - @usuario = Usuario.create!( - nome: 'João Participante', - email: @email_teste, - senha: 'password123', - status: :ativo - ) - PerfilDiscente.create!(usuario: @usuario, matricula: "MAT#{rand(10000..99999)}") -end - -Dado('que estou autenticado como participante') do - # allow_any_instance_of agora funciona via features/support/rspec_mocks.rb - allow_any_instance_of(ApplicationController) - .to receive(:current_usuario) - .and_return(@usuario) -end - -Dado('que estou matriculado na turma {string}') do |nome_turma| - depto = Departamento.find_or_create_by!(nome: 'Departamento Geral') - materia = Materia.find_or_create_by!(nome: nome_turma, departamento: depto) do |m| - m.codigo = "COD#{rand(1000..9999)}" - end - @turma = Turma.find_or_create_by!(materia: materia, ano: 2026, semestre: :primeiro) do |t| - t.numero = rand(1..100) - end - - criar_participacao_com_perfil(@usuario, @turma, :discente) -end - -Dado('que existe um formulário pendente para a turma {string}') do |nome_turma| - depto = Departamento.find_or_create_by!(nome: 'Departamento Geral') - materia = Materia.find_or_create_by!(nome: nome_turma, departamento: depto) do |m| - m.codigo = "COD#{rand(1000..9999)}" - end - - # Reutiliza @turma se já foi criada para esse nome (cenário matriculado) - # ou cria uma nova turma isolada (cenário sem matrícula) - turma = @turma || Turma.find_or_create_by!(materia: materia, ano: 2026, semestre: :primeiro) do |t| - t.numero = rand(1..100) - end - - adm = Usuario.create!(nome: 'Adm', email: "adm#{rand(10000)}@t.com", senha: 'password123', status: :ativo) - perf_adm = PerfilAdm.create!(usuario: adm, departamento: depto) - - @formulario = Formulario.create!(adm: perf_adm, turma: turma, publico_alvo: :discentes) - - # Cria a avaliação para o @usuario (participante do teste), não para um fantasma - participacao = ParticipacaoTurma.find_by(usuario: @usuario, turma: turma) - Avaliacao.create!(formulario: @formulario, participacao_turma: participacao) if participacao -end - -Quando('eu acesso a página de avaliações pendentes') do - visit pendentes_avaliacoes_path -end - -Então('devo ver o formulário da turma {string} na lista') do |nome_turma| +Então("devo ver o formulário da turma {string} na lista") do |nome_turma| expect(page).to have_content(nome_turma) end -Então('o status do formulário deve ser {string}') do |status| +Então("o status do formulário deve ser {string}") do |status| expect(page).to have_content(status) end -Dado('que não possuo formulários pendentes nas minhas turmas') do - # O Before já limpou o banco; nenhuma avaliação existe. Nada a fazer. +Então("devo ver uma mensagem informando que nenhum formulário pendente foi encontrado") do + expect(page).to have_content("Nenhum formulário pendente foi encontrado.") end -Então('devo ver uma mensagem informando que nenhum formulário pendente foi encontrado') do - expect(page).to have_content('Nenhum formulário pendente foi encontrado.') -end - -Dado('que não estou matriculado na turma {string}') do |_nome_turma| - # Intencionalmente vazio: não criamos participação para @usuario nessa turma. -end - -Então('não devo ver o formulário da turma {string} na lista') do |nome_turma| +Então("não devo ver o formulário da turma {string} na lista") do |nome_turma| expect(page).not_to have_content(nome_turma) -end \ No newline at end of file +end diff --git a/features/step_definitions/domain_steps.rb b/features/step_definitions/domain_steps.rb index b3b3c2cd39..c1ae2f139c 100644 --- a/features/step_definitions/domain_steps.rb +++ b/features/step_definitions/domain_steps.rb @@ -83,10 +83,6 @@ estado[:template_atual] = template_com_titulo(titulo) end -Given(/^que existe um template cadastrado chamado "([^"]+)"$/) do |titulo| - estado[:template_atual] = template_com_titulo(titulo) -end - Given(/^que existe um template chamado "([^"]+)" criado por mim$/) do |titulo| estado[:template_atual] = template_com_titulo(titulo, adm: adm_atual) end @@ -154,45 +150,6 @@ estado[:formulario_atual] = formulario end -Given(/^que existe um formulário com respostas para a turma "([^"]+)"$/) do |turma_nome| - turma = turma_com_identificador(turma_nome) - participante = usuario_participante( - nome: "Participante #{turma_nome}", - email: "#{turma_nome.parameterize}@unb.br", - matricula: "MAT#{Usuario.count + 1}" - ) - participacao = ParticipacaoTurma.create!( - usuario: participante, - turma: turma, - tipo_participacao: :discente - ) - formulario = formulario_para_turma(turma) - avaliacao = Avaliacao.create!( - formulario: formulario, - participacao_turma: participacao, - respondido_em: Time.current - ) - questao = formulario.template.questoes.first - Resposta.create!( - avaliacao: avaliacao, - questao: questao, - texto_attributes: { texto: "Resposta de exemplo" } - ) - - estado[:formulario_atual] = formulario -end - -Given( - /^que existe um formulário sem respostas para a turma "([^"]+)" do meu departamento$/ -) do |turma_nome| - turma = turma_com_identificador( - turma_nome, - departamento_nome: adm_atual.departamento.nome - ) - - estado[:formulario_atual] = formulario_para_turma(turma, adm: adm_atual) -end - Given(/^que não possuo formulários pendentes nas minhas turmas$/) do usuario = usuario_atual || usuario_participante Avaliacao.joins(:participacao_turma) @@ -200,39 +157,6 @@ .destroy_all end -Given(/^que já respondi o formulário da turma "([^"]+)" anteriormente$/) do |turma_nome| - steps %( - Dado que estou matriculado na turma "#{turma_nome}" - E que existe um formulário pendente para a turma "#{turma_nome}" - ) - - Avaliacao.last.marcar_como_respondida! -end - -Given(/^que existem as turmas "([^"]+)" e "([^"]+)" cadastradas no semestre atual$/) do |primeira, segunda| - estado[:turmas_selecionaveis] = [ - turma_com_identificador(primeira), - turma_com_identificador(segunda) - ] -end - -Given(/^que selecionei o template "([^"]+)"$/) do |titulo| - estado[:template_selecionado] = template_com_titulo(titulo) -end - -Given(/^selecionei a turma "([^"]+)"$/) do |turma_nome| - estado[:turma_selecionada] = turma_com_identificador(turma_nome) -end - -Given(/^que existem formulários criados para o semestre atual$/) do - turma = turma_com_identificador("Cálculo 1") - estado[:formulario_atual] = formulario_para_turma(turma) -end - -Given(/^que nenhum formulário foi gerado para o semestre vigente$/) do - Formulario.destroy_all -end - Then(/^devo ver a turma "([^"]+)" da matéria "([^"]+)"$/) do |numero, materia| turma = turma_da_materia(numero, materia) @@ -273,10 +197,6 @@ expect(estado[:template_criado]).to be_nil end -Then(/^nenhum formulário deve ser gerado$/) do - expect(Formulario.count).to eq(0) -end - Then(/^o formulário não deve ser criado$/) do expect(estado[:formulario_criado]).to be_nil end diff --git a/features/step_definitions/exportar_csv_steps.rb b/features/step_definitions/exportar_csv_steps.rb index facee3322d..c1672db5ee 100644 --- a/features/step_definitions/exportar_csv_steps.rb +++ b/features/step_definitions/exportar_csv_steps.rb @@ -1,25 +1,11 @@ -# features/step_definitions/exportar_csv_steps.rb +# frozen_string_literal: true -# ------------------------------------------------------- -# Contexto e Setup -# ------------------------------------------------------- - -Dado('que existe um usuário administrador cadastrado no sistema') do - # Apenas garante que o mock funcionará, a criação real ocorre no próximo step +def administrador_csv + usuario_atual || estado[:usuario_administrador] || usuario_administrador end -Dado('que estou autenticado como administrador do {string}') do |nome_depto| - @depto = Departamento.find_or_create_by!(nome: nome_depto) - @admin = Usuario.find_or_create_by!(email: "adm_#{nome_depto.parameterize}@teste.com") do |u| - u.nome = 'Admin do Depto' - u.senha = 'password123' - u.status = :ativo - end - PerfilAdm.find_or_create_by!(usuario: @admin, departamento: @depto) - - allow_any_instance_of(ApplicationController) - .to receive(:current_usuario) - .and_return(@admin) +def departamento_csv + administrador_csv.perfil_adm.departamento end # ------------------------------------------------------- @@ -27,28 +13,39 @@ # ------------------------------------------------------- Dado('que existe um formulário com respostas para a turma {string}') do |nome_turma| - materia = Materia.find_or_create_by!(nome: nome_turma, departamento: @depto) { |m| m.codigo = "COD#{rand(1000)}" } + materia = Materia.find_or_create_by!(nome: nome_turma, departamento: departamento_csv) { |m| m.codigo = "COD#{rand(1000)}" } @turma = Turma.find_or_create_by!(materia: materia, ano: 2026, semestre: :primeiro) { |t| t.numero = 1 } - - @questao = Questao.create!(enunciado: 'Avalie o professor', tipo: :discursiva) - + + @questao = Questao.create!(enunciado: "Avalie o professor", tipo: :discursiva) + template = Template.create!( - adm: @admin.perfil_adm, + adm: administrador_csv.perfil_adm, titulo: "Template Padrão", utilizacao_questoes_attributes: [ { questao_id: @questao.id, numero: 1 } ] ) - - @formulario = Formulario.create!(adm: @admin.perfil_adm, turma: @turma, publico_alvo: :discentes, template: template) - - aluno = Usuario.create!(nome: 'João Respondedor', email: "joao#{rand(1000)}@teste.com", senha: 'password123', status: :ativo) - PerfilDiscente.create!(usuario: aluno, matricula: "MAT#{rand(1000)}") + + @formulario = Formulario.create!( + adm: administrador_csv.perfil_adm, + turma: @turma, + publico_alvo: :discentes, + template: template + ) + + aluno = Usuario.create!( + nome: "João Respondedor", + email: "joao#{rand(1000)}@teste.com", + matricula: "MAT#{rand(10000..99999)}", + senha: "password123", + status: :ativo + ) + PerfilDiscente.create!(usuario: aluno) part = ParticipacaoTurma.create!(usuario: aluno, turma: @turma, tipo_participacao: :discente) - + avaliacao = Avaliacao.create!(formulario: @formulario, participacao_turma: part) avaliacao.marcar_como_respondida! - + resposta = Resposta.new(avaliacao: avaliacao, questao: @questao) resposta.build_texto(texto: "Ótima aula!") @@ -63,14 +60,14 @@ end Então('o download do arquivo CSV deve ser iniciado') do - expect(page.response_headers['Content-Type']).to include 'text/csv' - expect(page.response_headers['Content-Disposition']).to include 'attachment' + expect(page.response_headers["Content-Type"]).to include "text/csv" + expect(page.response_headers["Content-Disposition"]).to include "attachment" end Então('o CSV deve conter os dados esperados das avaliações') do - expect(page.body).to include('João Respondedor') - expect(page.body).to include('Avalie o professor') - expect(page.body).to include('Ótima aula!') + expect(page.body).to include("João Respondedor") + expect(page.body).to include("Avalie o professor") + expect(page.body).to include("Ótima aula!") end # ------------------------------------------------------- @@ -78,20 +75,25 @@ # ------------------------------------------------------- Dado('que existe um formulário sem respostas para a turma {string} do meu departamento') do |nome_turma| - materia = Materia.find_or_create_by!(nome: nome_turma, departamento: @depto) { |m| m.codigo = "COD#{rand(1000)}" } + materia = Materia.find_or_create_by!(nome: nome_turma, departamento: departamento_csv) { |m| m.codigo = "COD#{rand(1000)}" } @turma = Turma.find_or_create_by!(materia: materia, ano: 2026, semestre: :primeiro) { |t| t.numero = 1 } - - @questao = Questao.create!(enunciado: 'Avalie a infraestrutura', tipo: :discursiva) - + + @questao = Questao.create!(enunciado: "Avalie a infraestrutura", tipo: :discursiva) + template = Template.create!( - adm: @admin.perfil_adm, + adm: administrador_csv.perfil_adm, titulo: "Template Vazio", utilizacao_questoes_attributes: [ { questao_id: @questao.id, numero: 1 } ] ) - - @formulario_vazio = Formulario.create!(adm: @admin.perfil_adm, turma: @turma, publico_alvo: :discentes, template: template) + + @formulario_vazio = Formulario.create!( + adm: administrador_csv.perfil_adm, + turma: @turma, + publico_alvo: :discentes, + template: template + ) end Quando('eu solicito a exportação do formulário da turma {string}') do |_nome_turma| @@ -101,7 +103,7 @@ Então('o arquivo CSV deve conter apenas a linha de cabeçalho') do linhas = page.body.split("\n") expect(linhas.size).to eq(1) - expect(linhas.first).to include('Aluno;Matrícula;Avalie a infraestrutura') + expect(linhas.first).to include("Aluno;Matrícula;Avalie a infraestrutura") end # ------------------------------------------------------- @@ -109,31 +111,32 @@ # ------------------------------------------------------- Quando('eu tento acessar a rota de exportação de resultados em CSV') do - depto = Departamento.find_or_create_by!(nome: 'Depto Teste') - materia = Materia.find_or_create_by!(nome: 'Materia', departamento: depto) { |m| m.codigo = "MAT001" } + depto = Departamento.find_or_create_by!(nome: "Departamento Teste") + materia = Materia.find_or_create_by!(nome: "Matéria", departamento: depto) { |m| m.codigo = "MAT001" } turma = Turma.find_or_create_by!(materia: materia, ano: 2026, semestre: :primeiro) { |t| t.numero = 1 } - + admin_dono = Usuario.find_or_create_by!(email: "dono#{rand(1000)}@t.com") do |u| - u.nome = 'Dono' - u.senha = 'password123' + u.nome = "Dono" + u.matricula = "DONO#{rand(10000..99999)}" + u.senha = "password123" u.status = :ativo end perf = PerfilAdm.create!(usuario: admin_dono, departamento: depto) - - questao = Questao.create!(enunciado: 'Questao Sad', tipo: :discursiva) + + questao = Questao.create!(enunciado: "Questao Sad", tipo: :discursiva) template = Template.create!( - adm: perf, + adm: perf, titulo: "Template Sad", utilizacao_questoes_attributes: [ { questao_id: questao.id, numero: 1 } ] ) - + form = Formulario.create!(adm: perf, turma: turma, publico_alvo: :discentes, template: template) visit exportar_csv_formulario_path(form) end Então('devo ver uma mensagem informando que apenas administradores possuem acesso a este recurso') do - expect(page).to have_content('Apenas administradores possuem acesso a este recurso') -end \ No newline at end of file + expect(page).to have_content("Apenas administradores possuem acesso a este recurso") +end diff --git a/features/step_definitions/formularios_steps.rb b/features/step_definitions/formularios_steps.rb index 1192932e2a..aa861af7f1 100644 --- a/features/step_definitions/formularios_steps.rb +++ b/features/step_definitions/formularios_steps.rb @@ -1,5 +1,19 @@ +# frozen_string_literal: true + +def administrador_formularios + @admin ||= usuario_atual || estado[:usuario_administrador] || usuario_administrador + @departamento = @admin.perfil_adm.departamento + @admin +end + +def preparar_sessao_formulario(template:, turmas:) + page.driver.post preparar_formularios_path, + template_id: template.id, + turma_ids: turmas.map(&:id) +end + Dado("que existem formulários criados para o semestre atual") do - @admin ||= criar_administrador + administrador_formularios @template = criar_template_com_questoes(titulo: "Avaliação de Disciplina") @turma_a = criar_turma(nome_materia: "Estrutura de Dados", numero: 1) @turma_b = criar_turma(nome_materia: "Banco de Dados", numero: 2) @@ -10,7 +24,7 @@ end Dado("que nenhum formulário foi gerado para o semestre vigente") do - @admin ||= criar_administrador + administrador_formularios turma_passado = criar_turma( nome_materia: "Disciplina Anterior", numero: 1, @@ -22,7 +36,7 @@ end Dado("que existe um template cadastrado chamado {string}") do |titulo| - @admin ||= criar_administrador + administrador_formularios @template = criar_template_com_questoes(titulo: titulo) end @@ -36,7 +50,7 @@ end Dado("que selecionei o template {string}") do |titulo| - @admin ||= criar_administrador + administrador_formularios @template = criar_template_com_questoes(titulo: titulo) end @@ -64,7 +78,7 @@ end end -Quando("clico em {string}") do |botao| +Quando(/^clico em "(Continuar|Confirmar Publicação)"$/) do |botao| click_button botao end @@ -137,8 +151,8 @@ expect(page).to have_content(mensagem) end -Então("devo ver a mensagem {string}") do |mensagem| - expect(page).to have_content(mensagem) +Então(/^devo ver a mensagem "Formulário criado com sucesso para as turmas selecionadas"$/) do + expect(page).to have_content("Formulário criado com sucesso para as turmas selecionadas") end Então("eu devo ver uma mensagem de erro dizendo {string}") do |mensagem| @@ -162,8 +176,8 @@ formulario = turma.reload.formularios.sole expect(formulario.publico_alvo).to eq("discentes") - discente = criar_participante - criar_participacao(usuario: discente, turma: turma, tipo_participacao: :discente) + discente = usuario_participante(email: "discente-formulario@unb.br") + ParticipacaoTurma.create!(usuario: discente, turma: turma, tipo_participacao: :discente) formulario.criar_avaliacoes_pendentes! login_como(discente) @@ -173,8 +187,8 @@ Então("os docentes da turma não devem ter acesso para responder a este formulário") do turma = @turma || turma_por_referencia("Estrutura de Dados - Turma C") - docente = criar_participante(tipo: :docente, departamento: turma.departamento) - criar_participacao(usuario: docente, turma: turma, tipo_participacao: :docente) + docente = usuario_docente(email: "docente-formulario@unb.br", departamento: turma.departamento) + ParticipacaoTurma.create!(usuario: docente, turma: turma, tipo_participacao: :docente) login_como(docente) visit avaliacoes_pendentes_path @@ -186,8 +200,8 @@ formulario = turma.reload.formularios.sole expect(formulario.publico_alvo).to eq("docentes") - docente = criar_participante(tipo: :docente, departamento: turma.departamento) - criar_participacao(usuario: docente, turma: turma, tipo_participacao: :docente) + docente = usuario_docente(email: "docente-formulario@unb.br", departamento: turma.departamento) + ParticipacaoTurma.create!(usuario: docente, turma: turma, tipo_participacao: :docente) formulario.criar_avaliacoes_pendentes! login_como(docente) @@ -197,7 +211,12 @@ def criar_formulario_publicado(turma:, template: nil, publico_alvo: :docentes) template ||= criar_template_com_questoes(titulo: "Avaliação de teste") - Formulario.create!(adm: @admin.perfil_adm, turma: turma, template: template, publico_alvo: publico_alvo) + Formulario.create!( + adm: administrador_formularios.perfil_adm, + turma: turma, + template: template, + publico_alvo: publico_alvo + ) end def criar_turma_do_nome_exibicao(nome_exibicao) diff --git a/features/step_definitions/responder_avaliacao_steps.rb b/features/step_definitions/responder_avaliacao_steps.rb index 1c231fd6ff..dc913ebbee 100644 --- a/features/step_definitions/responder_avaliacao_steps.rb +++ b/features/step_definitions/responder_avaliacao_steps.rb @@ -1,9 +1,10 @@ -# features/step_definitions/responder_avaliacao_steps.rb -# Steps exclusivos da HU14 – Responder questionário da turma -# Steps compartilhados (usuário, autenticação) vêm do avaliacoes_steps.rb +# frozen_string_literal: true def criar_contexto_formulario_com_questoes(nome_turma) - depto = Departamento.find_or_create_by!(nome: 'Departamento Geral') + usuario = usuario_atual || estado[:usuario_participante] || usuario_participante + definir_usuario_atual(usuario) + + depto = Departamento.find_or_create_by!(nome: "Departamento Geral") materia = Materia.find_or_create_by!(nome: nome_turma, departamento: depto) do |m| m.codigo = "COD#{rand(1000..9999)}" end @@ -12,27 +13,29 @@ def criar_contexto_formulario_com_questoes(nome_turma) t.numero = rand(1..100) end - adm = Usuario.create!(nome: 'Adm', email: "adm#{rand(10000)}@t.com", - senha: 'password123', status: :ativo) + adm = Usuario.create!( + nome: "Administrador", + email: "administrador#{rand(10000)}@t.com", + matricula: "ADM#{rand(10000..99999)}", + senha: "password123", + status: :ativo + ) perf_adm = PerfilAdm.create!(usuario: adm, departamento: depto) - # Questão discursiva @questao_discursiva = Questao.create!( - enunciado: 'Como você avalia a turma?', + enunciado: "Como você avalia a turma?", tipo: :discursiva ) - # Questão objetiva com opções via nested attributes @questao_objetiva = Questao.create!( - enunciado: 'Qual nota você dá?', + enunciado: "Qual nota você dá?", tipo: :objetiva, opcoes_attributes: [ - { numero: 1, texto: 'Ótimo' }, - { numero: 2, texto: 'Ruim' } + { numero: 1, texto: "Ótimo" }, + { numero: 2, texto: "Ruim" } ] ) - # Template criado já com questões via nested attributes template = Template.create!( adm: perf_adm, titulo: "Template #{rand(1000)}", @@ -42,7 +45,6 @@ def criar_contexto_formulario_com_questoes(nome_turma) ] ) - # Recarrega para garantir associações atualizadas @questao_discursiva.reload @questao_objetiva.reload @@ -53,16 +55,17 @@ def criar_contexto_formulario_com_questoes(nome_turma) template: template ) - criar_participacao_com_perfil(@usuario, @turma, :discente) + PerfilDiscente.find_or_create_by!(usuario: usuario) + ParticipacaoTurma.find_or_create_by!( + usuario: usuario, + turma: @turma, + tipo_participacao: :discente + ) - participacao = ParticipacaoTurma.find_by!(usuario: @usuario, turma: @turma) - @avaliacao = Avaliacao.create!(formulario: @formulario, participacao_turma: participacao) + participacao = ParticipacaoTurma.find_by!(usuario: usuario, turma: @turma) + @avaliacao = Avaliacao.create!(formulario: @formulario, participacao_turma: participacao) end -# ------------------------------------------------------- -# Dado -# ------------------------------------------------------- - Dado('que estou na página de resposta do formulário da turma {string}') do |nome_turma| criar_contexto_formulario_com_questoes(nome_turma) visit responder_avaliacao_path(@avaliacao) @@ -73,15 +76,9 @@ def criar_contexto_formulario_com_questoes(nome_turma) @avaliacao.marcar_como_respondida! end -# ------------------------------------------------------- -# Quando -# ------------------------------------------------------- - Quando('eu preencho todas as questões obrigatórias') do - # Preenche a questão discursiva - fill_in "respostas[#{@questao_discursiva.id}][texto]", with: 'Achei a turma muito boa.' + fill_in "respostas[#{@questao_discursiva.id}][texto]", with: "Achei a turma muito boa." - # Seleciona a primeira opção da questão objetiva usando o texto da label primeira_opcao = @questao_objetiva.opcoes.ordenadas.first within "#questao-#{@questao_objetiva.id}" do choose primeira_opcao.texto @@ -89,24 +86,19 @@ def criar_contexto_formulario_com_questoes(nome_turma) end Quando('eu deixo uma questão obrigatória em branco') do - fill_in "respostas[#{@questao_discursiva.id}][texto]", with: 'Resposta parcial.' - # questão objetiva intencionalmente deixada em branco + fill_in "respostas[#{@questao_discursiva.id}][texto]", with: "Resposta parcial." end Quando('confirmo o envio da avaliação') do - click_button 'Confirmar envio' + click_button "Confirmar envio" end Quando('eu tento acessar a página de resposta do formulário da turma {string}') do |_nome_turma| visit responder_avaliacao_path(@avaliacao) end -# ------------------------------------------------------- -# Então -# ------------------------------------------------------- - Então('devo ver uma mensagem informando que a avaliação foi registrada com sucesso') do - expect(page).to have_content('Avaliação registrada com sucesso.') + expect(page).to have_content("Avaliação registrada com sucesso.") end Então('o formulário da turma {string} não deve mais aparecer na lista de pendentes') do |nome_turma| @@ -114,7 +106,7 @@ def criar_contexto_formulario_com_questoes(nome_turma) end Então('devo ver uma mensagem informando que todas as questões obrigatórias devem ser preenchidas') do - expect(page).to have_content('Todas as questões obrigatórias devem ser preenchidas.') + expect(page).to have_content("Todas as questões obrigatórias devem ser preenchidas.") end Então('a avaliação não deve ser registrada') do @@ -123,5 +115,5 @@ def criar_contexto_formulario_com_questoes(nome_turma) end Então('devo ver uma mensagem informando que esta avaliação já foi respondida') do - expect(page).to have_content('Esta avaliação já foi respondida.') -end \ No newline at end of file + expect(page).to have_content("Esta avaliação já foi respondida.") +end diff --git a/features/step_definitions/template_steps.rb b/features/step_definitions/template_steps.rb index 9c4a8bc75c..d3225c48a3 100644 --- a/features/step_definitions/template_steps.rb +++ b/features/step_definitions/template_steps.rb @@ -241,7 +241,13 @@ def mensagem_de_erro_do_template(template) end end -Then(/^devo ver uma mensagem informando que (.+)$/) do |mensagem| +Then(/^devo ver a mensagem "Você não tem permissão para visualizar templates\."$/) do + expect(estado[:mensagens]).to include("Você não tem permissão para visualizar templates.") +end + +Then( + /^devo ver uma mensagem informando que (o template .+|não tenho permissão para (?:criar|editar|deletar) templates|o título do template é obrigatório)$/ +) do |mensagem| mensagem = mensagem.delete_suffix(".") if mensagem.start_with?("o template") || diff --git a/features/support/camaar_world.rb b/features/support/camaar_world.rb index f58b140a9a..508e4878d3 100644 --- a/features/support/camaar_world.rb +++ b/features/support/camaar_world.rb @@ -47,10 +47,14 @@ def definir_usuario_atual(usuario) CucumberUsuarioAtual.usuario = usuario end - def adm_atual + def administrador_atual usuario_atual&.perfil_adm end + def adm_atual + administrador_atual + end + def departamento_com_nome(nome) Departamento.find_or_create_by!(nome: nome) end @@ -88,7 +92,7 @@ def usuario_administrador(departamento: "Departamento de Ciência da Computaçã departamento = departamento_com_nome(departamento) usuario = usuario_com_email( nome: "Administrador #{departamento.nome}", - email: "admin-#{departamento.id}@unb.br", + email: "administrador-#{departamento.id}@unb.br", senha: "Admin123" ) @@ -128,7 +132,7 @@ def usuario_docente(nome: "Docente", email: "docente@unb.br", departamento: nil) def usuario_nao_administrador usuario_participante( nome: "Usuário não administrador", - email: "nao-admin@unb.br", + email: "nao-administrador@unb.br", matricula: "232000000" ) end @@ -205,7 +209,7 @@ def questao_attributes(enunciado:, tipo:, opcoes: []) end def template_com_titulo(titulo, adm: nil, descricao: nil) - adm ||= adm_atual || usuario_administrador.perfil_adm + adm ||= administrador_atual || usuario_administrador.perfil_adm Template.find_by(titulo: titulo, adm: adm) || Template.create!( titulo: titulo, @@ -224,7 +228,7 @@ def template_com_titulo(titulo, adm: nil, descricao: nil) end def formulario_para_turma(turma, template: nil, adm: nil) - adm ||= adm_atual || usuario_administrador( + adm ||= administrador_atual || usuario_administrador( departamento: turma.departamento.nome ).perfil_adm template ||= template_com_titulo("Template padrão", adm: adm) @@ -251,7 +255,7 @@ def pendente_por_app_incompleto!(area) module CucumberApplicationAuthentication private - def current_usuario + def current_user CucumberUsuarioAtual.usuario || super end end diff --git a/features/support/test_data_helpers.rb b/features/support/test_data_helpers.rb index 3a443b6f0a..af4b807576 100644 --- a/features/support/test_data_helpers.rb +++ b/features/support/test_data_helpers.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module TestDataHelpers def criar_administrador(**attrs) departamento = attrs[:departamento] || Departamento.create!(nome: "DCC #{SecureRandom.hex(2)}") @@ -5,6 +7,7 @@ def criar_administrador(**attrs) defaults = { nome: "Administrador", email: "#{SecureRandom.hex(4)}@example.com", + matricula: "ADM#{SecureRandom.hex(5)}", status: :ativo, senha: "senha12345" } @@ -29,9 +32,11 @@ def criar_template_com_questoes(titulo:, questoes: nil) questao = Questao.new(attrs) - Array(opcoes).each_with_index do |texto, opcao_index| - questao.opcoes.build(numero: opcao_index + 1, texto: texto) - end if opcoes.present? + if opcoes.present? + Array(opcoes).each_with_index do |texto, opcao_index| + questao.opcoes.build(numero: opcao_index + 1, texto: texto) + end + end questao.save! @@ -60,7 +65,7 @@ def criar_turma(nome_materia:, numero:, semestre: Turma.semestre_atual, ano: Dat end def login_como(usuario) - allow_any_instance_of(ApplicationController).to receive(:current_usuario).and_return(usuario) + definir_usuario_atual(usuario) end def questoes_ordenadas_do_template(template) diff --git a/spec/requests/auth/autenticacao_spec.rb b/spec/requests/auth/autenticacao_spec.rb index 23aa7813e3..b22ca09bf6 100644 --- a/spec/requests/auth/autenticacao_spec.rb +++ b/spec/requests/auth/autenticacao_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "rails_helper" RSpec.describe "Autenticacao", type: :request do @@ -7,7 +9,7 @@ id: 42, email: "rafael@email.com", matricula: "123456789", - status: 1 + ativo?: true ) end @@ -42,8 +44,8 @@ expect(flash[:error]).to eq("Matrícula ou e-mail inválido.") end - it "falha se a conta do usuário não estiver ativa (status diferente de 1)" do - usuario_inativo = double("UsuarioInativo", status: 0) + it "falha se a conta do usuário não estiver ativa" do + usuario_inativo = double("UsuarioInativo", ativo?: false) allow(Usuario).to receive(:find_by).with(matricula: "123").and_return(usuario_inativo) post login_path, params: { identificador: "123", senha: "uma_senha" } diff --git a/spec/requests/auth/cadastro_spec.rb b/spec/requests/auth/cadastro_spec.rb index 24b845d4a6..78e9210a95 100644 --- a/spec/requests/auth/cadastro_spec.rb +++ b/spec/requests/auth/cadastro_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "rails_helper" RSpec.describe "Fluxo de Cadastro (Primeiro Acesso)", type: :request do @@ -6,7 +8,7 @@ matricula: "26100001", nome: "Rafael Sapienza", email: "rafael@unb.br", - status: 0, + status: :pendente, senha: "vazia_inicialmente" ) end @@ -32,8 +34,8 @@ expect(flash[:error]).to eq("O e-mail informado não corresponde ao e-mail institucional desta matrícula.") end - it "rejeita se a matrícula já possuir um cadastro ativo (status == 1)" do - usuario_institucional.update!(status: 1) + it "rejeita se a matrícula já possuir um cadastro ativo" do + usuario_institucional.update!(status: :ativo) post cadastro_path, params: { matricula: "26100001", email: "rafael@unb.br" } @@ -103,12 +105,12 @@ expect(flash[:error]).to eq("O link de confirmação é inválido, expirou ou não corresponde a esta operação.") end - it "salva a nova senha, muda status para 1 ativo e destrói o token usado" do + it "salva a nova senha, ativa o cadastro e destrói o token usado" do expect { post confirmar_cadastro_path, params: { token: "token_secreto_123", senha: "senha123", senha_confirmacao: "senha123" } }.to change(Token, :count).by(-1) usuario_institucional.reload - expect(usuario_institucional.status).to eq(1) + expect(usuario_institucional).to be_ativo expect(response).to redirect_to(root_path) expect(flash[:success]).to eq("Cadastro concluído com sucesso! Faça seu login.") diff --git a/spec/requests/auth/senha_redef_spec.rb b/spec/requests/auth/senha_redef_spec.rb index c771046f41..515a1108ff 100644 --- a/spec/requests/auth/senha_redef_spec.rb +++ b/spec/requests/auth/senha_redef_spec.rb @@ -1,15 +1,16 @@ -# spec/requests/auth/senha_redef_spec.rb -require 'rails_helper' +# frozen_string_literal: true + +require "rails_helper" RSpec.describe "Redefinição de Senha", type: :request do - # Cria um usuário base com a matrícula fictícia para passar nas validações do seu modelo let!(:usuario) do Usuario.create!( + nome: "Usuário de Teste", email: "usuario@teste.com", senha: "SenhaAntiga123", senha_confirmation: "SenhaAntiga123", matricula: "202600123", - status: 1 + status: :ativo ) end @@ -35,7 +36,6 @@ context "quando o e-mail é válido e cadastrado" do context "e o envio do e-mail ocorre com sucesso" do before do - # SIMULAÇÃO CORRETA: Intercepta o método no AuthController allow_any_instance_of(AuthController).to receive(:enviar_email_redefinicao).and_return(true) end @@ -47,7 +47,6 @@ expect(response).to redirect_to(root_path) expect(flash[:success]).to include("10 minutos") - # Garante que as propriedades do token foram salvas corretamente novo_token = usuario.tokens.last expect(novo_token.tipo).to eq("redefinicao") expect(novo_token.value).to be_present @@ -57,7 +56,6 @@ context "e ocorre um erro técnico no envio do e-mail" do before do - # SIMULAÇÃO: Força o método a falhar (retornar false) para testar o else allow_any_instance_of(AuthController).to receive(:enviar_email_redefinicao).and_return(false) end @@ -72,12 +70,10 @@ end describe "POST /redefinir-senha/confirmar" do - # Cria um token válido no banco associado ao usuário para os contextos de sucesso let!(:token_valido) { usuario.tokens.create!(value: "token_secreto_123", tipo: "redefinicao", expires_at: 10.minutes.from_now) } context "quando a nova senha tem menos de 6 caracteres" do it "recusa a alteração" do - # Ajustado para usar strings de rota diretas, evitando helpers desalinhados post "/redefinir-senha/confirmar", params: { token: "token_secreto_123", senha: "123", senha_confirmacao: "123" } expect(response).to redirect_to(redefinir_senha_path(token: "token_secreto_123")) @@ -110,16 +106,13 @@ expect(response).to redirect_to(root_path) expect(flash[:success]).to eq("Sua senha foi alterada com sucesso! Insira suas novas credenciais para acessar.") - # Garante que o token consumido foi devidamente deletado expect(Token.find_by(id: token_valido.id)).to be_nil end end context "quando o banco falha ao salvar o usuário por outra validação interna" do before do - # Simula uma falha na hora do usuario.save allow_any_instance_of(Usuario).to receive(:save).and_return(false) - # Permite que os erros do modelo retornem uma mensagem controlada allow_any_instance_of(ActiveModel::Errors).to receive(:full_messages).and_return([ "Erro customizado do modelo" ]) end diff --git a/spec/requests/auth/verify_adm_spec.rb b/spec/requests/auth/verify_adm_spec.rb index 12edb93182..cc42ad284c 100644 --- a/spec/requests/auth/verify_adm_spec.rb +++ b/spec/requests/auth/verify_adm_spec.rb @@ -1,9 +1,29 @@ +# frozen_string_literal: true + require "rails_helper" RSpec.describe "Dashboard", type: :request do - # ADICIONADO O CAMPO EMAIL NOS MOCKS ABAIXO - let(:usuario_comum) { double("UsuarioComum", id: 2, admin?: false, nome: "Rafael", matricula: "123456", email: "rafael@email.com") } - let(:usuario_admin) { double("UsuarioAdmin", id: 3, admin?: true, nome: "Admin", matricula: "654321", email: "admin@email.com") } + let(:usuario_comum) do + double( + "UsuarioComum", + id: 2, + administrador?: false, + nome: "Rafael", + matricula: "123456", + email: "rafael@email.com" + ) + end + + let(:usuario_administrador) do + double( + "UsuarioAdministrador", + id: 3, + administrador?: true, + nome: "Administrador", + matricula: "654321", + email: "administrador@email.com" + ) + end describe "GET /dashboard (index)" do it "redireciona para root se o usuário não estiver logado" do @@ -35,7 +55,7 @@ end it "permite que o administrador acesse a página de gerenciamento" do - allow_any_instance_of(ApplicationController).to receive(:current_user).and_return(usuario_admin) + allow_any_instance_of(ApplicationController).to receive(:current_user).and_return(usuario_administrador) get gerenciamento_path @@ -53,7 +73,7 @@ end it "EXIBE o link de Gerenciamento para usuários administradores" do - allow_any_instance_of(ApplicationController).to receive(:current_user).and_return(usuario_admin) + allow_any_instance_of(ApplicationController).to receive(:current_user).and_return(usuario_administrador) get avaliacoes_path diff --git a/spec/requests/avaliacoes_spec.rb b/spec/requests/avaliacoes_spec.rb index 218111dd10..896700948c 100644 --- a/spec/requests/avaliacoes_spec.rb +++ b/spec/requests/avaliacoes_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "rails_helper" RSpec.describe "Avaliacoes", type: :request do @@ -60,7 +62,7 @@ it "redireciona usuário não autenticado" do get avaliacoes_pendentes_path - expect(response).to redirect_to("/") + expect(response).to redirect_to(login_path) end end end diff --git a/spec/requests/formularios_spec.rb b/spec/requests/formularios_spec.rb index 141f68a0c1..1854b4c65c 100644 --- a/spec/requests/formularios_spec.rb +++ b/spec/requests/formularios_spec.rb @@ -106,8 +106,7 @@ def preparar_formulario(template_id: template.id, turma_ids: [ turma_a.id, turma get formularios_path expect(response).to redirect_to("/") - follow_redirect! - expect(response.body).to include("Acesso não autorizado") + expect(flash[:alert]).to eq("Acesso não autorizado") end end @@ -189,8 +188,7 @@ def preparar_formulario(template_id: template.id, turma_ids: [ turma_a.id, turma post formularios_path, params: { publico_alvo: "docentes" } expect(response).to redirect_to("/") - follow_redirect! - expect(response.body).to include("Acesso não autorizado") + expect(flash[:alert]).to eq("Acesso não autorizado") expect(Formulario.count).to eq(0) end end diff --git a/spec/requests/sigaa/enviar_solicitacoes_spec.rb b/spec/requests/sigaa/enviar_solicitacoes_spec.rb index 068fd01b13..e42078529a 100644 --- a/spec/requests/sigaa/enviar_solicitacoes_spec.rb +++ b/spec/requests/sigaa/enviar_solicitacoes_spec.rb @@ -1,4 +1,6 @@ -require 'rails_helper' +# frozen_string_literal: true + +require "rails_helper" RSpec.describe "Dashboard - Enviar Solicitações de Cadastro", type: :request do let!(:departamento) { Departamento.create!(nome: "Ciência da Computação") } @@ -15,14 +17,14 @@ let!(:perfil_adm) { PerfilAdm.create!(usuario: admin_user, departamento: departamento) } before do - allow(admin_user).to receive(:admin?).and_return(true) + allow(admin_user).to receive(:administrador?).and_return(true) allow_any_instance_of(DashboardController).to receive(:current_user).and_return(admin_user) end describe "POST /dashboard/enviar_solicitacoes" do context "quando o filtro 'verificar_admin' barra o acesso" do before do - allow(admin_user).to receive(:admin?).and_return(false) + allow(admin_user).to receive(:administrador?).and_return(false) end it "limpa a sessão e redireciona o intruso para a raiz com mensagem restrita" do @@ -82,7 +84,8 @@ PerfilDocente.create!(usuario: docente_pendente, departamento: departamento) materia = Materia.create!(nome: "Estruturas de Dados", codigo: "CIC0001", departamento_id: departamento.id) turma = Turma.create!(materia: materia, numero: 1, ano: 2026, semestre: 1) - ParticipacaoTurma.create!(usuario: discente_pendente, turma: turma) + PerfilDiscente.create!(usuario: discente_pendente) + ParticipacaoTurma.create!(usuario: discente_pendente, turma: turma, tipo_participacao: :discente) end context "e todos os envios de e-mail ocorrem perfeitamente" do diff --git a/spec/requests/sigaa/importar_dados_spec.rb b/spec/requests/sigaa/importar_dados_spec.rb index 0240b61e82..3961f245dd 100644 --- a/spec/requests/sigaa/importar_dados_spec.rb +++ b/spec/requests/sigaa/importar_dados_spec.rb @@ -1,14 +1,25 @@ +# frozen_string_literal: true + require "rails_helper" RSpec.describe "Importação de Dados SIGAA", type: :request do - let(:usuario_admin) { double("UsuarioAdmin", id: 99, admin?: true, nome: "Admin", matricula: "654321", email: "admin@unb.br") } + let(:usuario_administrador) do + double( + "UsuarioAdministrador", + id: 99, + administrador?: true, + nome: "Administrador", + matricula: "654321", + email: "administrador@unb.br" + ) + end let(:caminho_arquivo) { Rails.root.join("db", "usuarios_sigaa.json") } before do - Departamento.find_or_create_by!(id: 1) - Departamento.find_or_create_by!(id: 2) + Departamento.find_or_create_by!(id: 1) { |departamento| departamento.nome = "Departamento 1" } + Departamento.find_or_create_by!(id: 2) { |departamento| departamento.nome = "Departamento 2" } - allow_any_instance_of(ApplicationController).to receive(:current_user).and_return(usuario_admin) + allow_any_instance_of(ApplicationController).to receive(:current_user).and_return(usuario_administrador) end describe "POST /importar_dados - Arquivo inexistente" do it "redireciona para o gerenciamento avisando que o arquivo sumiu" do diff --git a/spec/requests/templates_spec.rb b/spec/requests/templates_spec.rb index 8cb487c4b4..268014f34e 100644 --- a/spec/requests/templates_spec.rb +++ b/spec/requests/templates_spec.rb @@ -1,12 +1,12 @@ require "rails_helper" RSpec.describe "Templates", type: :request do - let(:current_adm) { double("PerfilAdm", id: 1) } - let(:current_usuario) do + let(:current_administrador) { double("PerfilAdm", id: 1) } + let(:current_user) do double( "Usuario", administrador?: true, - perfil_adm: current_adm + perfil_adm: current_administrador ) end let(:template) do @@ -21,12 +21,12 @@ before do allow(template).to receive(:persisted?).and_return(true) allow_any_instance_of(ApplicationController) - .to receive(:current_usuario) - .and_return(current_usuario) + .to receive(:current_user) + .and_return(current_user) allow_any_instance_of(ApplicationController) - .to receive(:current_adm) - .and_return(current_adm) + .to receive(:current_administrador) + .and_return(current_administrador) allow_any_instance_of(ApplicationController) .to receive(:authorize!) @@ -53,11 +53,11 @@ allow(included_scope).to receive(:recentes).and_return(ordered_scope) allow(ordered_scope) .to receive(:criados_por) - .with(current_adm) + .with(current_administrador) .and_return([]) allow(ordered_scope) .to receive(:criados_por_outros) - .with(current_adm) + .with(current_administrador) .and_return([]) get templates_path @@ -73,7 +73,7 @@ allow(new_template).to receive(:questoes).and_return([]) allow(Template) .to receive(:new) - .with(adm: current_adm) + .with(adm: current_administrador) .and_return(new_template) get new_template_path diff --git a/spec/services/formularios/create_from_template_spec.rb b/spec/services/formularios/create_from_template_spec.rb index 966efeb146..89c1d2fe97 100644 --- a/spec/services/formularios/create_from_template_spec.rb +++ b/spec/services/formularios/create_from_template_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "rails_helper" RSpec.describe Formularios::CreateFromTemplate do @@ -103,7 +105,8 @@ def call_service(**overrides) ParticipacaoTurma.create!(usuario: docente, turma: turma_a, tipo_participacao: :docente) discente = create_usuario(nome: "Discente") - PerfilDiscente.create!(usuario: discente, matricula: "20260001") + discente.update!(matricula: "20260001") + PerfilDiscente.create!(usuario: discente) ParticipacaoTurma.create!(usuario: discente, turma: turma_a, tipo_participacao: :discente) formularios = call_service(turma_ids: [ turma_a.id ], publico_alvo: :docentes) diff --git a/spec/support/authentication_helpers.rb b/spec/support/authentication_helpers.rb index 10eda069dd..e06a0f9e44 100644 --- a/spec/support/authentication_helpers.rb +++ b/spec/support/authentication_helpers.rb @@ -1,6 +1,6 @@ module AuthenticationHelpers def sign_in_as(usuario) - allow_any_instance_of(ApplicationController).to receive(:current_usuario).and_return(usuario) + allow_any_instance_of(ApplicationController).to receive(:current_user).and_return(usuario) end end diff --git a/spec/support/test_builders.rb b/spec/support/test_builders.rb index 2ee70e1d20..571e310ed2 100644 --- a/spec/support/test_builders.rb +++ b/spec/support/test_builders.rb @@ -1,9 +1,12 @@ +# frozen_string_literal: true + module TestBuilders def create_admin_usuario(**attrs) departamento = attrs[:departamento] || Departamento.create!(nome: "DCC #{SecureRandom.hex(2)}") defaults = { nome: "Administrador", email: "#{SecureRandom.hex(4)}@example.com", + matricula: "ADM#{SecureRandom.hex(5)}", status: :ativo, senha: "senha12345" } @@ -16,6 +19,7 @@ def create_usuario(**attrs) defaults = { nome: "Usuário", email: "#{SecureRandom.hex(4)}@example.com", + matricula: "USR#{SecureRandom.hex(5)}", status: :ativo, senha: "senha12345" } @@ -34,9 +38,11 @@ def create_template_with_questoes(titulo:, adm: nil, questoes: nil) questao = Questao.new(attrs) - Array(opcoes).each_with_index do |texto, opcao_index| - questao.opcoes.build(numero: opcao_index + 1, texto: texto) - end if opcoes.present? + if opcoes.present? + Array(opcoes).each_with_index do |texto, opcao_index| + questao.opcoes.build(numero: opcao_index + 1, texto: texto) + end + end questao.save! @@ -76,7 +82,8 @@ def create_perfil_docente(usuario, departamento: nil, **attrs) def create_perfil_discente(usuario, matricula: nil, **attrs) matricula ||= "2026#{SecureRandom.hex(4)}" - PerfilDiscente.create!(id: usuario.id, usuario: usuario, matricula: matricula, **attrs) + usuario.update!(matricula: matricula) if usuario.matricula != matricula + PerfilDiscente.create!(id: usuario.id, usuario: usuario, **attrs) usuario end From adcc403fd0df0e6e76a65f183ee4c75391a85e3e Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Wed, 17 Jun 2026 10:54:43 -0300 Subject: [PATCH 079/125] ci: ajusta checks de Brakeman e Cucumber --- Gemfile | 4 +--- Gemfile.lock | 2 +- bin/brakeman | 2 -- config/cucumber.yml | 4 ++-- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/Gemfile b/Gemfile index c4a1ba7011..b9fcfc8189 100644 --- a/Gemfile +++ b/Gemfile @@ -50,7 +50,7 @@ group :development, :test do gem "bundler-audit", require: false # Static analysis for security vulnerabilities [https://brakemanscanner.org/] - gem "brakeman", "~> 8.0.5", require: false + gem "brakeman", require: false # Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/] gem "rubocop-rails-omakase", require: false @@ -74,5 +74,3 @@ group :test do gem "simplecov", require: false gem "database_cleaner-active_record" end - -gem "bcrypt", "~> 3.1.7" diff --git a/Gemfile.lock b/Gemfile.lock index 85c82379a2..6f55532b66 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -561,7 +561,7 @@ PLATFORMS DEPENDENCIES bcrypt (~> 3.1.7) bootsnap - brakeman (~> 8.0.5) + brakeman bundler-audit capybara csv diff --git a/bin/brakeman b/bin/brakeman index ace1c9ba08..171ac121a9 100755 --- a/bin/brakeman +++ b/bin/brakeman @@ -2,6 +2,4 @@ require "rubygems" require "bundler/setup" -ARGV.unshift("--ensure-latest") - load Gem.bin_path("brakeman", "brakeman") diff --git a/config/cucumber.yml b/config/cucumber.yml index 47a4663ae2..143642e3ba 100644 --- a/config/cucumber.yml +++ b/config/cucumber.yml @@ -2,7 +2,7 @@ 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'" +std_opts = "--format #{ENV['CUCUMBER_FORMAT'] || 'pretty'} --strict-undefined --no-strict-pending --tags 'not @wip'" %> default: <%= std_opts %> features -rerun: <%= rerun_opts %> --format rerun --out rerun.txt --strict --tags 'not @wip' +rerun: <%= rerun_opts %> --format rerun --out rerun.txt --strict-undefined --no-strict-pending --tags 'not @wip' From 755d1ee5749c33f31068d409de0c76ccfb83ebf7 Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Wed, 17 Jun 2026 11:01:43 -0300 Subject: [PATCH 080/125] =?UTF-8?q?ci:=20atualiza=20gems=20vulner=C3=A1vei?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Gemfile | 2 +- Gemfile.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Gemfile b/Gemfile index b9fcfc8189..b02404c636 100644 --- a/Gemfile +++ b/Gemfile @@ -7,7 +7,7 @@ gem "propshaft" # Use sqlite3 as the database for Active Record gem "sqlite3", ">= 2.1" # Use the Puma web server [https://github.com/puma/puma] -gem "puma", ">= 5.0" +gem "puma", ">= 8.0.2" # 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] diff --git a/Gemfile.lock b/Gemfile.lock index 6f55532b66..c2883d93bf 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -282,7 +282,7 @@ GEM prism (~> 1.5) msgpack (1.8.0) multi_test (1.1.0) - net-imap (0.6.4) + net-imap (0.6.4.1) date net-protocol net-pop (0.1.2) @@ -329,7 +329,7 @@ GEM date stringio public_suffix (7.0.5) - puma (8.0.1) + puma (8.0.2) nio4r (~> 2.0) raabro (1.4.0) racc (1.8.1) @@ -574,7 +574,7 @@ DEPENDENCIES jbuilder kamal propshaft - puma (>= 5.0) + puma (>= 8.0.2) rails (~> 8.1.3) rdoc rspec-rails @@ -689,7 +689,7 @@ CHECKSUMS minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1 msgpack (1.8.0) sha256=e64ce0212000d016809f5048b48eb3a65ffb169db22238fb4b72472fecb2d732 multi_test (1.1.0) sha256=e9e550cdd863fb72becfe344aefdcd4cbd26ebf307847f4a6c039a4082324d10 - net-imap (0.6.4) sha256=9a5598c67a3022c284d98430ef1d4948e7dbdb62596f61081ea8ca933270a02b + net-imap (0.6.4.1) sha256=29f0360d75a7efd3539f16ac1957dea5c0a51ddeceb348db4553c3120914ea0d net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8 net-scp (4.1.0) sha256=a99b0b92a1e5d360b0de4ffbf2dc0c91531502d3d4f56c28b0139a7c093d1a5d @@ -714,7 +714,7 @@ CHECKSUMS propshaft (1.3.2) sha256=1d56a3e56a92c21bfc29caf07406b5386b00d4c47ddf357cf989a5a234b1389e psych (5.3.1) sha256=eb7a57cef10c9d70173ff74e739d843ac3b2c019a003de48447b2963d81b1974 public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 - puma (8.0.1) sha256=7b94e50c07655718c1fb8ae41a11fc06c7d61293208b3aa608ff71a46d3ad37c + puma (8.0.2) sha256=c8ed871dfbbe66448ea9ffd46692342d9804d4071522b52b5331b7b6e7b686fb raabro (1.4.0) sha256=d4fa9ff5172391edb92b242eed8be802d1934b1464061ae5e70d80962c5da882 racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f rack (3.2.6) sha256=5ed78e1f73b2e25679bec7d45ee2d4483cc4146eb1be0264fc4d94cb5ef212c2 From 5fdb24aa679a15d3d1dd447f2d4e18f81b01f642 Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Fri, 19 Jun 2026 20:29:16 -0300 Subject: [PATCH 081/125] =?UTF-8?q?fix(templates):=20corre=C3=A7=C3=A3o=20?= =?UTF-8?q?de=20bug=20na=20rederiza=C3=A7=C3=A3o=20das=20op=C3=A7=C3=B5es?= =?UTF-8?q?=20de=20uma=20quest=C3=A3o=20de=20um=20template?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/controllers/templates_controller.rb | 90 ++++++++++++++++++++++--- app/models/questao.rb | 4 +- app/models/template.rb | 13 ++-- app/models/utilizacao_questao.rb | 4 +- 4 files changed, 92 insertions(+), 19 deletions(-) diff --git a/app/controllers/templates_controller.rb b/app/controllers/templates_controller.rb index 9ba09ee566..309e61bfdf 100644 --- a/app/controllers/templates_controller.rb +++ b/app/controllers/templates_controller.rb @@ -21,7 +21,7 @@ def show def new @template = Template.new(adm: current_administrador) - preparar_campos_do_formulario + preparar_campos_do_template authorize! @template end @@ -35,14 +35,14 @@ def create if @template.save redirect_to @template, notice: "Template criado com sucesso." else - preparar_campos_do_formulario + preparar_campos_do_template render :new, status: :unprocessable_entity end end def edit - preparar_campos_do_formulario + preparar_campos_do_template authorize! @template end @@ -50,10 +50,12 @@ def edit def update authorize! @template - if @template.update(template_params) + template_atualizado = atualizar_template_com_reordenacao + + if template_atualizado redirect_to @template, notice: "Template atualizado com sucesso." else - preparar_campos_do_formulario + preparar_campos_do_template render :edit, status: :unprocessable_entity end @@ -73,15 +75,85 @@ def set_template @template = Template.find(params[:id]) end - def preparar_campos_do_formulario - utilizacoes = @template.utilizacao_questoes + def preparar_campos_do_template + utilizacoes = @template.utilizacoes_questoes utilizacoes.build(numero: 1) if utilizacoes.empty? utilizacoes.each do |utilizacao| utilizacao.build_questao(tipo: :discursiva) if utilizacao.questao.blank? + end + end + + def atualizar_template_com_reordenacao + template_atualizado = false + + Template.transaction do + preparar_reordenacao_de_registros_persistidos + template_atualizado = @template.update(template_params) + + raise ActiveRecord::Rollback unless template_atualizado + end + + template_atualizado + end + + def preparar_reordenacao_de_registros_persistidos + utilizacoes_attributes = nested_attributes_values( + params.dig(:template, :utilizacoes_questoes_attributes) + ) + + preparar_reordenacao_de_utilizacoes(utilizacoes_attributes) + preparar_reordenacao_de_opcoes(utilizacoes_attributes) + end + + def preparar_reordenacao_de_utilizacoes(utilizacoes_attributes) + ids = utilizacoes_attributes + .reject { |attributes| destroy_attribute?(attributes) } + .filter_map { |attributes| persisted_id_with_number(attributes) } + + UtilizacaoQuestao + .where(template_id: @template.id, id: ids) + .find_each + .with_index(1) do |utilizacao, index| + utilizacao.update_columns(numero: -index) + end + end + + def preparar_reordenacao_de_opcoes(utilizacoes_attributes) + opcao_ids = utilizacoes_attributes.flat_map do |utilizacao_attributes| + questao_attributes = utilizacao_attributes[:questao_attributes] || + utilizacao_attributes["questao_attributes"] + opcoes_attributes = nested_attributes_values( + questao_attributes&.dig(:opcoes_attributes) || + questao_attributes&.dig("opcoes_attributes") + ) - 4.times { utilizacao.questao.opcoes.build } + opcoes_attributes + .reject { |attributes| destroy_attribute?(attributes) } + .filter_map { |attributes| persisted_id_with_number(attributes) } end + + Opcao.where(id: opcao_ids).find_each.with_index(1) do |opcao, index| + opcao.update_columns(numero: -index) + end + end + + def nested_attributes_values(attributes) + return [] if attributes.blank? + return attributes.values if attributes.respond_to?(:values) + + Array(attributes) + end + + def destroy_attribute?(attributes) + ActiveModel::Type::Boolean.new.cast(attributes[:_destroy] || attributes["_destroy"]) + end + + def persisted_id_with_number(attributes) + id = attributes[:id] || attributes["id"] + numero = attributes[:numero] || attributes["numero"] + + id if id.present? && numero.present? end def template_params @@ -90,7 +162,7 @@ def template_params .permit( :titulo, :descricao, - utilizacoes_questao_attributes: [ + utilizacoes_questoes_attributes: [ :id, :questao_id, :numero, diff --git a/app/models/questao.rb b/app/models/questao.rb index c4520889b1..389d4ff758 100644 --- a/app/models/questao.rb +++ b/app/models/questao.rb @@ -11,13 +11,13 @@ class Questao < ApplicationRecord optional: true, inverse_of: :questoes - has_many :utilizacao_questoes, + has_many :utilizacoes_questoes, class_name: "UtilizacaoQuestao", foreign_key: :questao_id, inverse_of: :questao, dependent: :restrict_with_error - has_many :templates, through: :utilizacao_questoes, source: :template + has_many :templates, through: :utilizacoes_questoes, source: :template has_many :opcoes, class_name: "Opcao", diff --git a/app/models/template.rb b/app/models/template.rb index 4001a79e7e..b5a243290a 100644 --- a/app/models/template.rb +++ b/app/models/template.rb @@ -6,13 +6,13 @@ class Template < ApplicationRecord foreign_key: :adm_id, inverse_of: :templates - has_many :utilizacao_questoes, + has_many :utilizacoes_questoes, class_name: "UtilizacaoQuestao", foreign_key: :template_id, inverse_of: :template, dependent: :destroy - has_many :questoes, through: :utilizacao_questoes, source: :questao + has_many :questoes, through: :utilizacoes_questoes, source: :questao has_many :formularios, class_name: "Formulario", @@ -20,7 +20,7 @@ class Template < ApplicationRecord inverse_of: :template, dependent: :nullify - accepts_nested_attributes_for :utilizacao_questoes, + accepts_nested_attributes_for :utilizacoes_questoes, allow_destroy: true, reject_if: :utilizacao_questao_em_branco? @@ -49,7 +49,7 @@ def criado_por?(perfil_adm) end def questoes_ordenadas - utilizacao_questoes.includes(:questao, :opcoes).ordenadas + utilizacoes_questoes.includes(:questao, :opcoes).ordenadas end private @@ -63,13 +63,14 @@ def preencher_criado_em end def deve_ter_ao_menos_uma_questao - questoes_validas = utilizacao_questoes.reject(&:marked_for_destruction?) + questoes_validas = utilizacoes_questoes.reject(&:marked_for_destruction?) return if questoes_validas.any? - errors.add(:utilizacao_questoes, "deve conter ao menos uma questão") + errors.add(:utilizacoes_questoes, "deve conter ao menos uma questão") end def utilizacao_questao_em_branco?(attributes) + return false if attributes["id"].present? return false if attributes["questao_id"].present? questao_attributes = attributes["questao_attributes"] || {} diff --git a/app/models/utilizacao_questao.rb b/app/models/utilizacao_questao.rb index cc6ae00b0e..a9bcd1b56d 100644 --- a/app/models/utilizacao_questao.rb +++ b/app/models/utilizacao_questao.rb @@ -4,12 +4,12 @@ class UtilizacaoQuestao < ApplicationRecord belongs_to :template, class_name: "Template", foreign_key: :template_id, - inverse_of: :utilizacao_questoes + inverse_of: :utilizacoes_questoes belongs_to :questao, class_name: "Questao", foreign_key: :questao_id, - inverse_of: :utilizacao_questoes + inverse_of: :utilizacoes_questoes belongs_to :parent, class_name: "UtilizacaoQuestao", From a5f167097c22f00d744a8490909fe0bcf5c47812 Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Fri, 19 Jun 2026 20:35:39 -0300 Subject: [PATCH 082/125] =?UTF-8?q?test(templates):=20adiciona=20testes=20?= =?UTF-8?q?cucumber=20para=20features=20de=20quest=C3=B5es=20e=20op=C3=A7?= =?UTF-8?q?=C3=B5es=20(est=C3=A3o=20de=20escopo=20de=20templates=20pois=20?= =?UTF-8?q?s=C3=A3o=20"nested"=20neles=20e=20todas=20as=20features=20testa?= =?UTF-8?q?das=20est=C3=A3o=20nas=20views=20de=20templates)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../template_dinamico_steps.rb | 258 ++++++++++++++++++ .../templates/opcoes/gerenciar_opcoes.feature | 57 ++++ .../questoes/gerenciar_questoes.feature | 46 ++++ 3 files changed, 361 insertions(+) create mode 100644 features/step_definitions/template_dinamico_steps.rb create mode 100644 features/templates/opcoes/gerenciar_opcoes.feature create mode 100644 features/templates/questoes/gerenciar_questoes.feature diff --git a/features/step_definitions/template_dinamico_steps.rb b/features/step_definitions/template_dinamico_steps.rb new file mode 100644 index 0000000000..4dfbe7c946 --- /dev/null +++ b/features/step_definitions/template_dinamico_steps.rb @@ -0,0 +1,258 @@ +# frozen_string_literal: true + +def utilizacoes_attributes_da_tabela(table) + table.hashes.each_with_index.map do |linha, index| + { + numero: index + 1, + questao_attributes: questao_attributes( + enunciado: linha.fetch("enunciado"), + tipo: linha.fetch("tipo") + ) + } + end +end + +def template_por_titulo!(titulo) + Template.find_by!(titulo: titulo, adm: adm_atual) +end + +def questao_do_template!(template, enunciado) + template + .questoes + .includes(:opcoes) + .find_by!(enunciado: enunciado) +end + +def utilizacao_da_questao!(template, enunciado) + template + .utilizacoes_questoes + .includes(:questao) + .find { |utilizacao| utilizacao.questao.enunciado == enunciado } || + raise(ActiveRecord::RecordNotFound, "Questão não encontrada: #{enunciado}") +end + +def opcao_da_questao!(questao, texto) + questao.opcoes.find_by!(texto: texto) +end + +def submeter_criacao_de_template(titulo, utilizacoes_attributes) + page.driver.submit( + :post, + templates_path, + { + template: { + titulo: titulo, + descricao: "Template criado em cenário de aceitação", + utilizacoes_questoes_attributes: utilizacoes_attributes + } + } + ) +end + +def submeter_atualizacao_de_template(template, utilizacoes_attributes) + page.driver.submit( + :patch, + template_path(template), + { + template: { + titulo: template.titulo, + descricao: template.descricao, + utilizacoes_questoes_attributes: utilizacoes_attributes + } + } + ) +end + +def criar_template_com_questoes!(titulo, table) + template = Template.create!( + titulo: titulo, + descricao: "Template criado em cenário de aceitação", + adm: adm_atual, + utilizacoes_questoes_attributes: utilizacoes_attributes_da_tabela(table) + ) + + estado[:template_atual] = template +end + +Quando(/^envio o formulário do template "([^"]+)" com as questões:$/) do |titulo, table| + submeter_criacao_de_template(titulo, utilizacoes_attributes_da_tabela(table)) +end + +Dado(/^que existe o template "([^"]+)" com as questões:$/) do |titulo, table| + criar_template_com_questoes!(titulo, table) +end + +Quando(/^removo a questão "([^"]+)" do template "([^"]+)"$/) do |enunciado, titulo| + template = template_por_titulo!(titulo) + utilizacao = utilizacao_da_questao!(template, enunciado) + + submeter_atualizacao_de_template( + template, + { + "0" => { + id: utilizacao.id, + _destroy: "1" + } + } + ) +end + +Quando(/^reordeno as questões do template "([^"]+)" para:$/) do |titulo, table| + template = template_por_titulo!(titulo) + utilizacoes_por_enunciado = template + .utilizacoes_questoes + .includes(:questao) + .index_by { |utilizacao| utilizacao.questao.enunciado } + + utilizacoes_attributes = table.hashes.each_with_index.to_h do |linha, index| + utilizacao = utilizacoes_por_enunciado.fetch(linha.fetch("enunciado")) + + [ + index.to_s, + { + id: utilizacao.id, + numero: index + 1 + } + ] + end + + submeter_atualizacao_de_template(template, utilizacoes_attributes) +end + +Então(/^o template "([^"]+)" deve conter as questões na ordem:$/) do |titulo, table| + template = template_por_titulo!(titulo) + questoes = template + .questoes_ordenadas + .map { |utilizacao| utilizacao.questao.enunciado } + + expect(questoes).to eq(table.hashes.map { |linha| linha.fetch("enunciado") }) +end + +Dado( + /^que existe o template "([^"]+)" com a questão objetiva "([^"]+)" e as opções:$/ +) do |titulo, enunciado, table| + opcoes = table.hashes.map { |linha| linha.fetch("texto") } + + template = Template.create!( + titulo: titulo, + descricao: "Template criado em cenário de aceitação", + adm: adm_atual, + utilizacoes_questoes_attributes: [ + { + numero: 1, + questao_attributes: questao_attributes( + enunciado: enunciado, + tipo: :objetiva, + opcoes: opcoes + ) + } + ] + ) + + estado[:template_atual] = template +end + +Quando( + /^adiciono as opções à questão "([^"]+)" do template "([^"]+)":$/ +) do |enunciado, titulo, table| + template = template_por_titulo!(titulo) + utilizacao = utilizacao_da_questao!(template, enunciado) + questao = utilizacao.questao + proximo_numero = questao.opcoes.maximum(:numero).to_i + 1 + + opcoes_attributes = table.hashes.each_with_index.to_h do |linha, index| + [ + index.to_s, + { + numero: proximo_numero + index, + texto: linha.fetch("texto") + } + ] + end + + submeter_atualizacao_de_template( + template, + { + "0" => { + id: utilizacao.id, + questao_attributes: { + id: questao.id, + enunciado: questao.enunciado, + tipo: questao.tipo, + opcoes_attributes: opcoes_attributes + } + } + } + ) +end + +Quando( + /^removo a opção "([^"]+)" da questão "([^"]+)" do template "([^"]+)"$/ +) do |texto, enunciado, titulo| + template = template_por_titulo!(titulo) + utilizacao = utilizacao_da_questao!(template, enunciado) + questao = utilizacao.questao + opcao = opcao_da_questao!(questao, texto) + + submeter_atualizacao_de_template( + template, + { + "0" => { + id: utilizacao.id, + questao_attributes: { + id: questao.id, + opcoes_attributes: { + "0" => { + id: opcao.id, + _destroy: "1" + } + } + } + } + } + ) +end + +Quando( + /^reordeno as opções da questão "([^"]+)" do template "([^"]+)" para:$/ +) do |enunciado, titulo, table| + template = template_por_titulo!(titulo) + utilizacao = utilizacao_da_questao!(template, enunciado) + questao = utilizacao.questao + opcoes_por_texto = questao.opcoes.index_by(&:texto) + + opcoes_attributes = table.hashes.each_with_index.to_h do |linha, index| + opcao = opcoes_por_texto.fetch(linha.fetch("texto")) + + [ + index.to_s, + { + id: opcao.id, + numero: index + 1 + } + ] + end + + submeter_atualizacao_de_template( + template, + { + "0" => { + id: utilizacao.id, + questao_attributes: { + id: questao.id, + opcoes_attributes: opcoes_attributes + } + } + } + ) +end + +Então( + /^a questão "([^"]+)" do template "([^"]+)" deve conter as opções na ordem:$/ +) do |enunciado, titulo, table| + template = template_por_titulo!(titulo) + questao = questao_do_template!(template, enunciado) + opcoes = questao.opcoes.ordenadas.pluck(:texto) + + expect(opcoes).to eq(table.hashes.map { |linha| linha.fetch("texto") }) +end diff --git a/features/templates/opcoes/gerenciar_opcoes.feature b/features/templates/opcoes/gerenciar_opcoes.feature new file mode 100644 index 0000000000..e9c73fd5f3 --- /dev/null +++ b/features/templates/opcoes/gerenciar_opcoes.feature @@ -0,0 +1,57 @@ +# language: pt +Funcionalidade: Gerenciar opções de questões objetivas + Como Administrador + Quero criar, remover e reordenar opções de uma questão objetiva + A fim de definir a ordem das alternativas exibidas no formulário + + Contexto: + Dado que existe um usuário administrador cadastrado no sistema + E que estou autenticado como administrador + + Cenário: Criar opções em questão objetiva + Dado que existe o template "Avaliação com Opções" com a questão objetiva "Como você avalia a disciplina?" e as opções: + | texto | + | Ruim | + | Regular | + Quando adiciono as opções à questão "Como você avalia a disciplina?" do template "Avaliação com Opções": + | texto | + | Boa | + | Excelente | + Então a questão "Como você avalia a disciplina?" do template "Avaliação com Opções" deve conter as opções na ordem: + | texto | + | Ruim | + | Regular | + | Boa | + | Excelente | + + Cenário: Remover opção de questão objetiva + Dado que existe o template "Avaliação para Remoção de Opção" com a questão objetiva "Como você avalia a didática?" e as opções: + | texto | + | Ruim | + | Regular | + | Boa | + Quando removo a opção "Regular" da questão "Como você avalia a didática?" do template "Avaliação para Remoção de Opção" + Então a questão "Como você avalia a didática?" do template "Avaliação para Remoção de Opção" deve conter as opções na ordem: + | texto | + | Ruim | + | Boa | + + Cenário: Reordenar opções de questão objetiva + Dado que existe o template "Avaliação para Reordenar Opções" com a questão objetiva "Como você avalia o professor?" e as opções: + | texto | + | Ruim | + | Regular | + | Boa | + | Excelente | + Quando reordeno as opções da questão "Como você avalia o professor?" do template "Avaliação para Reordenar Opções" para: + | texto | + | Excelente | + | Boa | + | Regular | + | Ruim | + Então a questão "Como você avalia o professor?" do template "Avaliação para Reordenar Opções" deve conter as opções na ordem: + | texto | + | Excelente | + | Boa | + | Regular | + | Ruim | diff --git a/features/templates/questoes/gerenciar_questoes.feature b/features/templates/questoes/gerenciar_questoes.feature new file mode 100644 index 0000000000..a16f35212e --- /dev/null +++ b/features/templates/questoes/gerenciar_questoes.feature @@ -0,0 +1,46 @@ +# language: pt +Funcionalidade: Gerenciar questões do template + Como Administrador + Quero criar, remover e reordenar questões de um template + A fim de definir a estrutura do formulário antes de publicá-lo + + Contexto: + Dado que existe um usuário administrador cadastrado no sistema + E que estou autenticado como administrador + + Cenário: Criar questões no template + Quando envio o formulário do template "Avaliação com Questões" com as questões: + | enunciado | tipo | + | Descreva os pontos positivos | discursiva | + | Descreva os pontos de melhoria | discursiva | + Então o template "Avaliação com Questões" deve conter as questões na ordem: + | enunciado | + | Descreva os pontos positivos | + | Descreva os pontos de melhoria | + + Cenário: Remover questão do template + Dado que existe o template "Avaliação para Remoção de Questão" com as questões: + | enunciado | tipo | + | Questão que permanece | discursiva | + | Questão removida | discursiva | + Quando removo a questão "Questão removida" do template "Avaliação para Remoção de Questão" + Então o template "Avaliação para Remoção de Questão" deve conter as questões na ordem: + | enunciado | + | Questão que permanece | + + Cenário: Reordenar questões do template + Dado que existe o template "Avaliação para Reordenar Questões" com as questões: + | enunciado | tipo | + | Primeira questão | discursiva | + | Segunda questão | discursiva | + | Terceira questão | discursiva | + Quando reordeno as questões do template "Avaliação para Reordenar Questões" para: + | enunciado | + | Terceira questão | + | Primeira questão | + | Segunda questão | + Então o template "Avaliação para Reordenar Questões" deve conter as questões na ordem: + | enunciado | + | Terceira questão | + | Primeira questão | + | Segunda questão | From 76394d1d1d66d155b0140ba38b9c6825d32c8759 Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Fri, 19 Jun 2026 20:37:33 -0300 Subject: [PATCH 083/125] =?UTF-8?q?fix:=20corrige=20a=20utiliza=C3=A7?= =?UTF-8?q?=C3=A3o=20de=20`utilizacao=5Fquestoes`=20para=20`utilizacoes=5F?= =?UTF-8?q?questoes`,=20de=20acordo=20com=20inflections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- features/step_definitions/domain_steps.rb | 4 +- .../step_definitions/exportar_csv_steps.rb | 6 +- .../responder_avaliacao_steps.rb | 2 +- features/step_definitions/template_steps.rb | 4 +- features/support/camaar_world.rb | 2 +- features/support/test_data_helpers.rb | 6 +- spec/models/questao_spec.rb | 16 ++++- spec/models/template_spec.rb | 71 +++++++++++++++++-- 8 files changed, 93 insertions(+), 18 deletions(-) diff --git a/features/step_definitions/domain_steps.rb b/features/step_definitions/domain_steps.rb index c1ae2f139c..4880857eb8 100644 --- a/features/step_definitions/domain_steps.rb +++ b/features/step_definitions/domain_steps.rb @@ -94,9 +94,9 @@ next if template.questoes.exists?(enunciado: enunciado) questao = Questao.create!(enunciado: enunciado, tipo: :discursiva) - template.utilizacao_questoes.create!( + template.utilizacoes_questoes.create!( questao: questao, - numero: template.utilizacao_questoes.count + 1 + numero: template.utilizacoes_questoes.count + 1 ) end diff --git a/features/step_definitions/exportar_csv_steps.rb b/features/step_definitions/exportar_csv_steps.rb index c1672db5ee..ed196ce783 100644 --- a/features/step_definitions/exportar_csv_steps.rb +++ b/features/step_definitions/exportar_csv_steps.rb @@ -21,7 +21,7 @@ def departamento_csv template = Template.create!( adm: administrador_csv.perfil_adm, titulo: "Template Padrão", - utilizacao_questoes_attributes: [ + utilizacoes_questoes_attributes: [ { questao_id: @questao.id, numero: 1 } ] ) @@ -83,7 +83,7 @@ def departamento_csv template = Template.create!( adm: administrador_csv.perfil_adm, titulo: "Template Vazio", - utilizacao_questoes_attributes: [ + utilizacoes_questoes_attributes: [ { questao_id: @questao.id, numero: 1 } ] ) @@ -127,7 +127,7 @@ def departamento_csv template = Template.create!( adm: perf, titulo: "Template Sad", - utilizacao_questoes_attributes: [ + utilizacoes_questoes_attributes: [ { questao_id: questao.id, numero: 1 } ] ) diff --git a/features/step_definitions/responder_avaliacao_steps.rb b/features/step_definitions/responder_avaliacao_steps.rb index dc913ebbee..080f899539 100644 --- a/features/step_definitions/responder_avaliacao_steps.rb +++ b/features/step_definitions/responder_avaliacao_steps.rb @@ -39,7 +39,7 @@ def criar_contexto_formulario_com_questoes(nome_turma) template = Template.create!( adm: perf_adm, titulo: "Template #{rand(1000)}", - utilizacao_questoes_attributes: [ + utilizacoes_questoes_attributes: [ { questao_id: @questao_discursiva.id, numero: 1 }, { questao_id: @questao_objetiva.id, numero: 2 } ] diff --git a/features/step_definitions/template_steps.rb b/features/step_definitions/template_steps.rb index d3225c48a3..f28db1b0d4 100644 --- a/features/step_definitions/template_steps.rb +++ b/features/step_definitions/template_steps.rb @@ -13,7 +13,7 @@ def template_params_do_formulario template: { titulo: estado[:template_form][:titulo], descricao: estado[:template_form][:descricao], - utilizacao_questoes_attributes: estado[:template_form][:questoes] + utilizacoes_questoes_attributes: estado[:template_form][:questoes] .each_with_index .map do |questao, index| { @@ -32,7 +32,7 @@ def template_params_do_formulario def mensagem_de_erro_do_template(template) return "o título do template é obrigatório" if template.errors[:titulo].any? - if template.errors[:utilizacao_questoes].any? + if template.errors[:utilizacoes_questoes].any? return "o template deve possuir pelo menos uma questão" end diff --git a/features/support/camaar_world.rb b/features/support/camaar_world.rb index 508e4878d3..83d8c8de3a 100644 --- a/features/support/camaar_world.rb +++ b/features/support/camaar_world.rb @@ -215,7 +215,7 @@ def template_com_titulo(titulo, adm: nil, descricao: nil) titulo: titulo, descricao: descricao, adm: adm, - utilizacao_questoes_attributes: [ + utilizacoes_questoes_attributes: [ { numero: 1, questao_attributes: questao_attributes( diff --git a/features/support/test_data_helpers.rb b/features/support/test_data_helpers.rb index af4b807576..6543d7b5d4 100644 --- a/features/support/test_data_helpers.rb +++ b/features/support/test_data_helpers.rb @@ -40,11 +40,11 @@ def criar_template_com_questoes(titulo:, questoes: nil) questao.save! - template.utilizacao_questoes.build(questao: questao, numero: numero) + template.utilizacoes_questoes.build(questao: questao, numero: numero) end template.save! - template.utilizacao_questoes.each(&:save!) + template.utilizacoes_questoes.each(&:save!) template end @@ -69,7 +69,7 @@ def login_como(usuario) end def questoes_ordenadas_do_template(template) - template.utilizacao_questoes.raizes.ordenadas.includes(:questao).map(&:questao) + template.utilizacoes_questoes.raizes.ordenadas.includes(:questao).map(&:questao) end private diff --git a/spec/models/questao_spec.rb b/spec/models/questao_spec.rb index fa837f0d1c..3689157a79 100644 --- a/spec/models/questao_spec.rb +++ b/spec/models/questao_spec.rb @@ -6,7 +6,7 @@ describe "associações" do it "possui utilizações de questões com restrição de remoção" do association = - described_class.reflect_on_association(:utilizacao_questoes) + described_class.reflect_on_association(:utilizacoes_questoes) expect(association.macro).to eq(:has_many) expect(association.class_name).to eq("UtilizacaoQuestao") @@ -102,6 +102,20 @@ .to include("não devem existir em questão discursiva") end + it "não permite opções em questão discursiva via atributos aninhados" do + questao = described_class.new( + enunciado: "Descreva sua experiência", + tipo: :discursiva, + opcoes_attributes: [ + { numero: 1, texto: "Boa" } + ] + ) + + expect(questao).not_to be_valid + expect(questao.errors[:opcoes]) + .to include("não devem existir em questão discursiva") + end + it "normaliza o enunciado" do questao = described_class.new( enunciado: " Descreva sua experiência ", diff --git a/spec/models/template_spec.rb b/spec/models/template_spec.rb index a0fc755721..e4c5d3ff84 100644 --- a/spec/models/template_spec.rb +++ b/spec/models/template_spec.rb @@ -44,7 +44,7 @@ it "possui utilizações de questões removidas em cascata" do association = - described_class.reflect_on_association(:utilizacao_questoes) + described_class.reflect_on_association(:utilizacoes_questoes) expect(association.macro).to eq(:has_many) expect(association.class_name).to eq("UtilizacaoQuestao") @@ -56,7 +56,7 @@ association = described_class.reflect_on_association(:questoes) expect(association.macro).to eq(:has_many) - expect(association.options[:through]).to eq(:utilizacao_questoes) + expect(association.options[:through]).to eq(:utilizacoes_questoes) expect(association.options[:source]).to eq(:questao) end @@ -82,7 +82,7 @@ simular_utilizacoes(template, []) expect(template).not_to be_valid - expect(template.errors[:utilizacao_questoes]) + expect(template.errors[:utilizacoes_questoes]) .to include("deve conter ao menos uma questão") end @@ -90,7 +90,7 @@ simular_utilizacoes(template, [ utilizacao_removida ]) expect(template).not_to be_valid - expect(template.errors[:utilizacao_questoes]) + expect(template.errors[:utilizacoes_questoes]) .to include("deve conter ao menos uma questão") end @@ -127,6 +127,67 @@ end end + describe "atributos aninhados" do + it "remove utilizações de questões marcadas para destruição" do + template_com_questoes = create_template_with_questoes( + titulo: "Avaliação com duas questões" + ) + utilizacao_removida = template_com_questoes.utilizacoes_questoes.first + questao = utilizacao_removida.questao + + expect do + template_com_questoes.update!( + utilizacoes_questoes_attributes: { + "0" => { + id: utilizacao_removida.id, + _destroy: "1" + } + } + ) + end.to change(UtilizacaoQuestao, :count).by(-1) + + expect(UtilizacaoQuestao.exists?(utilizacao_removida.id)).to be(false) + expect(Questao.exists?(questao.id)).to be(true) + end + + it "remove opções marcadas para destruição" do + template_com_questao_objetiva = create_template_with_questoes( + titulo: "Avaliação objetiva", + questoes: [ + { + enunciado: "Como você avalia a disciplina?", + tipo: :objetiva, + opcoes: %w[Ruim Regular Bom] + } + ] + ) + utilizacao = template_com_questao_objetiva.utilizacoes_questoes.first + questao = utilizacao.questao + opcao_removida = questao.opcoes.first + + expect do + template_com_questao_objetiva.update!( + utilizacoes_questoes_attributes: { + "0" => { + id: utilizacao.id, + questao_attributes: { + id: questao.id, + opcoes_attributes: { + "0" => { + id: opcao_removida.id, + _destroy: "1" + } + } + } + } + } + ) + end.to change(Opcao, :count).by(-1) + + expect(Opcao.exists?(opcao_removida.id)).to be(false) + end + end + describe "#criado_por?" do it "retorna verdadeiro quando o perfil é o administrador criador" do perfil_adm = double("PerfilAdm", id: 1) @@ -141,7 +202,7 @@ def simular_utilizacoes(template, utilizacoes) allow(template) - .to receive(:utilizacao_questoes) + .to receive(:utilizacoes_questoes) .and_return(utilizacoes) end end From 826a4e10fe3fcd0630bc059e6034cb871ae4cd7b Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Fri, 19 Jun 2026 20:40:19 -0300 Subject: [PATCH 084/125] feat(ux/ui): redesenha as views e adiciona funcionalidades a elas --- app/assets/images/icons/arrow-down.svg | 3 + app/assets/images/icons/arrow-up.svg | 3 + app/assets/images/icons/plus.svg | 3 + app/assets/images/icons/trash.svg | 4 + app/assets/stylesheets/application.css | 407 ++++++++++++++++++ .../controllers/template_form_controller.js | 239 ++++++++++ .../template_question_controller.js | 15 + app/views/dashboard/gerenciamento.html.erb | 2 +- app/views/layouts/application.html.erb | 3 + app/views/templates/_form.html.erb | 109 ++--- app/views/templates/_option_fields.html.erb | 53 +++ app/views/templates/_question_fields.html.erb | 100 +++++ app/views/templates/edit.html.erb | 6 +- app/views/templates/index.html.erb | 88 +++- app/views/templates/new.html.erb | 6 +- app/views/templates/show.html.erb | 79 +++- 16 files changed, 1034 insertions(+), 86 deletions(-) create mode 100644 app/assets/images/icons/arrow-down.svg create mode 100644 app/assets/images/icons/arrow-up.svg create mode 100644 app/assets/images/icons/plus.svg create mode 100644 app/assets/images/icons/trash.svg create mode 100644 app/javascript/controllers/template_form_controller.js create mode 100644 app/javascript/controllers/template_question_controller.js create mode 100644 app/views/templates/_option_fields.html.erb create mode 100644 app/views/templates/_question_fields.html.erb diff --git a/app/assets/images/icons/arrow-down.svg b/app/assets/images/icons/arrow-down.svg new file mode 100644 index 0000000000..79a2849048 --- /dev/null +++ b/app/assets/images/icons/arrow-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/icons/arrow-up.svg b/app/assets/images/icons/arrow-up.svg new file mode 100644 index 0000000000..a07e7280f5 --- /dev/null +++ b/app/assets/images/icons/arrow-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/icons/plus.svg b/app/assets/images/icons/plus.svg new file mode 100644 index 0000000000..6bf560d56d --- /dev/null +++ b/app/assets/images/icons/plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/icons/trash.svg b/app/assets/images/icons/trash.svg new file mode 100644 index 0000000000..16fe36fb80 --- /dev/null +++ b/app/assets/images/icons/trash.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index fe93333c0f..5d18a59edc 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -8,3 +8,410 @@ * * Consider organizing styles into separate files for maintainability. */ + +body { + color: #222222; + font-family: + "IBM Plex Sans", + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; + margin: 0; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +code, +kbd, +pre, +samp { + font-family: + "Fira Code", + ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; +} + +.template-page { + box-sizing: border-box; + min-height: 100vh; + padding: 32px clamp(16px, 4vw, 56px); + width: 100%; +} + +.template-form { + box-sizing: border-box; + max-width: none; + width: 100%; +} + +.template-form__metadata { + background: #FFFFFF; + border: 1px solid #ECECEC; + border-radius: 10px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03); + margin-bottom: 18px; + padding: 0; +} + +.template-form__metadata-summary { + color: #6A1B60; + cursor: pointer; + font-weight: 700; + list-style: none; + padding: 14px 16px; +} + +.template-form__metadata-summary::-webkit-details-marker { + display: none; +} + +.template-form__metadata-summary::after { + content: "+"; + float: right; + font-weight: 700; +} + +.template-form__metadata[open] .template-form__metadata-summary::after { + content: "-"; +} + +.template-form__metadata-fields { + border-top: 1px solid #F0F0F0; + padding: 16px; +} + +.template-form__field { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 16px; +} + +.template-form__field--compact { + max-width: 240px; +} + +.template-form__field label { + color: #333333; + font-size: 14px; + font-weight: 600; +} + +.template-form input[type="text"], +.template-form textarea, +.template-form select { + border: 1px solid #E5E7EB; + border-radius: 8px; + box-sizing: border-box; + color: #333333; + font: inherit; + padding: 10px 12px; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease; + width: 100%; +} + +.template-form textarea { + min-height: 92px; + resize: vertical; +} + +.template-form input[type="text"]:focus, +.template-form textarea:focus, +.template-form select:focus { + border-color: #6EE7B7; + box-shadow: 0 0 0 3px rgba(110, 231, 183, 0.25); + outline: none; +} + +.template-form__questions { + display: flex; + flex-direction: column; + gap: 16px; + margin-bottom: 16px; +} + +.template-question { + align-items: flex-start; + background: #FFFFFF; + border: 1px solid #ECECEC; + border-radius: 10px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); + display: flex; + gap: 14px; + padding: 16px; +} + +.template-question__content { + flex: 1; + min-width: 0; +} + +.template-question__header { + color: #6A1B60; + font-weight: 700; + margin-bottom: 12px; +} + +.template-question__actions, +.template-option__actions { + display: flex; + flex-shrink: 0; + gap: 8px; +} + +.template-question__actions { + flex-direction: column; +} + +.template-form__icon-button { + align-items: center; + background: #F9FAFB; + border: 1px solid #E5E7EB; + border-radius: 8px; + cursor: pointer; + display: inline-flex; + height: 34px; + justify-content: center; + padding: 0; + transition: + background-color 0.2s ease, + border-color 0.2s ease, + transform 0.2s ease; + width: 34px; +} + +.template-form__icon-button:hover, +.template-form__icon-button:focus { + background: #ECFDF5; + border-color: #6EE7B7; + outline: none; +} + +.template-form__icon-button:active { + transform: translateY(1px); +} + +.template-form__icon-button--danger:hover, +.template-form__icon-button--danger:focus { + background: #FEF2F2; + border-color: #FCA5A5; +} + +.template-form__add-button { + margin-bottom: 16px; +} + +.template-form__add-question-button { + background: #ECFDF5; + border-color: #6EE7B7; + border-radius: 18px; + box-shadow: 0 8px 18px rgba(16, 185, 129, 0.18); + display: flex; + height: 58px; + margin: 8px auto 24px; + width: 58px; +} + +.template-form__add-question-button:hover, +.template-form__add-question-button:focus { + background: #D1FAE5; + border-color: #10B981; + box-shadow: 0 10px 22px rgba(16, 185, 129, 0.24); +} + +.template-form__add-template-button { + aspect-ratio: 1; + background: #ECFDF5; + border: 2px solid #6EE7B7; + border-radius: 12px; + box-shadow: 0 2px 8px rgba(16, 185, 129, 0.16); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + min-width: min(calc(33.333% - 14px), 280px); + text-decoration: none; + transition: + background-color 0.2s ease, + border-color 0.2s ease, + box-shadow 0.2s ease, + transform 0.2s ease; +} + +.template-form__add-template-button:hover, +.template-form__add-template-button:focus { + background: #D1FAE5; + border-color: #10B981; + box-shadow: 0 4px 16px rgba(16, 185, 129, 0.24); + outline: none; + transform: translateY(-2px); +} + +.template-form__submit { + background: #6A1B60; + border: 1px solid #6A1B60; + border-radius: 10px; + color: #FFFFFF; + cursor: pointer; + font: inherit; + font-weight: 700; + letter-spacing: 0.01em; + padding: 12px 22px; + transition: + background-color 0.2s ease, + box-shadow 0.2s ease, + transform 0.2s ease; +} + +.template-form__submit:hover, +.template-form__submit:focus { + background: #58134F; + box-shadow: 0 8px 18px rgba(106, 27, 96, 0.22); + outline: none; +} + +.template-form__submit:active { + transform: translateY(1px); +} + +button.template-form__submit { + appearance: none; + display: inline-block; + text-decoration: none; +} + +.template-form__actions { + display: flex; + gap: 12px; + justify-content: flex-end; + margin-top: 16px; +} + +.template-options { + border: 1px solid #F0F0F0; + border-radius: 8px; + margin: 12px 0; + padding: 12px; +} + +.template-options legend { + color: #555555; + font-size: 14px; + font-weight: 700; + padding: 0 6px; +} + +.template-options__list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.template-option { + align-items: center; + background: #FAFAFA; + border: 1px solid #F0F0F0; + border-radius: 8px; + display: flex; + gap: 12px; + padding: 10px; +} + +.template-option__content { + flex: 1; + min-width: 0; +} + +.template-option__label { + color: #555555; + display: inline-block; + font-size: 13px; + font-weight: 700; + margin-bottom: 8px; +} + +.template-form__cards { + display: flex; + flex-wrap: wrap; + gap: 20px; + margin: 24px 0; + max-width: 100vw; +} + +.template-form__card { + aspect-ratio: 1; + background: #FFFFFF; + border: 1px solid #ECECEC; + border-radius: 12px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); + box-sizing: border-box; + cursor: pointer; + display: flex; + flex: 1; + flex-direction: column; + justify-content: space-between; + min-width: min(calc(33.333% - 14px), 280px); + max-width: 20vw; + padding: 20px; + text-decoration: none; + transition: + background-color 0.2s ease, + border-color 0.2s ease, + box-shadow 0.2s ease, + transform 0.2s ease; +} + +.template-form__card:hover, +.template-form__card:focus { + background: #FAFAFA; + border-color: #6A1B60; + box-shadow: 0 4px 16px rgba(106, 27, 96, 0.12); + outline: none; + transform: translateY(-2px); +} + +.template-form__card-title { + color: #222222; + font-size: 16px; + font-weight: 700; + margin: 0 0 8px 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.template-form__card-description { + color: #666666; + font-size: 13px; + line-height: 1.4; + margin: 0 0 16px 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: normal; +} + +.template-form__card-info { + display: flex; + flex-direction: column; + gap: 8px; +} + +.template-form__card-info-item { + align-items: center; + color: #555555; + display: flex; + font-size: 12px; + gap: 6px; +} + +.template-form__card-info-item strong { + color: #6A1B60; + font-weight: 700; +} \ No newline at end of file diff --git a/app/javascript/controllers/template_form_controller.js b/app/javascript/controllers/template_form_controller.js new file mode 100644 index 0000000000..0e6425538c --- /dev/null +++ b/app/javascript/controllers/template_form_controller.js @@ -0,0 +1,239 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["questions", "questionTemplate", "optionTemplate"] + + connect() { + this.renumberAll() + } + + addQuestion(event) { + event.preventDefault() + + const questionIndex = this.uniqueIndex() + const fragment = this.htmlToFragment( + this.questionTemplateTarget.innerHTML.replaceAll( + "NEW_QUESTION", + questionIndex, + ), + ) + const question = fragment.querySelector("[data-template-form-question]") + + question.dataset.templateFormQuestionIndex = questionIndex + this.questionsTarget.append(fragment) + this.renumberAll() + } + + addOption(event) { + event.preventDefault() + + const question = event.target.closest("[data-template-form-question]") + if (!question) return + + const optionIndex = this.uniqueIndex() + const questionIndex = question.dataset.templateFormQuestionIndex + const fragment = this.htmlToFragment( + this.optionTemplateTarget.innerHTML + .replaceAll("NEW_QUESTION", questionIndex) + .replaceAll("NEW_OPTION", optionIndex), + ) + const option = fragment.querySelector("[data-template-form-option]") + + question.querySelector("[data-template-form-options]").append(fragment) + this.renumberOptions(question) + } + + moveQuestionUp(event) { + event.preventDefault() + + const question = event.target.closest("[data-template-form-question]") + if (!question) return + + this.moveElementUp(question) + this.renumberAll() + } + + moveQuestionDown(event) { + event.preventDefault() + + const question = event.target.closest("[data-template-form-question]") + if (!question) return + + this.moveElementDown(question) + this.renumberAll() + } + + moveOptionUp(event) { + event.preventDefault() + + const option = event.target.closest("[data-template-form-option]") + const question = event.target.closest("[data-template-form-question]") + if (!option || !question) return + + this.moveElementUp(option) + this.renumberOptions(question) + } + + moveOptionDown(event) { + event.preventDefault() + + const option = event.target.closest("[data-template-form-option]") + const question = event.target.closest("[data-template-form-question]") + if (!option || !question) return + + this.moveElementDown(option) + this.renumberOptions(question) + } + + destroyQuestion(event) { + event.preventDefault() + + const question = event.target.closest("[data-template-form-question]") + if (!question) return + + const idField = question.querySelector("[data-template-form-utilizacao-id]") + const destroyField = question.querySelector("[data-template-form-question-destroy]") + + if (!idField?.value) { + question.remove() + this.renumberAll() + return + } + + this.submitDestroy([ + [idField.name, idField.value], + [destroyField.name, "1"], + ]) + } + + destroyOption(event) { + event.preventDefault() + + const option = event.target.closest("[data-template-form-option]") + const question = event.target.closest("[data-template-form-question]") + if (!option || !question) return + + const utilizationIdField = question.querySelector( + "[data-template-form-utilizacao-id]", + ) + const questionIdField = question.querySelector("[data-template-form-questao-id]") + const optionIdField = option.querySelector("[data-template-form-opcao-id]") + const destroyField = option.querySelector("[data-template-form-option-destroy]") + + if (!optionIdField?.value) { + option.remove() + this.renumberOptions(question) + return + } + + this.submitDestroy([ + [utilizationIdField.name, utilizationIdField.value], + [questionIdField.name, questionIdField.value], + [optionIdField.name, optionIdField.value], + [destroyField.name, "1"], + ]) + } + + uniqueIndex() { + if (!this.index) this.index = Date.now() + + this.index += 1 + return this.index.toString() + } + + htmlToFragment(html) { + const template = document.createElement("template") + template.innerHTML = html.trim() + + return template.content + } + + moveElementUp(element) { + const previous = element.previousElementSibling + if (!previous) return + + previous.before(element) + } + + moveElementDown(element) { + const next = element.nextElementSibling + if (!next) return + + next.after(element) + } + + renumberAll() { + this.questionElements.forEach((question, index) => { + const number = index + 1 + + question.querySelector("[data-template-form-question-number]").value = number + question.querySelector("[data-template-form-question-position]").textContent = + number + this.renumberOptions(question) + }) + } + + renumberOptions(question) { + this.optionElements(question).forEach((option, index) => { + const number = index + 1 + + option.querySelector("[data-template-form-option-number]").value = number + option.querySelector("[data-template-form-option-position]").textContent = + number + }) + } + + get questionElements() { + return [...this.questionsTarget.children].filter((element) => + element.matches("[data-template-form-question]"), + ) + } + + optionElements(question) { + const options = question.querySelector("[data-template-form-options]") + if (!options) return [] + + return [...options.children].filter((element) => + element.matches("[data-template-form-option]"), + ) + } + + submitDestroy(fields) { + const form = document.createElement("form") + form.hidden = true + form.method = "post" + form.action = this.element.action + + this.appendMethodOverride(form) + this.appendAuthenticityToken(form) + fields.forEach(([name, value]) => this.appendHiddenField(form, name, value)) + + document.body.append(form) + form.requestSubmit() + } + + appendMethodOverride(form) { + const methodOverride = this.element.querySelector("input[name='_method']") + if (!methodOverride) return + + this.appendHiddenField(form, methodOverride.name, methodOverride.value) + } + + appendAuthenticityToken(form) { + const token = + this.element.querySelector("input[name='authenticity_token']")?.value || + document.querySelector("meta[name='csrf-token']")?.content + if (!token) return + + this.appendHiddenField(form, "authenticity_token", token) + } + + appendHiddenField(form, name, value) { + const input = document.createElement("input") + input.type = "hidden" + input.name = name + input.value = value + + form.append(input) + } +} diff --git a/app/javascript/controllers/template_question_controller.js b/app/javascript/controllers/template_question_controller.js new file mode 100644 index 0000000000..53d3cf3871 --- /dev/null +++ b/app/javascript/controllers/template_question_controller.js @@ -0,0 +1,15 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["tipo", "opcoes"] + + connect() { + this.toggleOptions() + } + + toggleOptions() { + if (!this.hasTipoTarget || !this.hasOpcoesTarget) return + + this.opcoesTarget.hidden = this.tipoTarget.value !== "objetiva" + } +} diff --git a/app/views/dashboard/gerenciamento.html.erb b/app/views/dashboard/gerenciamento.html.erb index 5a4616b434..8f31f9b8b2 100644 --- a/app/views/dashboard/gerenciamento.html.erb +++ b/app/views/dashboard/gerenciamento.html.erb @@ -54,7 +54,7 @@ <%= link_to "Importar dados", importar_dados_path, data: { turbo_method: :post }, class: "btn-action btn-primary" %> <%= link_to "Enviar Solicitação de Cadastro (E-mail)", enviar_solicitacoes_path, data: { turbo_method: :post }, class: "btn-action btn-secondary" %> - <%= link_to "Editar Templates", templates_path, class: "btn-action btn-secondary" %> + <%= link_to "Gerenciar Templates", templates_path, class: "btn-action btn-secondary" %> <%= link_to "Resultados", "#", class: "btn-action btn-secondary" %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 9e51e3817f..3c3b4b8c7f 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -17,6 +17,9 @@ + + + <%# Includes all stylesheet files in app/assets/stylesheets %> <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> diff --git a/app/views/templates/_form.html.erb b/app/views/templates/_form.html.erb index e54a2d2f46..70741781b3 100644 --- a/app/views/templates/_form.html.erb +++ b/app/views/templates/_form.html.erb @@ -1,73 +1,86 @@ -<%= form_with model: @template do |f| %> - <% if @template.errors.any? %> +<% edicao = template.persisted? %> + +<%= form_with( + model: template, + class: "template-form", + data: { controller: "template-form", action: "submit->template-form#renumberAll" } + ) do |f| %> + <% if template.errors.any? %>

    Erros:

      - <% @template.errors.full_messages.each do |msg| %> + <% template.errors.full_messages.each do |msg| %>
    • <%= msg %>
    • <% end %>
    <% end %> -
    - <%= f.label :titulo %> - <%= f.text_field :titulo %> -
    +

    Questões

    - <%= f.fields_for :utilizacao_questoes do |utilizacao_form| %> -
    - <%= utilizacao_form.hidden_field :parent_id %> +
    + <% utilizacoes_ordenadas = template.utilizacoes_questoes.sort_by { |utilizacao| [utilizacao.numero || 0, utilizacao.id || 0] } %> -
    - <%= utilizacao_form.label :numero, "Ordem" %> - <%= utilizacao_form.number_field :numero, min: 1 %> -
    + <%= f.fields_for :utilizacoes_questoes, utilizacoes_ordenadas do |utilizacao_form| %> + <%= render "question_fields", utilizacao_form: utilizacao_form, edicao: edicao %> + <% end %> +
    - <%= utilizacao_form.fields_for :questao do |questao_form| %> -
    - <%= questao_form.label :enunciado %> - <%= questao_form.text_area :enunciado %> -
    + <%= button_tag( + type: "button", + class: "template-form__icon-button template-form__add-button template-form__add-question-button", + data: { action: "template-form#addQuestion" }, + title: "Adicionar questão", + aria: { label: "Adicionar questão" } + ) do %> + <%= image_tag "icons/plus.svg", alt: "", width: 24, height: 24 %> + <% end %> -
    - <%= questao_form.label :tipo %> - <%= questao_form.select( - :tipo, - [["Discursiva", "discursiva"], ["Objetiva", "objetiva"]] - ) %> -
    + <% nova_utilizacao = UtilizacaoQuestao.new %> + <% nova_utilizacao.build_questao(tipo: :discursiva) %> -

    Opções

    + - <%= questao_form.fields_for :opcoes do |opcao_form| %> -
    - <%= opcao_form.label :texto %> - <%= opcao_form.text_field :texto %> + <% nova_opcao = Opcao.new %> - <%= opcao_form.label :numero %> - <%= opcao_form.number_field :numero, min: 1 %> -
    + -
    - <%= utilizacao_form.check_box :_destroy %> - <%= utilizacao_form.label :_destroy, "Remover questão" %> -
    -
    - <% end %> - -
    - - <%= f.submit %> +
    + <%= f.submit( + template.persisted? ? "Atualizar template" : "Criar template", + class: "template-form__submit" + ) %> +
    <% end %> diff --git a/app/views/templates/_option_fields.html.erb b/app/views/templates/_option_fields.html.erb new file mode 100644 index 0000000000..e1e3010fcc --- /dev/null +++ b/app/views/templates/_option_fields.html.erb @@ -0,0 +1,53 @@ +<% opcao_persistida = edicao && opcao_form.object.persisted? %> + +
    + <% if opcao_persistida %> + <%= opcao_form.hidden_field :id, data: { template_form_opcao_id: true } %> + <%= opcao_form.hidden_field :_destroy, value: "0", data: { template_form_option_destroy: true } %> + <% end %> + + <%= opcao_form.hidden_field :numero, data: { template_form_option_number: true } %> + +
    + + Opção + + +
    + <%= opcao_form.label :texto %> + <%= opcao_form.text_field :texto %> +
    +
    + +
    + <%= button_tag( + type: "button", + class: "template-form__icon-button", + data: { action: "template-form#moveOptionUp" }, + title: "Mover opção para cima", + aria: { label: "Mover opção para cima" } + ) do %> + <%= image_tag "icons/arrow-up.svg", alt: "", width: 16, height: 16 %> + <% end %> + + <%= button_tag( + type: "button", + class: "template-form__icon-button", + data: { action: "template-form#moveOptionDown" }, + title: "Mover opção para baixo", + aria: { label: "Mover opção para baixo" } + ) do %> + <%= image_tag "icons/arrow-down.svg", alt: "", width: 16, height: 16 %> + <% end %> + + <%= button_tag( + type: "button", + class: "template-form__icon-button template-form__icon-button--danger", + data: { action: "template-form#destroyOption" }, + title: "Remover opção", + aria: { label: "Remover opção" } + ) do %> + <%= image_tag "icons/trash.svg", alt: "", width: 16, height: 16 %> + <% end %> +
    +
    diff --git a/app/views/templates/_question_fields.html.erb b/app/views/templates/_question_fields.html.erb new file mode 100644 index 0000000000..f42db30aeb --- /dev/null +++ b/app/views/templates/_question_fields.html.erb @@ -0,0 +1,100 @@ +<% utilizacao_persistida = edicao && utilizacao_form.object.persisted? %> + +
    + <% if utilizacao_persistida %> + <%= utilizacao_form.hidden_field :id, data: { template_form_utilizacao_id: true } %> + <%= utilizacao_form.hidden_field :_destroy, value: "0", data: { template_form_question_destroy: true } %> + <% end %> + + <%= utilizacao_form.hidden_field :parent_id %> + <%= utilizacao_form.hidden_field :numero, data: { template_form_question_number: true } %> + +
    +
    + Questão +
    + + <%= utilizacao_form.fields_for :questao do |questao_form| %> + <% questao_persistida = edicao && questao_form.object.persisted? %> + + <% if questao_persistida %> + <%= questao_form.hidden_field :id, data: { template_form_questao_id: true } %> + <% end %> + +
    + <%= questao_form.label :tipo %> + <%= questao_form.select( + :tipo, + [["Discursiva", "discursiva"], ["Objetiva", "objetiva"]], + {}, + data: { + action: "template-question#toggleOptions", + template_question_target: "tipo" + } + ) %> +
    + +
    + <%= questao_form.label :enunciado %> + <%= questao_form.text_area :enunciado, rows: 3 %> +
    + + <% opcoes_visiveis = questao_form.object.objetiva? || questao_form.object.opcoes.any? %> +
    > +
    + Opções + +
    + <% opcoes_ordenadas = questao_form.object.opcoes.sort_by { |opcao| [opcao.numero || 0, opcao.id || 0] } %> + + <%= questao_form.fields_for :opcoes, opcoes_ordenadas do |opcao_form| %> + <%= render "option_fields", opcao_form: opcao_form, edicao: edicao %> + <% end %> +
    +
    + + <%= button_tag( + type: "button", + class: "template-form__icon-button template-form__add-button", + data: { action: "template-form#addOption" }, + title: "Adicionar opção", + aria: { label: "Adicionar opção" } + ) do %> + <%= image_tag "icons/plus.svg", alt: "", width: 16, height: 16 %> + <% end %> +
    + <% end %> +
    + +
    + <%= button_tag( + type: "button", + class: "template-form__icon-button", + data: { action: "template-form#moveQuestionUp" }, + title: "Mover questão para cima", + aria: { label: "Mover questão para cima" } + ) do %> + <%= image_tag "icons/arrow-up.svg", alt: "", width: 16, height: 16 %> + <% end %> + + <%= button_tag( + type: "button", + class: "template-form__icon-button", + data: { action: "template-form#moveQuestionDown" }, + title: "Mover questão para baixo", + aria: { label: "Mover questão para baixo" } + ) do %> + <%= image_tag "icons/arrow-down.svg", alt: "", width: 16, height: 16 %> + <% end %> + + <%= button_tag( + type: "button", + class: "template-form__icon-button template-form__icon-button--danger", + data: { action: "template-form#destroyQuestion" }, + title: "Remover questão", + aria: { label: "Remover questão" } + ) do %> + <%= image_tag "icons/trash.svg", alt: "", width: 16, height: 16 %> + <% end %> +
    +
    diff --git a/app/views/templates/edit.html.erb b/app/views/templates/edit.html.erb index 079aaad5f0..06712c66c9 100644 --- a/app/views/templates/edit.html.erb +++ b/app/views/templates/edit.html.erb @@ -1,3 +1,5 @@ -

    Editar Template

    +
    +

    Editar Template

    -<%= render "form", template: @template %> + <%= render "form", template: @template %> +
    diff --git a/app/views/templates/index.html.erb b/app/views/templates/index.html.erb index 145d7fee5e..1b26d07de9 100644 --- a/app/views/templates/index.html.erb +++ b/app/views/templates/index.html.erb @@ -1,24 +1,76 @@ -

    Templates

    +
    +

    Templates

    -
    -

    Meus templates

    +
    +

    Meus templates

    - <% if @user_templates.any? %> - <% @user_templates.each do |t| %> -

    - <%= link_to t.titulo, template_path(t) %> -

    + <% if @user_templates.any? %> +
    + <% @user_templates.each do |t| %> + <%= link_to template_path(t), class: "template-form__card" do %> +
    +

    <%= t.titulo %>

    +

    <%= truncate(t.descricao, length: 80) %>

    +
    +
    +
    + 📋 + <%= t.questoes.count %> + <%= t.questoes.count == 1 ? "questão" : "questões" %> +
    +
    + 📝 + <%= t.formularios.count %> + <%= t.formularios.count == 1 ? "formulário" : "formulários" %> +
    +
    + <% end %> + <% end %> + <%= link_to new_template_path, class: "template-form__add-template-button", title: "Criar novo template", aria: { label: "Criar novo template" } do %> + <%= image_tag "icons/plus.svg", alt: "", width: 24, height: 24 %> + <% end %> +
    + <% else %> +
    +

    Nenhum template foi encontrado

    + <%= link_to new_template_path, class: "template-form__add-template-button", title: "Criar novo template", aria: { label: "Criar novo template" } do %> + <%= image_tag "icons/plus.svg", alt: "", width: 24, height: 24 %> + <% end %> +
    <% end %> +
    + +
    + +

    Outros Templates

    + <% if @other_templates.any? %> +
    + <% @other_templates.each do |t| %> + <%= link_to template_path(t), class: "template-form__card" do %> +
    +

    <%= t.titulo %>

    +

    <%= truncate(t.descricao, length: 80) %>

    +
    +
    +
    + 📋 + <%= t.questoes.count %> + <%= t.questoes.count == 1 ? "questão" : "questões" %> +
    +
    + 📝 + <%= t.formularios.count %> + <%= t.formularios.count == 1 ? "formulário" : "formulários" %> +
    +
    + <% end %> + <% end %> +
    <% else %> -

    Nenhum template foi encontrado

    +

    Nenhum template de outros administradores encontrado

    <% end %> -
    - -
    -

    Templates de outros administradores

    -<% @other_templates.each do |t| %> -

    - <%= link_to t.titulo, template_path(t) %> -

    -<% end %> +
    + <%= button_tag "Voltar", onclick: "window.history.back();", class: "template-form__submit", type: :button %> +
    +
    \ No newline at end of file diff --git a/app/views/templates/new.html.erb b/app/views/templates/new.html.erb index e2a233c3ef..55d175b6ac 100644 --- a/app/views/templates/new.html.erb +++ b/app/views/templates/new.html.erb @@ -1,3 +1,5 @@ -

    Novo Template

    +
    +

    Novo Template

    -<%= render "form", template: @template %> + <%= render "form", template: @template %> +
    diff --git a/app/views/templates/show.html.erb b/app/views/templates/show.html.erb index d90a2b2fa8..747e9fa4db 100644 --- a/app/views/templates/show.html.erb +++ b/app/views/templates/show.html.erb @@ -1,22 +1,71 @@ -

    <%= @template.titulo %>

    +
    +

    <%= @template.titulo %>

    -

    <%= @template.descricao %>

    + - <% if q.opcoes.any? %> -
      - <% q.opcoes.each do |o| %> -
    • <%= o.numero %> - <%= o.texto %>
    • - <% end %> -
    +
    + +

    Questões

    + +
    + <% @template.questoes_ordenadas.each do |utilizacao| %> +
    +
    +
    + Questão <%= utilizacao.numero %> +
    + +
    + +

    <%= utilizacao.questao.discursiva? ? "Discursiva" : "Objetiva" %>

    +
    + +
    + +

    <%= simple_format(utilizacao.questao.enunciado) %>

    +
    + + <% if utilizacao.questao.objetiva? && utilizacao.questao.opcoes.any? %> +
    + Opções + +
    + <% utilizacao.questao.opcoes.ordenadas.each do |opcao| %> +
    +
    + + Opção <%= opcao.numero %> + +

    <%= opcao.texto %>

    +
    +
    + <% end %> +
    +
    + <% end %> +
    +
    <% end %>
    -<% end %> -<%= link_to "Voltar", templates_path %> +
    + <%= button_tag "Editar", onclick: "window.location.href='#{edit_template_path(@template)}'", class: "template-form__submit", type: :button %> + <%= button_tag "Voltar", onclick: "window.history.back();", class: "template-form__submit", type: :button %> +
    +
    From be61afac06624ffdc616a1c98c5be91189dcd236 Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Fri, 19 Jun 2026 20:41:14 -0300 Subject: [PATCH 085/125] fix(templates): corrige `utilizacao_questoes` para `utilizacoes_questoes` --- app/services/formularios/create_from_template.rb | 4 ++-- spec/support/test_builders.rb | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/services/formularios/create_from_template.rb b/app/services/formularios/create_from_template.rb index 65270d60a2..011ec17dd7 100644 --- a/app/services/formularios/create_from_template.rb +++ b/app/services/formularios/create_from_template.rb @@ -61,7 +61,7 @@ def validate_turma_ids! end def validate_template! - raise Error, SEM_QUESTOES if template.utilizacao_questoes.raizes.none? + raise Error, SEM_QUESTOES if template.utilizacoes_questoes.raizes.none? end def validate_turmas! @@ -86,7 +86,7 @@ def turmas end def copy_questoes_from_template!(formulario) - template.utilizacao_questoes.raizes.ordenadas.each do |utilizacao| + template.utilizacoes_questoes.raizes.ordenadas.each do |utilizacao| questao_origem = utilizacao.questao questao = formulario.questoes.build( diff --git a/spec/support/test_builders.rb b/spec/support/test_builders.rb index 571e310ed2..2bff14ea8e 100644 --- a/spec/support/test_builders.rb +++ b/spec/support/test_builders.rb @@ -46,11 +46,11 @@ def create_template_with_questoes(titulo:, adm: nil, questoes: nil) questao.save! - template.utilizacao_questoes.build(questao: questao, numero: numero) + template.utilizacoes_questoes.build(questao: questao, numero: numero) end template.save! - template.utilizacao_questoes.each(&:save!) + template.utilizacoes_questoes.each(&:save!) template end @@ -71,7 +71,7 @@ def create_turma(nome_materia:, numero:, semestre: Turma.semestre_atual, departa end def questoes_ordenadas_do_template(template) - template.utilizacao_questoes.raizes.ordenadas.includes(:questao).map(&:questao) + template.utilizacoes_questoes.raizes.ordenadas.includes(:questao).map(&:questao) end def create_perfil_docente(usuario, departamento: nil, **attrs) From a091d43715773d27013282c741f13cda1a4da956 Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Fri, 19 Jun 2026 20:41:48 -0300 Subject: [PATCH 086/125] =?UTF-8?q?test(templates):=20adiciona=20e=20corri?= =?UTF-8?q?ge=20testes=20tendo=20em=20vista=20novas=20funcionalidades=20e?= =?UTF-8?q?=20corre=C3=A7=C3=B5es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- spec/requests/templates_spec.rb | 207 ++++++++++++++++++++++++++++++++ 1 file changed, 207 insertions(+) diff --git a/spec/requests/templates_spec.rb b/spec/requests/templates_spec.rb index 268014f34e..c67be35810 100644 --- a/spec/requests/templates_spec.rb +++ b/spec/requests/templates_spec.rb @@ -80,6 +80,49 @@ expect(response).to have_http_status(:ok) end + + it "prepara uma questão discursiva inicial sem opções" do + new_template = Template.new(adm_id: 1) + + allow(new_template).to receive(:questoes).and_return([]) + allow(Template) + .to receive(:new) + .with(adm: current_administrador) + .and_return(new_template) + + get new_template_path + + questao = new_template.utilizacoes_questoes.first.questao + expect(questao).to be_discursiva + expect(questao.opcoes).to be_empty + end + + it "não renderiza campos de exclusão persistida na criação" do + new_template = Template.new(adm_id: 1) + + allow(new_template).to receive(:questoes).and_return([]) + allow(Template) + .to receive(:new) + .with(adm: current_administrador) + .and_return(new_template) + + get new_template_path + + expect(response.body).to include("template-form#destroyQuestion") + expect(response.body).to include("template-form#destroyOption") + expect(response.body).not_to include( + "template[utilizacoes_questoes_attributes][0][id]" + ) + expect(response.body).not_to include( + "template[utilizacoes_questoes_attributes][0][_destroy]" + ) + expect(response.body).not_to include( + "template[utilizacoes_questoes_attributes][NEW_QUESTION][id]" + ) + expect(response.body).not_to include( + "template[utilizacoes_questoes_attributes][NEW_QUESTION][_destroy]" + ) + end end describe "GET /templates/:id" do @@ -118,6 +161,53 @@ expect(new_template).to have_received(:save) expect(response).to redirect_to(template_path(new_template)) end + + it "persiste questões adicionadas no frontend no submit final" do + administrador = create_admin_usuario.perfil_adm + + allow_any_instance_of(ApplicationController) + .to receive(:current_administrador) + .and_return(administrador) + + expect do + post templates_path, params: { + template: { + titulo: "Avaliação com campos dinâmicos", + descricao: "Descrição", + utilizacoes_questoes_attributes: { + "0" => { + numero: "1", + questao_attributes: { + enunciado: "Descreva os pontos positivos", + tipo: "discursiva" + } + }, + "1" => { + numero: "2", + questao_attributes: { + enunciado: "Como você avalia a disciplina?", + tipo: "objetiva", + opcoes_attributes: { + "0" => { + numero: "1", + texto: "Boa" + }, + "1" => { + numero: "2", + texto: "Excelente" + } + } + } + } + } + } + } + end.to change(Template, :count).by(1) + .and change(UtilizacaoQuestao, :count).by(2) + .and change(Opcao, :count).by(2) + + expect(response).to redirect_to(template_path(Template.last)) + end end describe "GET /templates/:id/edit" do @@ -128,6 +218,70 @@ expect(response).to have_http_status(:ok) end + + it "renderiza botões para remover questões e opções via update do template" do + template_com_questao_objetiva = create_template_with_questoes( + titulo: "Avaliação objetiva", + questoes: [ + { + enunciado: "Como você avalia a disciplina?", + tipo: :objetiva, + opcoes: %w[Ruim Regular Bom] + } + ] + ) + + allow(Template) + .to receive(:find) + .with(template_com_questao_objetiva.id.to_s) + .and_return(template_com_questao_objetiva) + + get edit_template_path(template_com_questao_objetiva) + + expect(response.body).to include("/assets/icons/trash-") + expect(response.body).to include( + "template[utilizacoes_questoes_attributes][0][id]" + ) + expect(response.body).to include( + "template[utilizacoes_questoes_attributes][0][_destroy]" + ) + expect(response.body).to include( + "template[utilizacoes_questoes_attributes][0]" \ + "[questao_attributes][opcoes_attributes][0][id]" + ) + expect(response.body).to include( + "template[utilizacoes_questoes_attributes][0]" \ + "[questao_attributes][opcoes_attributes][0][_destroy]" + ) + expect(response.body).to include("template-form#destroyQuestion") + expect(response.body).to include("template-form#destroyOption") + expect(response.body).not_to include("checkbox") + expect(response.body).not_to include("Remover") + end + + it "renderiza botões frontend para adicionar questão e opção" do + allow(Template).to receive(:find).with("1").and_return(template) + + get edit_template_path(template) + + expect(response.body).to include("data-controller=\"template-form\"") + expect(response.body).to include("template-form#addQuestion") + expect(response.body).to include("template-form#addOption") + expect(response.body).to include("template-form#moveQuestionUp") + expect(response.body).to include("template-form#moveQuestionDown") + expect(response.body).to include("template-form#moveOptionUp") + expect(response.body).to include("template-form#moveOptionDown") + expect(response.body).to include("data-template-form-question-number") + expect(response.body).to include("data-template-form-option-number") + expect(response.body).not_to include("type=\"number\"") + expect(response.body).not_to include("name=\"adicionar_questao\"") + expect(response.body).not_to include("name=\"adicionar_opcao\"") + expect(response.body).to include("/assets/icons/plus-") + expect(response.body).to include("/assets/icons/arrow-up-") + expect(response.body).to include("/assets/icons/arrow-down-") + expect(response.body).not_to include("Adicionar") + expect(response.body).not_to include("Mover") + end end describe "PATCH /templates/:id" do @@ -143,6 +297,59 @@ .with(ActionController::Parameters.new(titulo: "Novo título").permit!) expect(response).to redirect_to(template_path(template)) end + + it "persiste opções adicionadas no frontend no submit final" do + template_com_questao_objetiva = create_template_with_questoes( + titulo: "Avaliação objetiva", + questoes: [ + { + enunciado: "Como você avalia a disciplina?", + tipo: :objetiva, + opcoes: %w[Ruim Regular Bom] + } + ] + ) + utilizacao = template_com_questao_objetiva.utilizacoes_questoes.first + questao = utilizacao.questao + + expect do + patch template_path(template_com_questao_objetiva), params: { + template: { + titulo: template_com_questao_objetiva.titulo, + descricao: template_com_questao_objetiva.descricao, + utilizacoes_questoes_attributes: { + "0" => { + id: utilizacao.id, + numero: utilizacao.numero, + questao_attributes: { + id: questao.id, + enunciado: questao.enunciado, + tipo: "objetiva", + opcoes_attributes: questao.opcoes.each_with_index.to_h do |opcao, index| + [ + index.to_s, + { + id: opcao.id, + numero: opcao.numero, + texto: opcao.texto + } + ] + end.merge( + "3" => { + numero: "4", + texto: "Excelente" + } + ) + } + } + } + } + } + end.to change(Opcao, :count).by(1) + + expect(response).to redirect_to(template_path(template_com_questao_objetiva)) + expect(questao.opcoes.reload.pluck(:texto)).to include("Excelente") + end end describe "DELETE /templates/:id" do From e7b0d8c81485bd146b28b9107d4bab826bfd82c9 Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Sat, 20 Jun 2026 20:30:30 -0300 Subject: [PATCH 087/125] style(templates): Estilo das telas de template alterados e melhoradas --- app/assets/stylesheets/application.css | 408 ++---------------- app/assets/stylesheets/templates.css | 563 +++++++++++++++++++++++++ 2 files changed, 590 insertions(+), 381 deletions(-) create mode 100644 app/assets/stylesheets/templates.css diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 5d18a59edc..3fd063ac7e 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -9,8 +9,27 @@ * Consider organizing styles into separate files for maintainability. */ +:root { + --camaar-accent: #10B981; + --camaar-accent-dark: #047857; + --camaar-accent-soft: #ECFDF5; + --camaar-brand: #6A1B60; + --camaar-brand-dark: #4C1246; + --camaar-brand-soft: #F7EDF6; + --camaar-border: #E7DEE8; + --camaar-ink: #251F27; + --camaar-muted: #6F6672; + --camaar-surface: #FFFFFF; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + body { - color: #222222; + color: var(--camaar-ink); font-family: "IBM Plex Sans", system-ui, @@ -23,6 +42,13 @@ body { text-rendering: optimizeLegibility; } +button, +input, +select, +textarea { + font: inherit; +} + code, kbd, pre, @@ -35,383 +61,3 @@ samp { Consolas, monospace; } - -.template-page { - box-sizing: border-box; - min-height: 100vh; - padding: 32px clamp(16px, 4vw, 56px); - width: 100%; -} - -.template-form { - box-sizing: border-box; - max-width: none; - width: 100%; -} - -.template-form__metadata { - background: #FFFFFF; - border: 1px solid #ECECEC; - border-radius: 10px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03); - margin-bottom: 18px; - padding: 0; -} - -.template-form__metadata-summary { - color: #6A1B60; - cursor: pointer; - font-weight: 700; - list-style: none; - padding: 14px 16px; -} - -.template-form__metadata-summary::-webkit-details-marker { - display: none; -} - -.template-form__metadata-summary::after { - content: "+"; - float: right; - font-weight: 700; -} - -.template-form__metadata[open] .template-form__metadata-summary::after { - content: "-"; -} - -.template-form__metadata-fields { - border-top: 1px solid #F0F0F0; - padding: 16px; -} - -.template-form__field { - display: flex; - flex-direction: column; - gap: 6px; - margin-bottom: 16px; -} - -.template-form__field--compact { - max-width: 240px; -} - -.template-form__field label { - color: #333333; - font-size: 14px; - font-weight: 600; -} - -.template-form input[type="text"], -.template-form textarea, -.template-form select { - border: 1px solid #E5E7EB; - border-radius: 8px; - box-sizing: border-box; - color: #333333; - font: inherit; - padding: 10px 12px; - transition: - border-color 0.2s ease, - box-shadow 0.2s ease; - width: 100%; -} - -.template-form textarea { - min-height: 92px; - resize: vertical; -} - -.template-form input[type="text"]:focus, -.template-form textarea:focus, -.template-form select:focus { - border-color: #6EE7B7; - box-shadow: 0 0 0 3px rgba(110, 231, 183, 0.25); - outline: none; -} - -.template-form__questions { - display: flex; - flex-direction: column; - gap: 16px; - margin-bottom: 16px; -} - -.template-question { - align-items: flex-start; - background: #FFFFFF; - border: 1px solid #ECECEC; - border-radius: 10px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); - display: flex; - gap: 14px; - padding: 16px; -} - -.template-question__content { - flex: 1; - min-width: 0; -} - -.template-question__header { - color: #6A1B60; - font-weight: 700; - margin-bottom: 12px; -} - -.template-question__actions, -.template-option__actions { - display: flex; - flex-shrink: 0; - gap: 8px; -} - -.template-question__actions { - flex-direction: column; -} - -.template-form__icon-button { - align-items: center; - background: #F9FAFB; - border: 1px solid #E5E7EB; - border-radius: 8px; - cursor: pointer; - display: inline-flex; - height: 34px; - justify-content: center; - padding: 0; - transition: - background-color 0.2s ease, - border-color 0.2s ease, - transform 0.2s ease; - width: 34px; -} - -.template-form__icon-button:hover, -.template-form__icon-button:focus { - background: #ECFDF5; - border-color: #6EE7B7; - outline: none; -} - -.template-form__icon-button:active { - transform: translateY(1px); -} - -.template-form__icon-button--danger:hover, -.template-form__icon-button--danger:focus { - background: #FEF2F2; - border-color: #FCA5A5; -} - -.template-form__add-button { - margin-bottom: 16px; -} - -.template-form__add-question-button { - background: #ECFDF5; - border-color: #6EE7B7; - border-radius: 18px; - box-shadow: 0 8px 18px rgba(16, 185, 129, 0.18); - display: flex; - height: 58px; - margin: 8px auto 24px; - width: 58px; -} - -.template-form__add-question-button:hover, -.template-form__add-question-button:focus { - background: #D1FAE5; - border-color: #10B981; - box-shadow: 0 10px 22px rgba(16, 185, 129, 0.24); -} - -.template-form__add-template-button { - aspect-ratio: 1; - background: #ECFDF5; - border: 2px solid #6EE7B7; - border-radius: 12px; - box-shadow: 0 2px 8px rgba(16, 185, 129, 0.16); - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - min-width: min(calc(33.333% - 14px), 280px); - text-decoration: none; - transition: - background-color 0.2s ease, - border-color 0.2s ease, - box-shadow 0.2s ease, - transform 0.2s ease; -} - -.template-form__add-template-button:hover, -.template-form__add-template-button:focus { - background: #D1FAE5; - border-color: #10B981; - box-shadow: 0 4px 16px rgba(16, 185, 129, 0.24); - outline: none; - transform: translateY(-2px); -} - -.template-form__submit { - background: #6A1B60; - border: 1px solid #6A1B60; - border-radius: 10px; - color: #FFFFFF; - cursor: pointer; - font: inherit; - font-weight: 700; - letter-spacing: 0.01em; - padding: 12px 22px; - transition: - background-color 0.2s ease, - box-shadow 0.2s ease, - transform 0.2s ease; -} - -.template-form__submit:hover, -.template-form__submit:focus { - background: #58134F; - box-shadow: 0 8px 18px rgba(106, 27, 96, 0.22); - outline: none; -} - -.template-form__submit:active { - transform: translateY(1px); -} - -button.template-form__submit { - appearance: none; - display: inline-block; - text-decoration: none; -} - -.template-form__actions { - display: flex; - gap: 12px; - justify-content: flex-end; - margin-top: 16px; -} - -.template-options { - border: 1px solid #F0F0F0; - border-radius: 8px; - margin: 12px 0; - padding: 12px; -} - -.template-options legend { - color: #555555; - font-size: 14px; - font-weight: 700; - padding: 0 6px; -} - -.template-options__list { - display: flex; - flex-direction: column; - gap: 10px; -} - -.template-option { - align-items: center; - background: #FAFAFA; - border: 1px solid #F0F0F0; - border-radius: 8px; - display: flex; - gap: 12px; - padding: 10px; -} - -.template-option__content { - flex: 1; - min-width: 0; -} - -.template-option__label { - color: #555555; - display: inline-block; - font-size: 13px; - font-weight: 700; - margin-bottom: 8px; -} - -.template-form__cards { - display: flex; - flex-wrap: wrap; - gap: 20px; - margin: 24px 0; - max-width: 100vw; -} - -.template-form__card { - aspect-ratio: 1; - background: #FFFFFF; - border: 1px solid #ECECEC; - border-radius: 12px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); - box-sizing: border-box; - cursor: pointer; - display: flex; - flex: 1; - flex-direction: column; - justify-content: space-between; - min-width: min(calc(33.333% - 14px), 280px); - max-width: 20vw; - padding: 20px; - text-decoration: none; - transition: - background-color 0.2s ease, - border-color 0.2s ease, - box-shadow 0.2s ease, - transform 0.2s ease; -} - -.template-form__card:hover, -.template-form__card:focus { - background: #FAFAFA; - border-color: #6A1B60; - box-shadow: 0 4px 16px rgba(106, 27, 96, 0.12); - outline: none; - transform: translateY(-2px); -} - -.template-form__card-title { - color: #222222; - font-size: 16px; - font-weight: 700; - margin: 0 0 8px 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.template-form__card-description { - color: #666666; - font-size: 13px; - line-height: 1.4; - margin: 0 0 16px 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: normal; -} - -.template-form__card-info { - display: flex; - flex-direction: column; - gap: 8px; -} - -.template-form__card-info-item { - align-items: center; - color: #555555; - display: flex; - font-size: 12px; - gap: 6px; -} - -.template-form__card-info-item strong { - color: #6A1B60; - font-weight: 700; -} \ No newline at end of file diff --git a/app/assets/stylesheets/templates.css b/app/assets/stylesheets/templates.css new file mode 100644 index 0000000000..80f9fa1ee6 --- /dev/null +++ b/app/assets/stylesheets/templates.css @@ -0,0 +1,563 @@ +/* Template listing, editor, nested questions, and options. */ + +.template-page__header { + align-items: flex-start; + display: flex; + gap: 20px; + justify-content: space-between; + margin-bottom: 30px; +} + +.template-show__description { + margin-top: 0.5rem; + max-width: 48rem; + color: #6b7280; + font-size: 0.95rem; + line-height: 1.5; +} + +.template-page__header h1 { + color: var(--camaar-ink); + font-size: clamp(28px, 4vw, 42px); + letter-spacing: -0.035em; + margin: 0; + overflow-wrap: anywhere; + position: relative; +} + +.template-page__header h1::after { + background: linear-gradient(90deg, var(--camaar-brand), var(--camaar-accent)); + border-radius: 999px; + bottom: -12px; + content: ""; + height: 4px; + left: 0; + position: absolute; + width: 64px; +} + +.template-page h2, +.template-page>h3, +.template-form>h3 { + color: var(--camaar-ink); +} + +.template-page h2 { + font-size: clamp(20px, 2.5vw, 26px); + letter-spacing: -0.02em; + margin: 0 0 18px; +} + +.template-page>hr, +.template-form>hr { + background: linear-gradient(90deg, transparent, var(--camaar-border), transparent); + border: 0; + height: 1px; + margin: 26px 0; +} + +.template-section { + background: rgba(255, 255, 255, 0.72); + border: 1px solid rgba(231, 222, 232, 0.88); + border-radius: 18px; + box-shadow: 0 16px 40px rgba(76, 18, 70, 0.06); + margin-bottom: 26px; + padding: clamp(18px, 3vw, 28px); +} + +.template-form { + max-width: none; + width: 100%; +} + +.template-form__metadata { + background: rgba(255, 255, 255, 0.88); + border: 1px solid var(--camaar-border); + border-radius: 14px; + box-shadow: 0 10px 28px rgba(76, 18, 70, 0.06); + margin-bottom: 18px; + overflow: hidden; + padding: 0; +} + +.template-form__metadata-summary { + align-items: center; + color: var(--camaar-brand); + cursor: pointer; + display: flex; + font-weight: 700; + justify-content: space-between; + list-style: none; + padding: 16px 18px; + transition: background-color 0.2s ease; +} + +.template-form__metadata-summary::-webkit-details-marker { + display: none; +} + +.template-form__metadata-summary::after { + content: "+"; + font-size: 20px; + font-weight: 700; +} + +.template-form__metadata[open] .template-form__metadata-summary::after { + content: "-"; +} + +.template-form__metadata[open] .template-form__metadata-summary { + background: linear-gradient(90deg, var(--camaar-brand-soft), var(--camaar-accent-soft)); +} + +.template-form__metadata-fields { + border-top: 1px solid var(--camaar-border); + padding: 18px; +} + +.template-form__field { + display: flex; + flex-direction: column; +} + +.template-form__field--compact { + max-width: 240px; +} + +.template-form__field label { + color: var(--camaar-ink); + font-size: 18px; + font-weight: 600; +} + +.template-form textarea { + min-height: 92px; + resize: vertical; +} + +.template-form__questions { + display: flex; + flex-direction: column; + gap: 16px; + margin-bottom: 16px; +} + +.template-question { + align-items: flex-start; + background: linear-gradient(145deg, var(--camaar-surface) 0%, #FDF9FC 100%); + border: 1px solid var(--camaar-border); + border-left: 4px solid var(--camaar-brand); + border-radius: 14px; + box-shadow: 0 10px 28px rgba(76, 18, 70, 0.07); + display: flex; + gap: 14px; + padding: 18px; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease; +} + +.template-question:focus-within { + border-color: rgba(16, 185, 129, 0.58); + border-left-color: var(--camaar-accent); + box-shadow: 0 14px 34px rgba(16, 185, 129, 0.10); +} + +.template-question__content { + flex: 1; + min-width: 0; +} + +.template-question__header { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; +} + +.template-question__header-number { + background: var(--camaar-brand-soft); + border: 1px solid #E9D5E7; + border-radius: 999px; + color: var(--camaar-brand); + display: inline-flex; + font-weight: 700; + padding: 6px 15px; + gap: 0.25rem; +} + +.template-question__actions, +.template-option__actions { + display: flex; + flex-shrink: 0; + gap: 8px; +} + +.template-question__actions { + flex-direction: column; +} + +.template-form__icon-button, +.template-form__card-delete { + align-items: center; + cursor: pointer; + display: inline-flex; + justify-content: center; + padding: 0; +} + +.template-form__icon-button { + background: var(--camaar-surface); + border: 1px solid var(--camaar-border); + border-radius: 8px; + box-shadow: 0 3px 10px rgba(76, 18, 70, 0.06); + height: 34px; + transition: + background-color 0.2s ease, + border-color 0.2s ease, + transform 0.2s ease; + width: 34px; +} + +.template-form__icon-button:hover, +.template-form__icon-button:focus { + background: var(--camaar-brand-soft); + border-color: #CFA9CB; + outline: none; +} + +.template-form__icon-button:active { + transform: translateY(1px); +} + +.template-form__icon-button--danger:hover, +.template-form__icon-button--danger:focus { + background: #FEF2F2; + border-color: #F87171; +} + +.template-form__add-option-button { + margin-top: 0.75rem; + width: 100%; + padding: 27px 0px; + border-radius: 10px; +} + +.template-form__add-question-button { + background: linear-gradient(145deg, var(--camaar-accent-soft), #D1FAE5); + border-color: #6EE7B7; + border-radius: 18px; + box-shadow: 0 8px 18px rgba(16, 185, 129, 0.18); + display: flex; + height: 58px; + margin: 8px auto 24px; + width: 58px; +} + +.template-form__add-question-button:hover, +.template-form__add-question-button:focus { + background: #D1FAE5; + border-color: var(--camaar-accent); + box-shadow: 0 10px 22px rgba(16, 185, 129, 0.24); +} + +.template-form__add-template-button { + align-items: center; + background: + radial-gradient(circle at 50% 50%, rgba(110, 231, 183, 0.34), transparent 70%), + linear-gradient(145deg, #F1FFF9, #E5F9F0); + border: 2px dashed #6EE7B7; + border-radius: 16px; + box-shadow: 0 2px 8px rgba(16, 185, 129, 0.16); + cursor: pointer; + display: flex; + justify-content: center; + min-height: 220px; + min-width: 0; + text-decoration: none; + transition: + background-color 0.2s ease, + border-color 0.2s ease, + box-shadow 0.2s ease, + transform 0.2s ease; +} + +.template-form__add-template-button:hover, +.template-form__add-template-button:focus { + background: linear-gradient(145deg, #DDFBED, #CCF5E2); + border-color: var(--camaar-accent); + box-shadow: 0 4px 16px rgba(16, 185, 129, 0.24); + outline: none; + transform: translateY(-2px); +} + +.template-form__actions { + margin-top: 16px; + padding-bottom: clamp(2rem, 6vh, 4rem); +} + +.template-options { + background: rgba(236, 253, 245, 0.58); + border: 1px solid #CDEEDD; + border-radius: 12px; + margin: 12px 0; + padding: 12px; + display: flex; + flex-direction: column; + flex-wrap: wrap; + align-content: stretch; + justify-content: center; + align-items: stretch; +} + +.template-options legend { + color: var(--camaar-accent-dark); + font-size: 14px; + font-weight: 700; + padding: 0 6px; +} + +.template-options__list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.template-option { + align-items: center; + background: rgba(255, 255, 255, 0.88); + border: 1px solid #D7F1E4; + border-radius: 10px; + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 12px; + padding: 10px; +} + +.template-option__content { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + justify-content: flex-start; + gap: 1rem; +} + +.template-option__label { + color: var(--camaar-accent-dark); + display: contents; + font-size: 16px; + font-weight: 700; + margin-bottom: 8px; +} + +.template-form__cards { + display: grid; + gap: 20px; + grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 320px)); + margin: 24px 0; +} + +.template-form__card { + background: linear-gradient(145deg, var(--camaar-surface) 0%, #FCF8FC 100%); + border: 1px solid var(--camaar-border); + border-radius: 16px; + box-shadow: 0 10px 26px rgba(76, 18, 70, 0.07); + display: flex; + flex-direction: column; + min-height: 220px; + min-width: 0; + overflow: hidden; + position: relative; + transition: + background-color 0.2s ease, + border-color 0.2s ease, + box-shadow 0.2s ease, + transform 0.2s ease; +} + +.template-form__card::before { + background: linear-gradient(90deg, var(--camaar-brand), #9C4A91, var(--camaar-accent)); + content: ""; + height: 5px; + left: 0; + position: absolute; + right: 0; + top: 0; +} + +.template-form__card:hover, +.template-form__card:focus-within { + background: linear-gradient(145deg, var(--camaar-surface) 0%, var(--camaar-brand-soft) 100%); + border-color: #CFA9CB; + box-shadow: 0 18px 36px rgba(106, 27, 96, 0.14); + outline: none; + transform: translateY(-2px); +} + +.template-form__card-link { + color: inherit; + display: flex; + flex: 1; + flex-direction: column; + justify-content: space-between; + min-width: 0; + padding: 20px; + text-decoration: none; +} + +.template-form__card-link:focus { + outline: none; +} + +.template-form__card--removable .template-form__card-link { + padding-right: 64px; +} + +.template-form__card-delete-form { + margin: 0; + position: absolute; + right: 0.875rem; + bottom: 16px; + z-index: 2; +} + +.template-form__card-delete { + background: #FFF7F7; + border: 1px solid #F6C8C8; + border-radius: 9px; + height: 36px; + transition: background-color 0.2s ease, border-color 0.2s ease; + width: 36px; +} + +.template-form__card-delete img { + display: block; + opacity: 0.72; +} + +.template-form__card-delete:hover, +.template-form__card-delete:focus { + background: #FEE2E2; + border-color: #F87171; + outline: none; +} + +.template-form__card-delete:hover img, +.template-form__card-delete:focus img { + opacity: 1; +} + +.template-form__card-content { + min-width: 0; + overflow: hidden; +} + +.template-form__card-title { + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + color: var(--camaar-brand-dark); + display: -webkit-box; + font-size: 18px; + font-weight: 700; + line-clamp: 2; + margin: 0 0 8px 0; + overflow: hidden; + overflow-wrap: anywhere; + text-overflow: ellipsis; +} + +.template-form__card-description { + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + color: var(--camaar-muted); + display: -webkit-box; + font-size: 13px; + line-clamp: 3; + line-height: 1.4; + margin: 0 0 16px 0; + overflow: hidden; + overflow-wrap: anywhere; + text-overflow: ellipsis; +} + +.template-form__card-info { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.template-form__card-info-item { + align-items: center; + background: var(--camaar-brand-soft); + border: 1px solid #E9D5E7; + border-radius: 999px; + color: var(--camaar-brand-dark); + display: flex; + font-size: 12px; + gap: 6px; + padding: 6px 9px; +} + +.template-form__card-info-item:nth-child(2) { + background: var(--camaar-accent-soft); + border-color: #CDEEDD; + color: var(--camaar-accent-dark); +} + +.template-form__card-info-item strong { + color: currentColor; + font-weight: 700; +} + +.template-form__empty-state { + grid-column: 1 / -1; + margin: 24px 0; + width: 100%; +} + +@media (max-width: 640px) { + .template-page__header { + align-items: stretch; + flex-direction: column; + } + + .template-page__header .app-button { + align-self: flex-start; + } + + .template-section { + border-radius: 14px; + } + + .template-form__cards { + grid-template-columns: 1fr; + } + + .template-form__card, + .template-form__add-template-button { + min-height: 180px; + } + + .template-question { + align-items: stretch; + flex-direction: column; + } + + .template-question__actions { + flex-direction: row; + justify-content: flex-end; + } + + .template-form__card-title { + -webkit-line-clamp: 1; + line-clamp: 1; + } + + .template-form__card-description { + -webkit-line-clamp: 2; + line-clamp: 2; + } +} \ No newline at end of file From 4b2d0da9aab574c9ec96e43b525ca5c0ddf17956 Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Sat, 20 Jun 2026 20:33:12 -0300 Subject: [PATCH 088/125] =?UTF-8?q?fix(templates):=20corrige=20o=20control?= =?UTF-8?q?ler=20de=20templates=20para=20ser=20compat=C3=ADvel=20com=20a?= =?UTF-8?q?=20abordagem=20de=20UX/UI=20escolhida:=20agora=20a=20quest?= =?UTF-8?q?=C3=A3o=20n=C3=A3o=20=C3=A9=20definida=20por=20padr=C3=A3o=20co?= =?UTF-8?q?mo=20discursiva?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/controllers/templates_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/templates_controller.rb b/app/controllers/templates_controller.rb index 309e61bfdf..c465812aaa 100644 --- a/app/controllers/templates_controller.rb +++ b/app/controllers/templates_controller.rb @@ -80,7 +80,7 @@ def preparar_campos_do_template utilizacoes.build(numero: 1) if utilizacoes.empty? utilizacoes.each do |utilizacao| - utilizacao.build_questao(tipo: :discursiva) if utilizacao.questao.blank? + utilizacao.build_questao(tipo: nil) if utilizacao.questao.blank? end end From 025f52788d005417b6c006f8d9dbd4d7832afa61 Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Sat, 20 Jun 2026 20:33:59 -0300 Subject: [PATCH 089/125] =?UTF-8?q?feat(templates):=20adiciona=20helper=20?= =?UTF-8?q?para=20exibir=20op=C3=A7=C3=B5es=20com=20letras=20como=20=C3=AD?= =?UTF-8?q?ndice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/helpers/templates_helper.rb | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 app/helpers/templates_helper.rb diff --git a/app/helpers/templates_helper.rb b/app/helpers/templates_helper.rb new file mode 100644 index 0000000000..4420204cff --- /dev/null +++ b/app/helpers/templates_helper.rb @@ -0,0 +1,16 @@ +module TemplatesHelper + def letra_da_opcao(numero) + return "" if numero.blank? + + numero = numero.to_i + resultado = "" + + while numero.positive? + numero -= 1 + resultado.prepend((97 + (numero % 26)).chr) + numero /= 26 + end + + resultado + end +end From dae9fc4b54ceb1f681aef57c3388960eb9517962 Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Sat, 20 Jun 2026 20:34:39 -0300 Subject: [PATCH 090/125] =?UTF-8?q?feat(templates):=20adiciona=20os=20java?= =?UTF-8?q?scrips=20necess=C3=A1rios=20para=20as=20views?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/app_shell_controller.js | 34 +++++++++++++++++++ .../controllers/dropdown_controller.js | 17 ++++++++++ .../controllers/template_form_controller.js | 15 +++++++- 3 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 app/javascript/controllers/app_shell_controller.js create mode 100644 app/javascript/controllers/dropdown_controller.js diff --git a/app/javascript/controllers/app_shell_controller.js b/app/javascript/controllers/app_shell_controller.js new file mode 100644 index 0000000000..597eda9bca --- /dev/null +++ b/app/javascript/controllers/app_shell_controller.js @@ -0,0 +1,34 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["sidebar", "backdrop", "menuButton"] + + connect() { + this.expanded = window.innerWidth > 760 + this.updateSidebar() + } + + toggleSidebar() { + this.expanded = !this.expanded + this.updateSidebar() + } + + closeSidebar() { + this.expanded = false + this.updateSidebar() + } + + closeOnEscape(event) { + if (event.key !== "Escape" || !this.expanded) return + + this.closeSidebar() + this.menuButtonTarget.focus() + } + + updateSidebar() { + this.element.classList.toggle("sidebar-open", this.expanded) + this.element.classList.toggle("sidebar-collapsed", !this.expanded) + this.menuButtonTarget.setAttribute("aria-expanded", this.expanded.toString()) + this.backdropTarget.hidden = !this.expanded + } +} diff --git a/app/javascript/controllers/dropdown_controller.js b/app/javascript/controllers/dropdown_controller.js new file mode 100644 index 0000000000..762c9d52ab --- /dev/null +++ b/app/javascript/controllers/dropdown_controller.js @@ -0,0 +1,17 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + connect() { + this.open = false + } + + toggle() { + this.open = !this.open + this.element.classList.toggle("is-open", this.open) + } + + close() { + this.open = false + this.element.classList.remove("is-open") + } +} \ No newline at end of file diff --git a/app/javascript/controllers/template_form_controller.js b/app/javascript/controllers/template_form_controller.js index 0e6425538c..b66f325a7a 100644 --- a/app/javascript/controllers/template_form_controller.js +++ b/app/javascript/controllers/template_form_controller.js @@ -179,10 +179,23 @@ export default class extends Controller { option.querySelector("[data-template-form-option-number]").value = number option.querySelector("[data-template-form-option-position]").textContent = - number + this.optionLetter(number) }) } + optionLetter(number) { + let result = "" + let current = number + + while (current > 0) { + current -= 1 + result = String.fromCharCode(97 + (current % 26)) + result + current = Math.floor(current / 26) + } + + return result + } + get questionElements() { return [...this.questionsTarget.children].filter((element) => element.matches("[data-template-form-question]"), From 69fd299a4a50a935a478fd73d7246173c3bd0264 Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Sat, 20 Jun 2026 20:36:08 -0300 Subject: [PATCH 091/125] =?UTF-8?q?test(templates):=20adiciona=20cen=C3=A1?= =?UTF-8?q?rios=20BDD=20considerando=20as=20decis=C3=B5es=20arquiteturais?= =?UTF-8?q?=20do=20grupo/project=20owner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- features/step_definitions/template_steps.rb | 47 +++++++++++++++---- .../templates/visualizar_templates.feature | 9 ++++ 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/features/step_definitions/template_steps.rb b/features/step_definitions/template_steps.rb index f28db1b0d4..a2c4883f00 100644 --- a/features/step_definitions/template_steps.rb +++ b/features/step_definitions/template_steps.rb @@ -49,6 +49,14 @@ def mensagem_de_erro_do_template(template) end end +Given(/^que existe um template chamado "([^"]+)" criado por outro administrador$/) do |titulo| + outro_admin = usuario_administrador(departamento: "Outro Departamento") + estado[:template_de_outro_administrador] = template_com_titulo( + titulo, + adm: outro_admin.perfil_adm + ) +end + When(/^eu tento acessar a página de criação de template$/) do preparar_formulario_de_template @@ -227,7 +235,12 @@ def mensagem_de_erro_do_template(template) next end - template.destroy! + within(%([data-template-id="#{template.id}"])) do + find(%(button[aria-label="Excluir template #{template.titulo}"])).click + end + + expect(page).to have_current_path(templates_path) + expect(page).to have_content("Template excluído com sucesso.") estado[:mensagens] << "o template foi removido com sucesso" end @@ -279,18 +292,34 @@ def mensagem_de_erro_do_template(template) expect(estado[:formulario_visivel]).to be(false) end +Then(/^devo ver a ação de excluir o template "([^"]+)"$/) do |titulo| + template = Template.find_by!(titulo: titulo) + + within(%([data-template-id="#{template.id}"])) do + expect(page).to have_button("Excluir template #{titulo}") + end +end + +Then(/^não devo ver a ação de excluir o template "([^"]+)"$/) do |titulo| + template = Template.find_by!(titulo: titulo) + + within(%([data-template-id="#{template.id}"])) do + expect(page).to have_no_button("Excluir template #{titulo}") + end +end + Then( /^o formulário criado anteriormente deve continuar contendo a questão "([^"]+)"$/ -) do |_enunciado| - pendente_por_app_incompleto!( - "snapshot de questões do formulário criado a partir de template" - ) +) do |enunciado| + formulario = estado.fetch(:formulario_anterior).reload + + expect(formulario.questoes.where(enunciado: enunciado)).to exist end Then( /^o formulário criado anteriormente não deve conter a questão "([^"]+)"$/ -) do |_enunciado| - pendente_por_app_incompleto!( - "snapshot de questões do formulário criado a partir de template" - ) +) do |enunciado| + formulario = estado.fetch(:formulario_anterior).reload + + expect(formulario.questoes.where(enunciado: enunciado)).not_to exist end diff --git a/features/templates/visualizar_templates.feature b/features/templates/visualizar_templates.feature index 3882bfe32f..0a4c5ed900 100644 --- a/features/templates/visualizar_templates.feature +++ b/features/templates/visualizar_templates.feature @@ -29,3 +29,12 @@ Funcionalidade: Visualizar templates criados E que estou autenticado como usuário não administrador Quando eu tento acessar a página de templates Então devo ver a mensagem "Você não tem permissão para visualizar templates." + + @sad + Cenário: Administrador só pode excluir templates criados por ele + Dado que estou autenticado como administrador + E que existe um template chamado "Meu Template" criado por mim + E que existe um template chamado "Template de Outro Administrador" criado por outro administrador + Quando eu acesso a página de templates + Então devo ver a ação de excluir o template "Meu Template" + E não devo ver a ação de excluir o template "Template de Outro Administrador" From 0e5095c6423cd29761aa6a980f848758123c4e44 Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Sat, 20 Jun 2026 20:38:14 -0300 Subject: [PATCH 092/125] =?UTF-8?q?fix(templates):=20corrige/melhora=20as?= =?UTF-8?q?=20telas=20de=20templates,=20j=C3=A1=20preparando=20o=20sistema?= =?UTF-8?q?=20para=20uma=20unifica=C3=A7=C3=A3o=20das=20telas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/views/layouts/application.html.erb | 23 +++++- .../dashboard_shared/_sidebar.html.erb | 20 ++++- .../layouts/dashboard_shared/_topbar.html.erb | 45 ++++++++++++ app/views/templates/_card.html.erb | 42 +++++++++++ app/views/templates/_form.html.erb | 5 +- app/views/templates/_option_fields.html.erb | 9 +-- app/views/templates/_question_fields.html.erb | 53 +++++++++----- app/views/templates/edit.html.erb | 2 +- app/views/templates/index.html.erb | 73 +++++-------------- app/views/templates/new.html.erb | 2 +- app/views/templates/show.html.erb | 45 ++++-------- 11 files changed, 204 insertions(+), 115 deletions(-) create mode 100644 app/views/layouts/dashboard_shared/_topbar.html.erb create mode 100644 app/views/templates/_card.html.erb diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 3c3b4b8c7f..93771aa9db 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -27,6 +27,27 @@ - <%= yield %> + <% if current_user.present? %> +
    + <%= render "layouts/dashboard_shared/sidebar" %> + + +
    + <%= render "layouts/dashboard_shared/topbar" %> + <%= yield %> +
    +
    + <% else %> + <%= yield %> + <% end %> diff --git a/app/views/layouts/dashboard_shared/_sidebar.html.erb b/app/views/layouts/dashboard_shared/_sidebar.html.erb index f1ef31ada3..c178136301 100644 --- a/app/views/layouts/dashboard_shared/_sidebar.html.erb +++ b/app/views/layouts/dashboard_shared/_sidebar.html.erb @@ -1,13 +1,27 @@ - + diff --git a/app/views/layouts/dashboard_shared/_topbar.html.erb b/app/views/layouts/dashboard_shared/_topbar.html.erb new file mode 100644 index 0000000000..33b877f436 --- /dev/null +++ b/app/views/layouts/dashboard_shared/_topbar.html.erb @@ -0,0 +1,45 @@ +
    + + + <%= form_with url: pesquisa_path, method: :get, class: "search-container", role: :search do %> + <%= label_tag :q, "Pesquisar no CAMAAR", class: "visually-hidden" %> + <%= search_field_tag :q, + params[:q], + placeholder: "Pesquisar avaliações, templates e formulários...", + autocomplete: "off", + required: true %> + <%= button_tag type: :submit, class: "search-btn", aria: { label: "Pesquisar" } do %> + Pesquisar + + <% end %> + <% end %> + +
    + + <%= current_user.nome.first.upcase %> + + + +
    +
    diff --git a/app/views/templates/_card.html.erb b/app/views/templates/_card.html.erb new file mode 100644 index 0000000000..738fcde054 --- /dev/null +++ b/app/views/templates/_card.html.erb @@ -0,0 +1,42 @@ +
    " + data-template-id="<%= template.id %>"> + <%= link_to template_path(template), class: "template-form__card-link" do %> +
    +

    <%= template.titulo %>

    +

    <%= template.descricao %>

    +
    + +
    +
    + 📋 + <%= template.questoes.count %> + <%= template.questoes.count == 1 ? "questão" : "questões" %> +
    +
    + 📝 + <%= template.formularios.count %> + <%= template.formularios.count == 1 ? "formulário" : "formulários" %> +
    +
    + <% end %> + + <% if removable %> + <%= button_to( + template_path(template), + method: :delete, + class: "template-form__card-delete", + title: "Excluir #{template.titulo}", + aria: { label: "Excluir template #{template.titulo}" }, + form: { + class: "template-form__card-delete-form", + data: { + turbo_confirm: "Excluir o template '#{template.titulo}'? Esta ação não pode ser desfeita." + } + } + ) do %> + Excluir template <%= template.titulo %> + <%= image_tag "icons/trash.svg", alt: "", width: 16, height: 16 %> + <% end %> + <% end %> +
    diff --git a/app/views/templates/_form.html.erb b/app/views/templates/_form.html.erb index 70741781b3..7331a17548 100644 --- a/app/views/templates/_form.html.erb +++ b/app/views/templates/_form.html.erb @@ -57,7 +57,7 @@ <% end %> <% nova_utilizacao = UtilizacaoQuestao.new %> - <% nova_utilizacao.build_questao(tipo: :discursiva) %> + <% nova_utilizacao.build_questao(tipo: nil) %>
    + <%= link_to "Voltar aos templates", templates_path, class: "app-button app-button--secondary" %> <%= f.submit( template.persisted? ? "Atualizar template" : "Criar template", - class: "template-form__submit" + class: "app-button" ) %>
    <% end %> diff --git a/app/views/templates/_option_fields.html.erb b/app/views/templates/_option_fields.html.erb index e1e3010fcc..ecb11e3d3e 100644 --- a/app/views/templates/_option_fields.html.erb +++ b/app/views/templates/_option_fields.html.erb @@ -10,13 +10,12 @@
    - Opção + + <%= letra_da_opcao(opcao_form.object.numero) %> + -
    - <%= opcao_form.label :texto %> - <%= opcao_form.text_field :texto %> -
    + <%= opcao_form.text_field :texto %>
    diff --git a/app/views/templates/_question_fields.html.erb b/app/views/templates/_question_fields.html.erb index f42db30aeb..53fbe0eb68 100644 --- a/app/views/templates/_question_fields.html.erb +++ b/app/views/templates/_question_fields.html.erb @@ -10,7 +10,9 @@ <%= utilizacao_form.hidden_field :numero, data: { template_form_question_number: true } %>
    +
    +
    Questão
    @@ -21,18 +23,35 @@ <%= questao_form.hidden_field :id, data: { template_form_questao_id: true } %> <% end %> -
    - <%= questao_form.label :tipo %> +
    <%= questao_form.select( - :tipo, + :tipo, [["Discursiva", "discursiva"], ["Objetiva", "objetiva"]], - {}, + { prompt: "Tipo de questão" }, data: { - action: "template-question#toggleOptions", + action: " + click->dropdown#toggle + blur->dropdown#close + change->template-question#toggleOptions + ", template_question_target: "tipo" - } + }, + required: true, + aria: { label: "Tipo de questão" } ) %> + +
    +
    <%= questao_form.label :enunciado %> @@ -51,17 +70,17 @@ <%= render "option_fields", opcao_form: opcao_form, edicao: edicao %> <% end %>
    - - - <%= button_tag( - type: "button", - class: "template-form__icon-button template-form__add-button", - data: { action: "template-form#addOption" }, - title: "Adicionar opção", - aria: { label: "Adicionar opção" } - ) do %> - <%= image_tag "icons/plus.svg", alt: "", width: 16, height: 16 %> - <% end %> + + <%= button_tag( + type: "button", + class: "template-form__icon-button template-form__add-option-button", + data: { action: "template-form#addOption" }, + title: "Adicionar opção", + aria: { label: "Adicionar opção" } + ) do %> + <%= image_tag "icons/plus.svg", alt: "", width: 16, height: 16 %> + <% end %> +
    <% end %>
    diff --git a/app/views/templates/edit.html.erb b/app/views/templates/edit.html.erb index 06712c66c9..cb3740f5fe 100644 --- a/app/views/templates/edit.html.erb +++ b/app/views/templates/edit.html.erb @@ -1,4 +1,4 @@ -
    +

    Editar Template

    <%= render "form", template: @template %> diff --git a/app/views/templates/index.html.erb b/app/views/templates/index.html.erb index 1b26d07de9..52bc61e667 100644 --- a/app/views/templates/index.html.erb +++ b/app/views/templates/index.html.erb @@ -1,30 +1,17 @@ -
    +

    Templates

    -
    + <% if notice %> +

    <%= notice %>

    + <% end %> + +

    Meus templates

    <% if @user_templates.any? %>
    <% @user_templates.each do |t| %> - <%= link_to template_path(t), class: "template-form__card" do %> -
    -

    <%= t.titulo %>

    -

    <%= truncate(t.descricao, length: 80) %>

    -
    -
    -
    - 📋 - <%= t.questoes.count %> - <%= t.questoes.count == 1 ? "questão" : "questões" %> -
    -
    - 📝 - <%= t.formularios.count %> - <%= t.formularios.count == 1 ? "formulário" : "formulários" %> -
    -
    - <% end %> + <%= render "card", template: t, removable: true %> <% end %> <%= link_to new_template_path, class: "template-form__add-template-button", title: "Criar novo template", aria: { label: "Criar novo template" } do %> <%= image_tag "icons/plus.svg", alt: "", width: 24, height: 24 %> @@ -32,7 +19,7 @@
    <% else %>
    -

    Nenhum template foi encontrado

    +

    Nenhum template foi encontrado

    <%= link_to new_template_path, class: "template-form__add-template-button", title: "Criar novo template", aria: { label: "Criar novo template" } do %> <%= image_tag "icons/plus.svg", alt: "", width: 24, height: 24 %> <% end %> @@ -40,37 +27,17 @@ <% end %>
    -
    - -

    Outros Templates

    - <% if @other_templates.any? %> -
    - <% @other_templates.each do |t| %> - <%= link_to template_path(t), class: "template-form__card" do %> -
    -

    <%= t.titulo %>

    -

    <%= truncate(t.descricao, length: 80) %>

    -
    -
    -
    - 📋 - <%= t.questoes.count %> - <%= t.questoes.count == 1 ? "questão" : "questões" %> -
    -
    - 📝 - <%= t.formularios.count %> - <%= t.formularios.count == 1 ? "formulário" : "formulários" %> -
    -
    +
    +

    Outros Templates

    + <% if @other_templates.any? %> +
    + <% @other_templates.each do |t| %> + <%= render "card", template: t, removable: false %> <% end %> - <% end %> -
    - <% else %> -

    Nenhum template de outros administradores encontrado

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

    Nenhum template de outros administradores encontrado

    + <% end %> +
    -
    - <%= button_tag "Voltar", onclick: "window.history.back();", class: "template-form__submit", type: :button %> -
    -
    \ No newline at end of file +
    diff --git a/app/views/templates/new.html.erb b/app/views/templates/new.html.erb index 55d175b6ac..79bb35adcd 100644 --- a/app/views/templates/new.html.erb +++ b/app/views/templates/new.html.erb @@ -1,4 +1,4 @@ -
    +

    Novo Template

    <%= render "form", template: @template %> diff --git a/app/views/templates/show.html.erb b/app/views/templates/show.html.erb index 747e9fa4db..75ab157013 100644 --- a/app/views/templates/show.html.erb +++ b/app/views/templates/show.html.erb @@ -1,45 +1,26 @@ -
    -

    <%= @template.titulo %>

    +
    +
    +

    <%= @template.titulo %>

    + <%= link_to "Usar em Formulário", new_formulario_path(template_id: @template.id), class: "app-button app-button--accent" %> +
    - +

    <%= @template.descricao %>


    -

    Questões

    -
    <% @template.questoes_ordenadas.each do |utilizacao| %>
    - Questão <%= utilizacao.numero %> -
    +
    + Questão <%= utilizacao.numero %> +
    -
    -

    <%= utilizacao.questao.discursiva? ? "Discursiva" : "Objetiva" %>

    -
    - -

    <%= simple_format(utilizacao.questao.enunciado) %>

    -
    + <%= simple_format(utilizacao.questao.enunciado) %> <% if utilizacao.questao.objetiva? && utilizacao.questao.opcoes.any? %>
    @@ -50,7 +31,7 @@
    - Opção <%= opcao.numero %> + <%= (opcao.numero.to_i + 96).chr %>

    <%= opcao.texto %>

    @@ -65,7 +46,7 @@
    - <%= button_tag "Editar", onclick: "window.location.href='#{edit_template_path(@template)}'", class: "template-form__submit", type: :button %> - <%= button_tag "Voltar", onclick: "window.history.back();", class: "template-form__submit", type: :button %> + <%= link_to "Voltar aos templates", templates_path, class: "app-button app-button--secondary" %> + <%= link_to "Editar", edit_template_path(@template), class: "app-button" %>
    From 6b261533a3e2f31398d78ff9088dbf99df6322bf Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Sat, 20 Jun 2026 20:40:57 -0300 Subject: [PATCH 093/125] test(templates): adiciona e melhora testes --- spec/requests/templates_spec.rb | 79 ++++++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/spec/requests/templates_spec.rb b/spec/requests/templates_spec.rb index c67be35810..d049ae2008 100644 --- a/spec/requests/templates_spec.rb +++ b/spec/requests/templates_spec.rb @@ -6,7 +6,10 @@ double( "Usuario", administrador?: true, - perfil_adm: current_administrador + perfil_adm: current_administrador, + nome: "Administrador", + matricula: "ADM001", + email: "administrador@example.com" ) end let(:template) do @@ -63,6 +66,49 @@ get templates_path expect(response).to have_http_status(:ok) + expect( + Nokogiri::HTML(response.body) + .at_xpath("//a[normalize-space()='Voltar aos templates']") + ).to be_nil + end + + it "renderiza a lixeira que envia DELETE para um template próprio" do + policy_scope = double("TemplatePolicyScope") + included_scope = double("IncludedTemplates") + ordered_scope = double("OrderedTemplates") + + allow_any_instance_of(ApplicationController) + .to receive(:policy_scope) + .with(Template) + .and_return(policy_scope) + allow(policy_scope) + .to receive(:includes) + .with(adm: :usuario) + .and_return(included_scope) + allow(included_scope).to receive(:recentes).and_return(ordered_scope) + allow(ordered_scope) + .to receive(:criados_por) + .with(current_administrador) + .and_return([ template ]) + allow(ordered_scope) + .to receive(:criados_por_outros) + .with(current_administrador) + .and_return([]) + allow(template).to receive(:formularios).and_return([]) + + get templates_path + + pagina = Nokogiri::HTML(response.body) + card = pagina.at_css('article[data-template-id="1"]') + formulario = card.at_css("form[action='#{template_path(template)}']") + + expect(response).to have_http_status(:ok) + expect(formulario["data-turbo-confirm"]).to include("Excluir o template") + expect(formulario.at_css('input[name="_method"][value="delete"]')).to be_present + expect( + formulario.at_css('button[aria-label="Excluir template Avaliação"]') + ).to be_present + expect(formulario.at_css('img[src*="icons/trash"]')).to be_present end end @@ -79,6 +125,10 @@ get new_template_path expect(response).to have_http_status(:ok) + expect( + Nokogiri::HTML(response.body) + .at_xpath("//a[normalize-space()='Voltar aos templates']")["href"] + ).to eq(templates_path) end it "prepara uma questão discursiva inicial sem opções" do @@ -132,6 +182,29 @@ get template_path(template) expect(response).to have_http_status(:ok) + pagina = Nokogiri::HTML(response.body) + links_de_acao = pagina.css(".template-form__actions a") + + expect(links_de_acao.map { |link| link.text.strip }).to eq( + [ "Voltar aos templates", "Editar" ] + ) + expect(links_de_acao.first["href"]).to eq(templates_path) + end + + it "renderiza no cabeçalho o botão para usar o template em um formulário" do + allow(Template).to receive(:find).with("1").and_return(template) + + get template_path(template) + + pagina = Nokogiri::HTML(response.body) + botao = pagina.at_css( + ".template-page__header .app-button--accent" + ) + + expect(botao.text.strip).to eq("Usar em Formulário") + expect(botao["href"]).to eq( + new_formulario_path(template_id: template.id) + ) end end @@ -217,6 +290,10 @@ get edit_template_path(template) expect(response).to have_http_status(:ok) + expect( + Nokogiri::HTML(response.body) + .at_xpath("//a[normalize-space()='Voltar aos templates']")["href"] + ).to eq(templates_path) end it "renderiza botões para remover questões e opções via update do template" do From 35dff739553545b9e6cc468693a0d3792856ebb0 Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Sun, 21 Jun 2026 14:57:57 -0300 Subject: [PATCH 094/125] =?UTF-8?q?fix(templates):=20altera=20a=20linguage?= =?UTF-8?q?m=20das=20step=5Fdefinitions=20de=20quest=C3=A3o=20e=20op=C3=A7?= =?UTF-8?q?=C3=A3o=20para=20manter=20o=20padr=C3=A3o=20do=20projeto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../template_dinamico_steps.rb | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/features/step_definitions/template_dinamico_steps.rb b/features/step_definitions/template_dinamico_steps.rb index 4dfbe7c946..2dcd967084 100644 --- a/features/step_definitions/template_dinamico_steps.rb +++ b/features/step_definitions/template_dinamico_steps.rb @@ -74,15 +74,15 @@ def criar_template_com_questoes!(titulo, table) estado[:template_atual] = template end -Quando(/^envio o formulário do template "([^"]+)" com as questões:$/) do |titulo, table| +When(/^envio o formulário do template "([^"]+)" com as questões:$/) do |titulo, table| submeter_criacao_de_template(titulo, utilizacoes_attributes_da_tabela(table)) end -Dado(/^que existe o template "([^"]+)" com as questões:$/) do |titulo, table| +Given(/^que existe o template "([^"]+)" com as questões:$/) do |titulo, table| criar_template_com_questoes!(titulo, table) end -Quando(/^removo a questão "([^"]+)" do template "([^"]+)"$/) do |enunciado, titulo| +When(/^removo a questão "([^"]+)" do template "([^"]+)"$/) do |enunciado, titulo| template = template_por_titulo!(titulo) utilizacao = utilizacao_da_questao!(template, enunciado) @@ -97,7 +97,7 @@ def criar_template_com_questoes!(titulo, table) ) end -Quando(/^reordeno as questões do template "([^"]+)" para:$/) do |titulo, table| +When(/^reordeno as questões do template "([^"]+)" para:$/) do |titulo, table| template = template_por_titulo!(titulo) utilizacoes_por_enunciado = template .utilizacoes_questoes @@ -119,7 +119,7 @@ def criar_template_com_questoes!(titulo, table) submeter_atualizacao_de_template(template, utilizacoes_attributes) end -Então(/^o template "([^"]+)" deve conter as questões na ordem:$/) do |titulo, table| +Then(/^o template "([^"]+)" deve conter as questões na ordem:$/) do |titulo, table| template = template_por_titulo!(titulo) questoes = template .questoes_ordenadas @@ -128,7 +128,7 @@ def criar_template_com_questoes!(titulo, table) expect(questoes).to eq(table.hashes.map { |linha| linha.fetch("enunciado") }) end -Dado( +Given( /^que existe o template "([^"]+)" com a questão objetiva "([^"]+)" e as opções:$/ ) do |titulo, enunciado, table| opcoes = table.hashes.map { |linha| linha.fetch("texto") } @@ -152,7 +152,7 @@ def criar_template_com_questoes!(titulo, table) estado[:template_atual] = template end -Quando( +When( /^adiciono as opções à questão "([^"]+)" do template "([^"]+)":$/ ) do |enunciado, titulo, table| template = template_por_titulo!(titulo) @@ -186,7 +186,7 @@ def criar_template_com_questoes!(titulo, table) ) end -Quando( +When( /^removo a opção "([^"]+)" da questão "([^"]+)" do template "([^"]+)"$/ ) do |texto, enunciado, titulo| template = template_por_titulo!(titulo) @@ -213,7 +213,7 @@ def criar_template_com_questoes!(titulo, table) ) end -Quando( +When( /^reordeno as opções da questão "([^"]+)" do template "([^"]+)" para:$/ ) do |enunciado, titulo, table| template = template_por_titulo!(titulo) @@ -247,7 +247,7 @@ def criar_template_com_questoes!(titulo, table) ) end -Então( +Then( /^a questão "([^"]+)" do template "([^"]+)" deve conter as opções na ordem:$/ ) do |enunciado, titulo, table| template = template_por_titulo!(titulo) From c9b6703489fc33a0cab2933da6573f650940503b Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Sun, 21 Jun 2026 14:58:30 -0300 Subject: [PATCH 095/125] =?UTF-8?q?fix(templates):=20corrige=20formata?= =?UTF-8?q?=C3=A7=C3=A3o=20dos=20arquivos=20.features=20para=20ficar=20de?= =?UTF-8?q?=20acordo=20com=20o=20rubocop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../templates/opcoes/gerenciar_opcoes.feature | 20 ++++---- .../questoes/gerenciar_questoes.feature | 46 +++++++++---------- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/features/templates/opcoes/gerenciar_opcoes.feature b/features/templates/opcoes/gerenciar_opcoes.feature index e9c73fd5f3..5457fc5b41 100644 --- a/features/templates/opcoes/gerenciar_opcoes.feature +++ b/features/templates/opcoes/gerenciar_opcoes.feature @@ -10,9 +10,9 @@ Funcionalidade: Gerenciar opções de questões objetivas Cenário: Criar opções em questão objetiva Dado que existe o template "Avaliação com Opções" com a questão objetiva "Como você avalia a disciplina?" e as opções: - | texto | - | Ruim | - | Regular | + | texto | + | Ruim | + | Regular | Quando adiciono as opções à questão "Como você avalia a disciplina?" do template "Avaliação com Opções": | texto | | Boa | @@ -26,15 +26,15 @@ Funcionalidade: Gerenciar opções de questões objetivas Cenário: Remover opção de questão objetiva Dado que existe o template "Avaliação para Remoção de Opção" com a questão objetiva "Como você avalia a didática?" e as opções: - | texto | - | Ruim | - | Regular | - | Boa | + | texto | + | Ruim | + | Regular | + | Boa | Quando removo a opção "Regular" da questão "Como você avalia a didática?" do template "Avaliação para Remoção de Opção" Então a questão "Como você avalia a didática?" do template "Avaliação para Remoção de Opção" deve conter as opções na ordem: - | texto | - | Ruim | - | Boa | + | texto | + | Ruim | + | Boa | Cenário: Reordenar opções de questão objetiva Dado que existe o template "Avaliação para Reordenar Opções" com a questão objetiva "Como você avalia o professor?" e as opções: diff --git a/features/templates/questoes/gerenciar_questoes.feature b/features/templates/questoes/gerenciar_questoes.feature index a16f35212e..71a7ce4195 100644 --- a/features/templates/questoes/gerenciar_questoes.feature +++ b/features/templates/questoes/gerenciar_questoes.feature @@ -10,37 +10,37 @@ Funcionalidade: Gerenciar questões do template Cenário: Criar questões no template Quando envio o formulário do template "Avaliação com Questões" com as questões: - | enunciado | tipo | - | Descreva os pontos positivos | discursiva | - | Descreva os pontos de melhoria | discursiva | + | enunciado | tipo | + | Descreva os pontos positivos | discursiva | + | Descreva os pontos de melhoria | discursiva | Então o template "Avaliação com Questões" deve conter as questões na ordem: - | enunciado | - | Descreva os pontos positivos | - | Descreva os pontos de melhoria | + | enunciado | + | Descreva os pontos positivos | + | Descreva os pontos de melhoria | Cenário: Remover questão do template Dado que existe o template "Avaliação para Remoção de Questão" com as questões: - | enunciado | tipo | - | Questão que permanece | discursiva | - | Questão removida | discursiva | + | enunciado | tipo | + | Questão que permanece | discursiva | + | Questão removida | discursiva | Quando removo a questão "Questão removida" do template "Avaliação para Remoção de Questão" Então o template "Avaliação para Remoção de Questão" deve conter as questões na ordem: - | enunciado | - | Questão que permanece | + | enunciado | + | Questão que permanece | Cenário: Reordenar questões do template Dado que existe o template "Avaliação para Reordenar Questões" com as questões: - | enunciado | tipo | - | Primeira questão | discursiva | - | Segunda questão | discursiva | - | Terceira questão | discursiva | + | enunciado | tipo | + | Primeira questão | discursiva | + | Segunda questão | discursiva | + | Terceira questão | discursiva | Quando reordeno as questões do template "Avaliação para Reordenar Questões" para: - | enunciado | - | Terceira questão | - | Primeira questão | - | Segunda questão | + | enunciado | + | Terceira questão | + | Primeira questão | + | Segunda questão | Então o template "Avaliação para Reordenar Questões" deve conter as questões na ordem: - | enunciado | - | Terceira questão | - | Primeira questão | - | Segunda questão | + | enunciado | + | Terceira questão | + | Primeira questão | + | Segunda questão | From 5f774957b4766bd48e9d5ec71ef149e35d751831 Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Wed, 24 Jun 2026 00:18:19 -0300 Subject: [PATCH 096/125] =?UTF-8?q?feat(template):=20melhora=20do=20fronte?= =?UTF-8?q?nd=20de=20templates.=20Inclui=20personaliza=C3=A7=C3=A3o=20de?= =?UTF-8?q?=20mensagens=20de=20erros=20e=20estilo=20do=20dropdown,=20que?= =?UTF-8?q?=20foram=20desenvolvidos=20no=20contexto=20de=20templates=20(ma?= =?UTF-8?q?s=20devem=20ser=20utilizados=20no=20resto=20do=20projeto)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/assets/stylesheets/templates.css | 7 +- .../controllers/dropdown_controller.js | 87 ++++++++++++++++-- .../controllers/error_dialog_controller.js | 17 ++++ .../controllers/template_form_controller.js | 89 +++++++------------ 4 files changed, 135 insertions(+), 65 deletions(-) create mode 100644 app/javascript/controllers/error_dialog_controller.js diff --git a/app/assets/stylesheets/templates.css b/app/assets/stylesheets/templates.css index 80f9fa1ee6..fdaa16598e 100644 --- a/app/assets/stylesheets/templates.css +++ b/app/assets/stylesheets/templates.css @@ -157,6 +157,11 @@ box-shadow 0.2s ease; } +.template-question[hidden], +.template-option[hidden] { + display: none; +} + .template-question:focus-within { border-color: rgba(16, 185, 129, 0.58); border-left-color: var(--camaar-accent); @@ -333,7 +338,7 @@ flex-wrap: wrap; justify-content: space-between; gap: 12px; - padding: 10px; + padding: 0px 15px; } .template-option__content { diff --git a/app/javascript/controllers/dropdown_controller.js b/app/javascript/controllers/dropdown_controller.js index 762c9d52ab..1a49839d50 100644 --- a/app/javascript/controllers/dropdown_controller.js +++ b/app/javascript/controllers/dropdown_controller.js @@ -1,17 +1,92 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { + static targets = ["native", "trigger", "label", "menu", "option"] + connect() { - this.open = false + this.syncLabel() + this.close() } - toggle() { - this.open = !this.open - this.element.classList.toggle("is-open", this.open) + toggle(event) { + event.preventDefault() + + if (this.menuTarget.hidden) { + this.open() + } else { + this.close() + } } close() { - this.open = false + this.menuTarget.hidden = true + this.triggerTarget.setAttribute("aria-expanded", "false") this.element.classList.remove("is-open") } -} \ No newline at end of file + + open() { + this.menuTarget.hidden = false + this.triggerTarget.setAttribute("aria-expanded", "true") + this.element.classList.add("is-open") + } + + openWithKeyboard(event) { + event.preventDefault() + this.open() + + const selected = this.optionTargets.find( + (option) => option.getAttribute("aria-selected") === "true", + ) + const optionToFocus = selected || this.optionTargets[0] + + optionToFocus?.focus() + } + + select(event) { + event.preventDefault() + + this.nativeTarget.value = event.currentTarget.dataset.dropdownValue + this.nativeTarget.dispatchEvent(new Event("change", { bubbles: true })) + this.close() + this.triggerTarget.focus() + } + + syncLabel() { + const selected = this.nativeTarget.selectedOptions[0] + this.labelTarget.textContent = selected?.textContent || "Tipo de questão" + + this.optionTargets.forEach((option) => { + const selectedOption = option.dataset.dropdownValue === this.nativeTarget.value + option.setAttribute("aria-selected", selectedOption.toString()) + option.classList.toggle("is-selected", selectedOption) + }) + } + + closeFromOutside(event) { + if (!this.element.contains(event.target)) this.close() + } + + closeOnEscape(event) { + if (this.menuTarget.hidden) return + + event.preventDefault() + this.close() + this.triggerTarget.focus() + } + + navigate(event) { + if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return + + event.preventDefault() + const currentIndex = this.optionTargets.indexOf(event.target) + let nextIndex = currentIndex + + if (event.key === "ArrowDown") nextIndex = currentIndex + 1 + if (event.key === "ArrowUp") nextIndex = currentIndex - 1 + if (event.key === "Home") nextIndex = 0 + if (event.key === "End") nextIndex = this.optionTargets.length - 1 + + const boundedIndex = Math.max(0, Math.min(nextIndex, this.optionTargets.length - 1)) + this.optionTargets[boundedIndex]?.focus() + } +} diff --git a/app/javascript/controllers/error_dialog_controller.js b/app/javascript/controllers/error_dialog_controller.js new file mode 100644 index 0000000000..2455847ba1 --- /dev/null +++ b/app/javascript/controllers/error_dialog_controller.js @@ -0,0 +1,17 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["dialog"] + + connect() { + if (!this.dialogTarget.open) this.dialogTarget.showModal() + } + + close() { + this.dialogTarget.close() + } + + closeFromBackdrop(event) { + if (event.target === event.currentTarget) this.close() + } +} diff --git a/app/javascript/controllers/template_form_controller.js b/app/javascript/controllers/template_form_controller.js index b66f325a7a..d02769291b 100644 --- a/app/javascript/controllers/template_form_controller.js +++ b/app/javascript/controllers/template_form_controller.js @@ -30,6 +30,24 @@ export default class extends Controller { const question = event.target.closest("[data-template-form-question]") if (!question) return + this.appendOption(question) + this.renumberOptions(question) + } + + ensureObjectiveOptions(event) { + const question = event.target.closest("[data-template-form-question]") + if (!question || event.target.value !== "objetiva") return + + const missingOptions = Math.max(0, 2 - this.optionElements(question).length) + + for (let index = 0; index < missingOptions; index += 1) { + this.appendOption(question) + } + + this.renumberOptions(question) + } + + appendOption(question) { const optionIndex = this.uniqueIndex() const questionIndex = question.dataset.templateFormQuestionIndex const fragment = this.htmlToFragment( @@ -37,10 +55,7 @@ export default class extends Controller { .replaceAll("NEW_QUESTION", questionIndex) .replaceAll("NEW_OPTION", optionIndex), ) - const option = fragment.querySelector("[data-template-form-option]") - question.querySelector("[data-template-form-options]").append(fragment) - this.renumberOptions(question) } moveQuestionUp(event) { @@ -100,10 +115,10 @@ export default class extends Controller { return } - this.submitDestroy([ - [idField.name, idField.value], - [destroyField.name, "1"], - ]) + destroyField.value = "1" + question.hidden = true + question.style.display = "none" + this.renumberAll() } destroyOption(event) { @@ -113,10 +128,6 @@ export default class extends Controller { const question = event.target.closest("[data-template-form-question]") if (!option || !question) return - const utilizationIdField = question.querySelector( - "[data-template-form-utilizacao-id]", - ) - const questionIdField = question.querySelector("[data-template-form-questao-id]") const optionIdField = option.querySelector("[data-template-form-opcao-id]") const destroyField = option.querySelector("[data-template-form-option-destroy]") @@ -126,12 +137,10 @@ export default class extends Controller { return } - this.submitDestroy([ - [utilizationIdField.name, utilizationIdField.value], - [questionIdField.name, questionIdField.value], - [optionIdField.name, optionIdField.value], - [destroyField.name, "1"], - ]) + destroyField.value = "1" + option.hidden = true + option.style.display = "none" + this.renumberOptions(question) } uniqueIndex() { @@ -180,6 +189,9 @@ export default class extends Controller { option.querySelector("[data-template-form-option-number]").value = number option.querySelector("[data-template-form-option-position]").textContent = this.optionLetter(number) + + const textField = option.querySelector("[data-template-form-option-text]") + if (textField) textField.placeholder = `Opção ${number}` }) } @@ -198,7 +210,7 @@ export default class extends Controller { get questionElements() { return [...this.questionsTarget.children].filter((element) => - element.matches("[data-template-form-question]"), + element.matches("[data-template-form-question]") && !element.hidden, ) } @@ -207,46 +219,7 @@ export default class extends Controller { if (!options) return [] return [...options.children].filter((element) => - element.matches("[data-template-form-option]"), + element.matches("[data-template-form-option]") && !element.hidden, ) } - - submitDestroy(fields) { - const form = document.createElement("form") - form.hidden = true - form.method = "post" - form.action = this.element.action - - this.appendMethodOverride(form) - this.appendAuthenticityToken(form) - fields.forEach(([name, value]) => this.appendHiddenField(form, name, value)) - - document.body.append(form) - form.requestSubmit() - } - - appendMethodOverride(form) { - const methodOverride = this.element.querySelector("input[name='_method']") - if (!methodOverride) return - - this.appendHiddenField(form, methodOverride.name, methodOverride.value) - } - - appendAuthenticityToken(form) { - const token = - this.element.querySelector("input[name='authenticity_token']")?.value || - document.querySelector("meta[name='csrf-token']")?.content - if (!token) return - - this.appendHiddenField(form, "authenticity_token", token) - } - - appendHiddenField(form, name, value) { - const input = document.createElement("input") - input.type = "hidden" - input.name = name - input.value = value - - form.append(input) - } } From 9a66550d5f04bb88040da115374b38151e944473 Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Wed, 24 Jun 2026 00:19:27 -0300 Subject: [PATCH 097/125] =?UTF-8?q?feat:=20configura=C3=A7=C3=A3o=20para?= =?UTF-8?q?=20portugu=C3=AAs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Gemfile | 2 ++ Gemfile.lock | 5 +++++ config/locales/pt-BR.yml | 13 +++++++++++++ 3 files changed, 20 insertions(+) create mode 100644 config/locales/pt-BR.yml diff --git a/Gemfile b/Gemfile index b02404c636..25bb9b06be 100644 --- a/Gemfile +++ b/Gemfile @@ -16,6 +16,8 @@ gem "turbo-rails" gem "stimulus-rails" # Build JSON APIs with ease [https://github.com/rails/jbuilder] gem "jbuilder" +# Traduções padrão +gem "rails-i18n" gem "csv" diff --git a/Gemfile.lock b/Gemfile.lock index c2883d93bf..33108ffb3d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -362,6 +362,9 @@ GEM 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) + rails-i18n (8.1.0) + i18n (>= 0.7, < 2) + railties (>= 8.0.0, < 9) railties (8.1.3) actionpack (= 8.1.3) activesupport (= 8.1.3) @@ -576,6 +579,7 @@ DEPENDENCIES propshaft puma (>= 8.0.2) rails (~> 8.1.3) + rails-i18n rdoc rspec-rails rubocop-rails-omakase @@ -724,6 +728,7 @@ CHECKSUMS rails (8.1.3) sha256=6d017ba5348c98fc909753a8169b21d44de14d2a0b92d140d1a966834c3c9cd3 rails-dom-testing (2.3.0) sha256=8acc7953a7b911ca44588bf08737bc16719f431a1cc3091a292bca7317925c1d rails-html-sanitizer (1.7.0) sha256=28b145cceaf9cc214a9874feaa183c3acba036c9592b19886e0e45efc62b1e89 + rails-i18n (8.1.0) sha256=52d5fd6c0abef28d84223cc05647f6ae0fd552637a1ede92deee9545755b6cf3 railties (8.1.3) sha256=913eb0e0cb520aac687ffd74916bd726d48fa21f47833c6292576ef6a286de22 rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 diff --git a/config/locales/pt-BR.yml b/config/locales/pt-BR.yml new file mode 100644 index 0000000000..de73169536 --- /dev/null +++ b/config/locales/pt-BR.yml @@ -0,0 +1,13 @@ +"pt-BR": + activerecord: + models: + template: "Template" + questao: "Questão" + utilizacao_questao: "Utilização de Questão" + formulario: "Formulário" + attributes: + template: + titulo: "Título" + descricao: "Descrição" + "template/utilizacoes_questoes/questao": + opcoes: "Opções" From 59df1cfa249debf745ac341a456448c1bef4ac97 Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Sat, 27 Jun 2026 16:03:33 -0300 Subject: [PATCH 098/125] =?UTF-8?q?style(templates):=20altera=20a=20organi?= =?UTF-8?q?za=C3=A7=C3=A3o=20do=20css=20de=20templates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/assets/stylesheets/templates.css | 92 +++------------------------- 1 file changed, 10 insertions(+), 82 deletions(-) diff --git a/app/assets/stylesheets/templates.css b/app/assets/stylesheets/templates.css index fdaa16598e..5f15af2c40 100644 --- a/app/assets/stylesheets/templates.css +++ b/app/assets/stylesheets/templates.css @@ -63,6 +63,7 @@ box-shadow: 0 16px 40px rgba(76, 18, 70, 0.06); margin-bottom: 26px; padding: clamp(18px, 3vw, 28px); + position: relative; } .template-form { @@ -267,36 +268,6 @@ box-shadow: 0 10px 22px rgba(16, 185, 129, 0.24); } -.template-form__add-template-button { - align-items: center; - background: - radial-gradient(circle at 50% 50%, rgba(110, 231, 183, 0.34), transparent 70%), - linear-gradient(145deg, #F1FFF9, #E5F9F0); - border: 2px dashed #6EE7B7; - border-radius: 16px; - box-shadow: 0 2px 8px rgba(16, 185, 129, 0.16); - cursor: pointer; - display: flex; - justify-content: center; - min-height: 220px; - min-width: 0; - text-decoration: none; - transition: - background-color 0.2s ease, - border-color 0.2s ease, - box-shadow 0.2s ease, - transform 0.2s ease; -} - -.template-form__add-template-button:hover, -.template-form__add-template-button:focus { - background: linear-gradient(145deg, #DDFBED, #CCF5E2); - border-color: var(--camaar-accent); - box-shadow: 0 4px 16px rgba(16, 185, 129, 0.24); - outline: none; - transform: translateY(-2px); -} - .template-form__actions { margin-top: 16px; padding-bottom: clamp(2rem, 6vh, 4rem); @@ -358,48 +329,10 @@ margin-bottom: 8px; } -.template-form__cards { - display: grid; - gap: 20px; - grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 320px)); - margin: 24px 0; -} - -.template-form__card { - background: linear-gradient(145deg, var(--camaar-surface) 0%, #FCF8FC 100%); - border: 1px solid var(--camaar-border); - border-radius: 16px; - box-shadow: 0 10px 26px rgba(76, 18, 70, 0.07); - display: flex; - flex-direction: column; - min-height: 220px; - min-width: 0; - overflow: hidden; - position: relative; - transition: - background-color 0.2s ease, - border-color 0.2s ease, - box-shadow 0.2s ease, - transform 0.2s ease; -} - -.template-form__card::before { - background: linear-gradient(90deg, var(--camaar-brand), #9C4A91, var(--camaar-accent)); - content: ""; - height: 5px; - left: 0; - position: absolute; - right: 0; - top: 0; -} - -.template-form__card:hover, -.template-form__card:focus-within { - background: linear-gradient(145deg, var(--camaar-surface) 0%, var(--camaar-brand-soft) 100%); - border-color: #CFA9CB; - box-shadow: 0 18px 36px rgba(106, 27, 96, 0.14); - outline: none; - transform: translateY(-2px); +.template-form__cards--empty { + gap: 12px; + grid-template-columns: minmax(0, 1fr); + margin-bottom: 0; } .template-form__card-link { @@ -523,6 +456,10 @@ width: 100%; } +.template-form__cards--empty .template-form__empty-state { + margin: 0; +} + @media (max-width: 640px) { .template-page__header { align-items: stretch; @@ -537,15 +474,6 @@ border-radius: 14px; } - .template-form__cards { - grid-template-columns: 1fr; - } - - .template-form__card, - .template-form__add-template-button { - min-height: 180px; - } - .template-question { align-items: stretch; flex-direction: column; @@ -565,4 +493,4 @@ -webkit-line-clamp: 2; line-clamp: 2; } -} \ No newline at end of file +} From 0929e5bd5a48b80425771452406a7cd3df7ca27f Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Sat, 27 Jun 2026 16:31:39 -0300 Subject: [PATCH 099/125] =?UTF-8?q?fix(templates):=20reorganiza=20a=20orga?= =?UTF-8?q?niza=C3=A7=C3=A3o=20arquitetural=20dos=20arquivos=20associados?= =?UTF-8?q?=20a=20templates=20para=20melhorar=20ABC=20score=20e=20complexi?= =?UTF-8?q?dade=20ciclom=C3=A1tica?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/controllers/templates_controller.rb | 75 +++------------- app/helpers/templates_helper.rb | 12 +-- app/models/template.rb | 29 +++++-- .../templates/persisted_reordering.rb | 86 +++++++++++++++++++ 4 files changed, 124 insertions(+), 78 deletions(-) create mode 100644 app/services/templates/persisted_reordering.rb diff --git a/app/controllers/templates_controller.rb b/app/controllers/templates_controller.rb index c465812aaa..ae65425a10 100644 --- a/app/controllers/templates_controller.rb +++ b/app/controllers/templates_controller.rb @@ -27,8 +27,7 @@ def new end def create - @template = Template.new(template_params) - @template.adm = current_administrador + @template = build_template authorize! @template @@ -75,6 +74,12 @@ def set_template @template = Template.find(params[:id]) end + def build_template + Template.new(template_params).tap do |template| + template.adm = current_administrador + end + end + def preparar_campos_do_template utilizacoes = @template.utilizacoes_questoes utilizacoes.build(numero: 1) if utilizacoes.empty? @@ -85,75 +90,21 @@ def preparar_campos_do_template end def atualizar_template_com_reordenacao - template_atualizado = false - Template.transaction do preparar_reordenacao_de_registros_persistidos - template_atualizado = @template.update(template_params) - - raise ActiveRecord::Rollback unless template_atualizado + update_template_or_rollback end - - template_atualizado end def preparar_reordenacao_de_registros_persistidos - utilizacoes_attributes = nested_attributes_values( - params.dig(:template, :utilizacoes_questoes_attributes) + Templates::PersistedReordering.prepare( + template: @template, + attributes: params.dig(:template, :utilizacoes_questoes_attributes) ) - - preparar_reordenacao_de_utilizacoes(utilizacoes_attributes) - preparar_reordenacao_de_opcoes(utilizacoes_attributes) - end - - def preparar_reordenacao_de_utilizacoes(utilizacoes_attributes) - ids = utilizacoes_attributes - .reject { |attributes| destroy_attribute?(attributes) } - .filter_map { |attributes| persisted_id_with_number(attributes) } - - UtilizacaoQuestao - .where(template_id: @template.id, id: ids) - .find_each - .with_index(1) do |utilizacao, index| - utilizacao.update_columns(numero: -index) - end - end - - def preparar_reordenacao_de_opcoes(utilizacoes_attributes) - opcao_ids = utilizacoes_attributes.flat_map do |utilizacao_attributes| - questao_attributes = utilizacao_attributes[:questao_attributes] || - utilizacao_attributes["questao_attributes"] - opcoes_attributes = nested_attributes_values( - questao_attributes&.dig(:opcoes_attributes) || - questao_attributes&.dig("opcoes_attributes") - ) - - opcoes_attributes - .reject { |attributes| destroy_attribute?(attributes) } - .filter_map { |attributes| persisted_id_with_number(attributes) } - end - - Opcao.where(id: opcao_ids).find_each.with_index(1) do |opcao, index| - opcao.update_columns(numero: -index) - end end - def nested_attributes_values(attributes) - return [] if attributes.blank? - return attributes.values if attributes.respond_to?(:values) - - Array(attributes) - end - - def destroy_attribute?(attributes) - ActiveModel::Type::Boolean.new.cast(attributes[:_destroy] || attributes["_destroy"]) - end - - def persisted_id_with_number(attributes) - id = attributes[:id] || attributes["id"] - numero = attributes[:numero] || attributes["numero"] - - id if id.present? && numero.present? + def update_template_or_rollback + @template.update(template_params) || raise(ActiveRecord::Rollback) end def template_params diff --git a/app/helpers/templates_helper.rb b/app/helpers/templates_helper.rb index 4420204cff..fb7c18e847 100644 --- a/app/helpers/templates_helper.rb +++ b/app/helpers/templates_helper.rb @@ -1,16 +1,10 @@ module TemplatesHelper def letra_da_opcao(numero) - return "" if numero.blank? - numero = numero.to_i - resultado = "" + return "" unless numero.positive? - while numero.positive? - numero -= 1 - resultado.prepend((97 + (numero % 26)).chr) - numero /= 26 - end + quotient, remainder = (numero - 1).divmod(26) - resultado + "#{letra_da_opcao(quotient)}#{(97 + remainder).chr}" end end diff --git a/app/models/template.rb b/app/models/template.rb index b5a243290a..0d2e902de1 100644 --- a/app/models/template.rb +++ b/app/models/template.rb @@ -70,17 +70,32 @@ def deve_ter_ao_menos_uma_questao end def utilizacao_questao_em_branco?(attributes) - return false if attributes["id"].present? - return false if attributes["questao_id"].present? + return false if persisted_or_referenced_questao?(attributes) - questao_attributes = attributes["questao_attributes"] || {} - return false if questao_attributes["enunciado"].present? + questao_attributes_em_branco?(attributes["questao_attributes"] || {}) + end + + def persisted_or_referenced_questao?(attributes) + attributes.values_at("id", "questao_id").any?(&:present?) + end - opcoes_attributes = questao_attributes["opcoes_attributes"] || {} - opcoes = opcoes_attributes.respond_to?(:values) ? opcoes_attributes.values : opcoes_attributes - opcoes.none? do |opcao_attributes| + def questao_attributes_em_branco?(attributes) + attributes["enunciado"].blank? && + opcoes_attributes_em_branco?(attributes["opcoes_attributes"]) + end + + + def opcoes_attributes_em_branco?(attributes) + nested_attributes_values(attributes).none? do |opcao_attributes| opcao_attributes["texto"].present? end end + + def nested_attributes_values(attributes) + return [] if attributes.blank? + return attributes.values if attributes.is_a?(Hash) + + Array(attributes) + end end diff --git a/app/services/templates/persisted_reordering.rb b/app/services/templates/persisted_reordering.rb new file mode 100644 index 0000000000..abad314df1 --- /dev/null +++ b/app/services/templates/persisted_reordering.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module Templates + class PersistedReordering + BOOLEAN = ActiveModel::Type::Boolean.new + + private_constant :BOOLEAN + + def self.prepare(template:, attributes:) + new(template:, attributes:).prepare + end + + def initialize(template:, attributes:) + @template = template + @utilizacoes_attributes = nested_values(attributes) + end + + def prepare + prepare_utilizacoes + prepare_opcoes + end + + private + + attr_reader :template, :utilizacoes_attributes + + def prepare_utilizacoes + UtilizacaoQuestao + .where(template_id: template.id, id: ids_for(utilizacoes_attributes)) + .find_each { |utilizacao| renumber(utilizacao) } + end + + def prepare_opcoes + Opcao + .where(id: option_ids) + .find_each { |opcao| renumber(opcao) } + end + + def option_ids + utilizacoes_attributes.flat_map do |attributes| + ids_for(option_attributes_from(attributes)) + end + end + + def option_attributes_from(attributes) + question_attributes = nested_attribute(attributes, :questao_attributes) + + nested_values(nested_attribute(question_attributes, :opcoes_attributes)) + end + + def ids_for(attributes) + attributes + .reject { |item| destroy_attribute?(item) } + .filter_map { |item| persisted_id_with_number(item) } + end + + def destroy_attribute?(attributes) + BOOLEAN.cast(nested_attribute(attributes, :_destroy)) + end + + def persisted_id_with_number(attributes) + id = nested_attribute(attributes, :id) + number = nested_attribute(attributes, :numero) + + id if id.present? && number.present? + end + + def renumber(record) + record.update_columns(numero: -record.id) + end + + def nested_attribute(attributes, key) + return if attributes.blank? + + attributes[key] || attributes[key.to_s] + end + + def nested_values(attributes) + return [] if attributes.blank? + return attributes.values if attributes.is_a?(Hash) + return attributes.values if attributes.is_a?(ActionController::Parameters) + + Array(attributes) + end + end +end From d44ae092eb72b6631acbd70b70868283c62107f5 Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Sat, 27 Jun 2026 16:33:25 -0300 Subject: [PATCH 100/125] =?UTF-8?q?docs(template):=20adiciona=20documenta?= =?UTF-8?q?=C3=A7=C3=A3o=20RDoc=20=C3=A0s=20fun=C3=A7=C3=B5es=20(e=20class?= =?UTF-8?q?es)=20associadas=20a=20templates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/controllers/templates_controller.rb | 187 ++++++++++++++++++ app/helpers/templates_helper.rb | 14 ++ app/models/template.rb | 126 +++++++++++- .../templates/persisted_reordering.rb | 152 ++++++++++++++ 4 files changed, 477 insertions(+), 2 deletions(-) diff --git a/app/controllers/templates_controller.rb b/app/controllers/templates_controller.rb index ae65425a10..73c13c23b3 100644 --- a/app/controllers/templates_controller.rb +++ b/app/controllers/templates_controller.rb @@ -1,9 +1,26 @@ # frozen_string_literal: true +# Controla as telas e operações HTTP para gerenciamento de templates. +# +# Os métodos públicos correspondem às actions Rails e coordenam autorização, +# preparação de dados para views e persistência dos templates. class TemplatesController < ApplicationController before_action :authenticate_user! before_action :set_template, only: %i[show edit update destroy] + # Lista os templates visíveis para o administrador autenticado. + # + # Argumentos: + # - Não recebe argumentos explicitamente. Usa o usuário autenticado e a + # policy de templates da requisição atual. + # + # Retorno: + # - Não usa valor de retorno próprio. A action deixa +@user_templates+ e + # +@other_templates+ disponíveis para a view. + # + # Efeitos colaterais: + # - Consulta o banco de dados. + # - Pode interromper a requisição se a autorização falhar. def index authorize! Template @@ -15,10 +32,34 @@ def index @other_templates = templates.criados_por_outros(current_administrador) end + # Exibe um template específico. + # + # Argumentos: + # - Não recebe argumentos explicitamente. Usa +params[:id]+ indiretamente + # pelo callback +set_template+. + # + # Retorno: + # - Não usa valor de retorno próprio. A action expõe +@template+ para a view. + # + # Efeitos colaterais: + # - Consulta o banco no callback +set_template+. + # - Pode interromper a requisição se a autorização falhar. def show authorize! @template end + # Prepara o formulário de criação de template. + # + # Argumentos: + # - Não recebe argumentos explicitamente. Usa o administrador autenticado. + # + # Retorno: + # - Não usa valor de retorno próprio. A action inicializa +@template+ para + # renderização do formulário. + # + # Efeitos colaterais: + # - Instancia objetos em memória para os campos aninhados. + # - Pode interromper a requisição se a autorização falhar. def new @template = Template.new(adm: current_administrador) preparar_campos_do_template @@ -26,6 +67,21 @@ def new authorize! @template end + # Cria um template com os parâmetros enviados pelo formulário. + # + # Argumentos: + # - Não recebe argumentos explicitamente. Usa +params[:template]+ e o + # administrador autenticado. + # + # Retorno: + # - Não usa valor de retorno próprio. Em caso de sucesso, redireciona para o + # template criado; em caso de erro, renderiza novamente a tela de criação. + # + # Efeitos colaterais: + # - Insere registros no banco quando o template é válido. + # - Redireciona para a página do template criado ou renderiza +new+ com + # status +unprocessable_entity+. + # - Pode interromper a requisição se a autorização falhar. def create @template = build_template @@ -40,12 +96,43 @@ def create end end + # Prepara o formulário de edição de um template existente. + # + # Argumentos: + # - Não recebe argumentos explicitamente. Usa +params[:id]+ indiretamente + # pelo callback +set_template+. + # + # Retorno: + # - Não usa valor de retorno próprio. A action prepara +@template+ para a + # view de edição. + # + # Efeitos colaterais: + # - Consulta o banco no callback +set_template+. + # - Instancia objetos em memória para campos aninhados faltantes. + # - Pode interromper a requisição se a autorização falhar. def edit preparar_campos_do_template authorize! @template end + # Atualiza um template existente e sua estrutura de questões/opções. + # + # Argumentos: + # - Não recebe argumentos explicitamente. Usa +params[:id]+ e + # +params[:template]+ da requisição. + # + # Retorno: + # - Não usa valor de retorno próprio. Redireciona quando a atualização é + # válida; renderiza +edit+ quando há erro de validação. + # + # Efeitos colaterais: + # - Atualiza registros no banco em transação. + # - Reordena temporariamente registros persistidos para evitar conflito de + # índices únicos antes do update final. + # - Redireciona para a página do template ou renderiza +edit+ com status + # +unprocessable_entity+. + # - Pode interromper a requisição se a autorização falhar. def update authorize! @template @@ -60,6 +147,20 @@ def update end end + # Remove um template existente. + # + # Argumentos: + # - Não recebe argumentos explicitamente. Usa +params[:id]+ indiretamente + # pelo callback +set_template+. + # + # Retorno: + # - Não usa valor de retorno próprio. Redireciona para a listagem de + # templates após a remoção. + # + # Efeitos colaterais: + # - Remove o template do banco de dados. + # - Redireciona para +templates_path+ com mensagem de sucesso. + # - Pode interromper a requisição se a autorização falhar. def destroy authorize! @template @@ -70,16 +171,52 @@ def destroy private + # Carrega o template informado na rota. + # + # Argumentos: + # - Não recebe argumentos explicitamente. Usa +params[:id]+. + # + # Retorno: + # - Retorna o objeto atribuído a +@template+ por convenção de atribuição Ruby. + # - Levanta exceção se o registro não existir. + # + # Efeitos colaterais: + # - Consulta o banco de dados. + # - Define a variável de instância +@template+. def set_template @template = Template.find(params[:id]) end + # Monta um novo template a partir dos parâmetros permitidos. + # + # Argumentos: + # - Não recebe argumentos explicitamente. Usa +template_params+ e + # +current_administrador+. + # + # Retorno: + # - Retorna uma instância de +Template+ ainda não persistida. + # + # Efeitos colaterais: + # - Não altera o banco de dados. + # - Associa o administrador autenticado ao objeto em memória. def build_template Template.new(template_params).tap do |template| template.adm = current_administrador end end + # Garante que o template possua campos aninhados mínimos para o formulário. + # + # Argumentos: + # - Não recebe argumentos explicitamente. Usa +@template+. + # + # Retorno: + # - Retorna a coleção iterada pelo último +each+ quando há utilizações. + # - Pode retornar uma coleção vazia ou modificada em memória. + # + # Efeitos colaterais: + # - Cria objetos associados em memória quando faltam utilizações ou questões. + # - Não persiste alterações no banco de dados. def preparar_campos_do_template utilizacoes = @template.utilizacoes_questoes utilizacoes.build(numero: 1) if utilizacoes.empty? @@ -89,6 +226,19 @@ def preparar_campos_do_template end end + # Executa a atualização do template dentro de uma transação. + # + # Argumentos: + # - Não recebe argumentos explicitamente. Usa +@template+ e os parâmetros da + # requisição. + # + # Retorno: + # - Retorna +true+ quando a transação conclui com sucesso. + # - Retorna +nil+ quando ocorre rollback por falha de validação. + # + # Efeitos colaterais: + # - Pode atualizar o banco de dados. + # - Pode reverter toda a transação quando o update falha. def atualizar_template_com_reordenacao Template.transaction do preparar_reordenacao_de_registros_persistidos @@ -96,6 +246,18 @@ def atualizar_template_com_reordenacao end end + # Prepara registros já persistidos para receber novos números de ordenação. + # + # Argumentos: + # - Não recebe argumentos explicitamente. Usa +@template+ e + # +params[:template][:utilizacoes_questoes_attributes]+. + # + # Retorno: + # - Retorna o resultado de +Templates::PersistedReordering.prepare+. + # + # Efeitos colaterais: + # - Atualiza temporariamente números de utilizações e opções persistidas no + # banco de dados. def preparar_reordenacao_de_registros_persistidos Templates::PersistedReordering.prepare( template: @template, @@ -103,10 +265,35 @@ def preparar_reordenacao_de_registros_persistidos ) end + # Atualiza o template ou interrompe a transação atual. + # + # Argumentos: + # - Não recebe argumentos explicitamente. Usa +@template+ e + # +template_params+. + # + # Retorno: + # - Retorna +true+ quando o update é bem-sucedido. + # - Levanta +ActiveRecord::Rollback+ quando o update retorna +false+. + # + # Efeitos colaterais: + # - Persiste alterações no banco quando o template é válido. + # - Dispara rollback da transação quando há erro de validação. def update_template_or_rollback @template.update(template_params) || raise(ActiveRecord::Rollback) end + # Filtra os parâmetros permitidos para criação e atualização de templates. + # + # Argumentos: + # - Não recebe argumentos explicitamente. Usa +params[:template]+. + # + # Retorno: + # - Retorna um objeto +ActionController::Parameters+ permitido, contendo os + # atributos aceitos para template, utilizações, questões e opções. + # + # Efeitos colaterais: + # - Não altera o banco de dados. + # - Levanta exceção se a chave obrigatória +:template+ não existir. def template_params params .require(:template) diff --git a/app/helpers/templates_helper.rb b/app/helpers/templates_helper.rb index fb7c18e847..01a2240f87 100644 --- a/app/helpers/templates_helper.rb +++ b/app/helpers/templates_helper.rb @@ -1,4 +1,18 @@ +# Helper de apresentação para as telas de templates. module TemplatesHelper + # Converte o número de uma opção em rótulo alfabético. + # + # Argumentos: + # - +numero+: número da opção. Pode ser inteiro, string numérica ou valor em + # branco. + # + # Retorno: + # - Retorna uma string vazia quando +numero+ não representa valor positivo. + # - Retorna letras minúsculas no estilo de planilha para números positivos, + # como +"a"+, +"b"+, +"z"+, +"aa"+. + # + # Efeitos colaterais: + # - Não altera estado nem persiste dados. def letra_da_opcao(numero) numero = numero.to_i return "" unless numero.positive? diff --git a/app/models/template.rb b/app/models/template.rb index 0d2e902de1..e17b54a95d 100644 --- a/app/models/template.rb +++ b/app/models/template.rb @@ -1,5 +1,10 @@ # frozen_string_literal: true +# Representa um modelo reutilizável de formulário de avaliação. +# +# Um template pertence a um administrador, possui utilizações de questões e pode +# ser usado como base para criar formulários com cópias independentes das +# questões. class Template < ApplicationRecord belongs_to :adm, class_name: "PerfilAdm", @@ -44,24 +49,83 @@ class Template < ApplicationRecord scope :criados_por, ->(adm) { where(adm_id: adm&.id) } scope :criados_por_outros, ->(adm) { where.not(adm_id: adm&.id) } + # Verifica se o template foi criado pelo administrador informado. + # + # Argumentos: + # - +perfil_adm+: perfil administrativo usado na comparação de autoria. + # + # Retorno: + # - Retorna +true+ quando +perfil_adm+ existe e seu id é igual a +adm_id+. + # - Retorna +false+ quando +perfil_adm+ é nulo ou pertence a outro + # administrador. + # + # Efeitos colaterais: + # - Não altera estado em memória nem persiste dados no banco. def criado_por?(perfil_adm) perfil_adm.present? && adm_id == perfil_adm.id end + # Retorna as questões do template na ordem configurada. + # + # Argumentos: + # - Não recebe argumentos. + # + # Retorno: + # - Retorna uma relação de +UtilizacaoQuestao+ com +questao+ e +opcoes+ + # pré-carregadas e ordenadas. + # + # Efeitos colaterais: + # - Pode consultar o banco de dados ao materializar a relação. + # - Não altera registros. def questoes_ordenadas utilizacoes_questoes.includes(:questao, :opcoes).ordenadas end private + # Remove espaços extras do título antes da validação. + # + # Argumentos: + # - Não recebe argumentos. + # + # Retorno: + # - Retorna a string normalizada quando há título presente. + # - Retorna +nil+ quando o título está em branco. + # + # Efeitos colaterais: + # - Altera o atributo +titulo+ do objeto em memória. + # - Não persiste a alteração no banco por si só. def normalizar_titulo self.titulo = titulo.to_s.strip if titulo.present? end + # Preenche a data de criação do template quando ela ainda não existe. + # + # Argumentos: + # - Não recebe argumentos. + # + # Retorno: + # - Retorna o valor final de +criado_em+. + # + # Efeitos colaterais: + # - Pode alterar o atributo +criado_em+ do objeto em memória. + # - Não persiste a alteração no banco por si só. def preencher_criado_em self.criado_em ||= Time.current end + # Valida se o template mantém ao menos uma questão não removida. + # + # Argumentos: + # - Não recebe argumentos. + # + # Retorno: + # - Retorna +nil+ quando há pelo menos uma utilização válida. + # - Retorna o objeto de erros quando adiciona uma mensagem de validação. + # + # Efeitos colaterais: + # - Pode adicionar erro em +errors[:utilizacoes_questoes]+. + # - Não altera o banco de dados. def deve_ter_ao_menos_uma_questao questoes_validas = utilizacoes_questoes.reject(&:marked_for_destruction?) return if questoes_validas.any? @@ -69,29 +133,87 @@ def deve_ter_ao_menos_uma_questao errors.add(:utilizacoes_questoes, "deve conter ao menos uma questão") end + # Decide se uma utilização de questão aninhada deve ser ignorada. + # + # Argumentos: + # - +attributes+: hash de atributos recebido pelo nested attributes da + # utilização de questão. + # + # Retorno: + # - Retorna +true+ quando a utilização é nova e não possui questão, + # enunciado ou opções preenchidas. + # - Retorna +false+ quando há id persistido, questão referenciada ou conteúdo + # preenchido. + # + # Efeitos colaterais: + # - Não altera estado em memória nem persiste dados no banco. def utilizacao_questao_em_branco?(attributes) return false if persisted_or_referenced_questao?(attributes) questao_attributes_em_branco?(attributes["questao_attributes"] || {}) end + # Verifica se os atributos apontam para uma utilização ou questão existente. + # + # Argumentos: + # - +attributes+: hash de atributos da utilização de questão. + # + # Retorno: + # - Retorna +true+ quando há +"id"+ ou +"questao_id"+ preenchido. + # - Retorna +false+ quando ambos estão ausentes ou em branco. + # + # Efeitos colaterais: + # - Não altera estado em memória nem persiste dados no banco. def persisted_or_referenced_questao?(attributes) attributes.values_at("id", "questao_id").any?(&:present?) end - + # Verifica se os atributos aninhados da questão estão em branco. + # + # Argumentos: + # - +attributes+: hash de atributos da questão aninhada. + # + # Retorno: + # - Retorna +true+ quando o enunciado está em branco e nenhuma opção possui + # texto. + # - Retorna +false+ quando há enunciado ou alguma opção preenchida. + # + # Efeitos colaterais: + # - Não altera estado em memória nem persiste dados no banco. def questao_attributes_em_branco?(attributes) attributes["enunciado"].blank? && opcoes_attributes_em_branco?(attributes["opcoes_attributes"]) end - + # Verifica se todas as opções aninhadas estão em branco. + # + # Argumentos: + # - +attributes+: hash, array ou valor nulo com atributos de opções. + # + # Retorno: + # - Retorna +true+ quando nenhuma opção possui +"texto"+ preenchido. + # - Retorna +false+ quando ao menos uma opção possui texto. + # + # Efeitos colaterais: + # - Não altera estado em memória nem persiste dados no banco. def opcoes_attributes_em_branco?(attributes) nested_attributes_values(attributes).none? do |opcao_attributes| opcao_attributes["texto"].present? end end + # Normaliza atributos aninhados para uma lista iterável. + # + # Argumentos: + # - +attributes+: coleção de atributos em formato hash, array ou valor nulo. + # + # Retorno: + # - Retorna +[]+ quando +attributes+ está em branco. + # - Retorna +attributes.values+ quando recebe hash. + # - Retorna +Array(attributes)+ nos demais casos. + # + # Efeitos colaterais: + # - Não altera estado em memória nem persiste dados no banco. def nested_attributes_values(attributes) return [] if attributes.blank? return attributes.values if attributes.is_a?(Hash) diff --git a/app/services/templates/persisted_reordering.rb b/app/services/templates/persisted_reordering.rb index abad314df1..8cbbcb97f5 100644 --- a/app/services/templates/persisted_reordering.rb +++ b/app/services/templates/persisted_reordering.rb @@ -1,20 +1,61 @@ # frozen_string_literal: true module Templates + # Prepara utilizações e opções persistidas para reordenação segura. + # + # A classe coloca números temporários negativos em registros já existentes + # antes do update final de nested attributes, evitando colisões com índices + # únicos de ordenação. class PersistedReordering BOOLEAN = ActiveModel::Type::Boolean.new private_constant :BOOLEAN + # Executa a preparação de reordenação para um template. + # + # Argumentos: + # - +template+: template que está sendo atualizado. + # - +attributes+: atributos aninhados de utilizações de questões recebidos + # do formulário. + # + # Retorno: + # - Retorna o resultado de +prepare+. + # + # Efeitos colaterais: + # - Atualiza diretamente o campo +numero+ de utilizações e opções + # persistidas. + # - Não cria nem remove registros. def self.prepare(template:, attributes:) new(template:, attributes:).prepare end + # Inicializa a preparação com o template e os atributos do formulário. + # + # Argumentos: + # - +template+: template em edição. + # - +attributes+: hash, parâmetros ou array de atributos aninhados. + # + # Retorno: + # - Retorna a instância inicializada. + # + # Efeitos colaterais: + # - Normaliza atributos em memória. + # - Não consulta nem altera o banco de dados. def initialize(template:, attributes:) @template = template @utilizacoes_attributes = nested_values(attributes) end + # Prepara utilizações de questões e opções para a reordenação. + # + # Argumentos: + # - Não recebe argumentos. Usa dados fornecidos no inicializador. + # + # Retorno: + # - Retorna o resultado de +prepare_opcoes+, a última operação executada. + # + # Efeitos colaterais: + # - Atualiza números temporários de utilizações e opções no banco. def prepare prepare_utilizacoes prepare_opcoes @@ -24,40 +65,115 @@ def prepare attr_reader :template, :utilizacoes_attributes + # Atualiza temporariamente o número das utilizações persistidas. + # + # Argumentos: + # - Não recebe argumentos. Usa +template+ e +utilizacoes_attributes+. + # + # Retorno: + # - Retorna a relação processada por +find_each+. + # + # Efeitos colaterais: + # - Atualiza diretamente +numero+ em registros de +UtilizacaoQuestao+. def prepare_utilizacoes UtilizacaoQuestao .where(template_id: template.id, id: ids_for(utilizacoes_attributes)) .find_each { |utilizacao| renumber(utilizacao) } end + # Atualiza temporariamente o número das opções persistidas. + # + # Argumentos: + # - Não recebe argumentos. Usa os ids calculados por +option_ids+. + # + # Retorno: + # - Retorna a relação processada por +find_each+. + # + # Efeitos colaterais: + # - Atualiza diretamente +numero+ em registros de +Opcao+. def prepare_opcoes Opcao .where(id: option_ids) .find_each { |opcao| renumber(opcao) } end + # Coleta ids de opções persistidas que serão reordenadas. + # + # Argumentos: + # - Não recebe argumentos. Usa +utilizacoes_attributes+. + # + # Retorno: + # - Retorna um array com ids de opções que possuem id e número. + # + # Efeitos colaterais: + # - Não consulta nem altera o banco de dados. def option_ids utilizacoes_attributes.flat_map do |attributes| ids_for(option_attributes_from(attributes)) end end + # Extrai os atributos de opções de uma utilização de questão. + # + # Argumentos: + # - +attributes+: atributos de uma utilização de questão. + # + # Retorno: + # - Retorna uma lista normalizada de atributos de opções. + # - Retorna +[]+ quando não há opções aninhadas. + # + # Efeitos colaterais: + # - Não consulta nem altera o banco de dados. def option_attributes_from(attributes) question_attributes = nested_attribute(attributes, :questao_attributes) nested_values(nested_attribute(question_attributes, :opcoes_attributes)) end + # Seleciona ids persistidos entre atributos aninhados. + # + # Argumentos: + # - +attributes+: lista de hashes/parâmetros de atributos aninhados. + # + # Retorno: + # - Retorna ids de itens não marcados para destruição que possuem id e + # número. + # + # Efeitos colaterais: + # - Não consulta nem altera o banco de dados. def ids_for(attributes) attributes .reject { |item| destroy_attribute?(item) } .filter_map { |item| persisted_id_with_number(item) } end + # Verifica se um item aninhado está marcado para destruição. + # + # Argumentos: + # - +attributes+: atributos do item aninhado. + # + # Retorno: + # - Retorna +true+ quando +:_destroy+ ou +"_destroy"+ representa valor + # verdadeiro. + # - Retorna +false+ nos demais casos. + # + # Efeitos colaterais: + # - Não consulta nem altera o banco de dados. def destroy_attribute?(attributes) BOOLEAN.cast(nested_attribute(attributes, :_destroy)) end + # Retorna o id de um item persistido que possui número enviado. + # + # Argumentos: + # - +attributes+: atributos do item aninhado. + # + # Retorno: + # - Retorna o id quando há id e número presentes. + # - Retorna +nil+ quando algum desses campos está ausente. + # + # Efeitos colaterais: + # - Não consulta nem altera o banco de dados. def persisted_id_with_number(attributes) id = nested_attribute(attributes, :id) number = nested_attribute(attributes, :numero) @@ -65,16 +181,52 @@ def persisted_id_with_number(attributes) id if id.present? && number.present? end + # Atribui um número temporário negativo a um registro. + # + # Argumentos: + # - +record+: registro ActiveRecord com coluna +numero+ e id persistido. + # + # Retorno: + # - Retorna o resultado de +update_columns+. + # + # Efeitos colaterais: + # - Atualiza diretamente a coluna +numero+ no banco de dados. + # - Ignora validações e callbacks por usar +update_columns+. def renumber(record) record.update_columns(numero: -record.id) end + # Lê um atributo aceitando chaves símbolo ou string. + # + # Argumentos: + # - +attributes+: hash ou parâmetros de origem. + # - +key+: chave desejada em formato símbolo. + # + # Retorno: + # - Retorna o valor encontrado pela chave símbolo ou string. + # - Retorna +nil+ quando os atributos estão em branco ou a chave não existe. + # + # Efeitos colaterais: + # - Não consulta nem altera o banco de dados. def nested_attribute(attributes, key) return if attributes.blank? attributes[key] || attributes[key.to_s] end + # Normaliza atributos aninhados para uma lista iterável. + # + # Argumentos: + # - +attributes+: valor nulo, hash, +ActionController::Parameters+ ou + # coleção de atributos. + # + # Retorno: + # - Retorna +[]+ quando +attributes+ está em branco. + # - Retorna os valores quando recebe hash ou parâmetros. + # - Retorna +Array(attributes)+ nos demais casos. + # + # Efeitos colaterais: + # - Não consulta nem altera o banco de dados. def nested_values(attributes) return [] if attributes.blank? return attributes.values if attributes.is_a?(Hash) From 2e11fb97f849e97aa79770c4171addfd24c4a197 Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Sat, 27 Jun 2026 16:41:14 -0300 Subject: [PATCH 101/125] =?UTF-8?q?fix(templates):=20corrige=20a=20exibi?= =?UTF-8?q?=C3=A7=C3=A3o=20de=20mensagens=20de=20erro?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/views/templates/_form.html.erb | 47 ++++++++-- app/views/templates/_question_fields.html.erb | 92 ++++++++++++++----- 2 files changed, 105 insertions(+), 34 deletions(-) diff --git a/app/views/templates/_form.html.erb b/app/views/templates/_form.html.erb index 7331a17548..cecef362a6 100644 --- a/app/views/templates/_form.html.erb +++ b/app/views/templates/_form.html.erb @@ -6,13 +6,40 @@ data: { controller: "template-form", action: "submit->template-form#renumberAll" } ) do |f| %> <% if template.errors.any? %> -
    -

    Erros:

    -
      - <% template.errors.full_messages.each do |msg| %> -
    • <%= msg %>
    • - <% end %> -
    +
    + +
    + + +
    +

    Não foi possível salvar

    +

    Revise os campos abaixo e tente novamente:

    + +
      + <% template.errors.full_messages.each do |msg| %> +
    • <%= msg %>
    • + <% end %> +
    +
    + + +
    + +
    + +
    +
    <% end %> @@ -24,12 +51,12 @@ @@ -77,7 +104,7 @@ <% end %> -
    +
    <%= link_to "Voltar aos templates", templates_path, class: "app-button app-button--secondary" %> <%= f.submit( template.persisted? ? "Atualizar template" : "Criar template", diff --git a/app/views/templates/_question_fields.html.erb b/app/views/templates/_question_fields.html.erb index 53fbe0eb68..2a05491092 100644 --- a/app/views/templates/_question_fields.html.erb +++ b/app/views/templates/_question_fields.html.erb @@ -1,20 +1,19 @@ <% utilizacao_persistida = edicao && utilizacao_form.object.persisted? %> -
    +
    > <% if utilizacao_persistida %> <%= utilizacao_form.hidden_field :id, data: { template_form_utilizacao_id: true } %> - <%= utilizacao_form.hidden_field :_destroy, value: "0", data: { template_form_question_destroy: true } %> + <%= utilizacao_form.hidden_field :_destroy, value: utilizacao_form.object.marked_for_destruction? ? "1" : "0", data: { template_form_question_destroy: true } %> <% end %> <%= utilizacao_form.hidden_field :parent_id %> <%= utilizacao_form.hidden_field :numero, data: { template_form_question_number: true } %>
    -
    -
    - Questão -
    +
    + Questão +
    <%= utilizacao_form.fields_for :questao do |questao_form| %> <% questao_persistida = edicao && questao_form.object.persisted? %> @@ -23,39 +22,84 @@ <%= questao_form.hidden_field :id, data: { template_form_questao_id: true } %> <% end %> -
    + <% tipo_menu_id = "template-question-type-menu-#{utilizacao_form.index}" %> + <% tipos_de_questao = [ + ["Tipo de questão", ""], + ["Discursiva", "discursiva"], + ["Objetiva", "objetiva"] + ] %> + <% tipo_atual = tipos_de_questao.find { |_, value| value == questao_form.object.tipo.to_s } %> + +
    <%= questao_form.select( - :tipo, - [["Discursiva", "discursiva"], ["Objetiva", "objetiva"]], + :tipo, + tipos_de_questao.drop(1), { prompt: "Tipo de questão" }, + class: "app-select__native", data: { action: " - click->dropdown#toggle - blur->dropdown#close + change->dropdown#syncLabel change->template-question#toggleOptions + change->template-form#ensureObjectiveOptions ", + dropdown_target: "native", template_question_target: "tipo" }, required: true, aria: { label: "Tipo de questão" } ) %> - + + + +
    -
    <%= questao_form.label :enunciado %> - <%= questao_form.text_area :enunciado, rows: 3 %> + <%= questao_form.text_area :enunciado, rows: 3, class: "app-control" %>
    <% opcoes_visiveis = questao_form.object.objetiva? || questao_form.object.opcoes.any? %> @@ -70,7 +114,7 @@ <%= render "option_fields", opcao_form: opcao_form, edicao: edicao %> <% end %>
    - + <%= button_tag( type: "button", class: "template-form__icon-button template-form__add-option-button", From 96dde3f71850a45bec1307f3f628450d6cf281b0 Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Sat, 27 Jun 2026 16:42:23 -0300 Subject: [PATCH 102/125] docs(templates): adiciona RDoc da policy e service de templates --- app/policies/template_policy.rb | 115 +++++++ .../formularios/create_from_template.rb | 281 ++++++++++++++---- 2 files changed, 346 insertions(+), 50 deletions(-) diff --git a/app/policies/template_policy.rb b/app/policies/template_policy.rb index d782afd830..d15bf3fb2b 100644 --- a/app/policies/template_policy.rb +++ b/app/policies/template_policy.rb @@ -1,7 +1,25 @@ # frozen_string_literal: true +# Define as permissões de acesso para templates. +# +# A política permite que administradores vejam templates e restringe alterações +# destrutivas ao administrador dono do template. class TemplatePolicy < ApplicationPolicy + # Define quais templates ficam visíveis para o usuário atual. class Scope < ApplicationPolicy::Scope + # Resolve o escopo de templates autorizado. + # + # Argumentos: + # - Não recebe argumentos explicitamente. Usa +usuario+ e +scope+ + # fornecidos ao construir o escopo da policy. + # + # Retorno: + # - Retorna +scope.all+ para administradores. + # - Retorna +scope.none+ para usuários não administradores ou anônimos. + # + # Efeitos colaterais: + # - Não altera o banco de dados. + # - Pode gerar consulta ao banco quando a relação retornada for avaliada. def resolve return scope.all if usuario&.administrador? @@ -9,40 +27,137 @@ def resolve end end + # Autoriza acesso à listagem de templates. + # + # Argumentos: + # - Não recebe argumentos. + # + # Retorno: + # - Retorna +true+ para administradores. + # - Retorna +false+ para demais usuários. + # + # Efeitos colaterais: + # - Não altera estado nem persiste dados. def index? administrador? end + # Autoriza visualização de um template. + # + # Argumentos: + # - Não recebe argumentos. + # + # Retorno: + # - Retorna +true+ para administradores. + # - Retorna +false+ para demais usuários. + # + # Efeitos colaterais: + # - Não altera estado nem persiste dados. def show? administrador? end + # Autoriza acesso à tela de criação de template. + # + # Argumentos: + # - Não recebe argumentos. + # + # Retorno: + # - Retorna o mesmo resultado de +create?+. + # + # Efeitos colaterais: + # - Não altera estado nem persiste dados. def new? create? end + # Autoriza criação de templates. + # + # Argumentos: + # - Não recebe argumentos. + # + # Retorno: + # - Retorna +true+ para administradores. + # - Retorna +false+ para demais usuários. + # + # Efeitos colaterais: + # - Não altera estado nem persiste dados. def create? administrador? end + # Autoriza acesso à tela de edição de template. + # + # Argumentos: + # - Não recebe argumentos. + # + # Retorno: + # - Retorna o mesmo resultado de +update?+. + # + # Efeitos colaterais: + # - Não altera estado nem persiste dados. def edit? update? end + # Autoriza alteração de um template. + # + # Argumentos: + # - Não recebe argumentos. Usa +record+ e o usuário da policy. + # + # Retorno: + # - Retorna +true+ quando o usuário é administrador e dono do template. + # - Retorna +false+ nos demais casos. + # + # Efeitos colaterais: + # - Não altera estado nem persiste dados. def update? dono_do_template? end + # Autoriza exclusão de um template. + # + # Argumentos: + # - Não recebe argumentos. Usa +record+ e o usuário da policy. + # + # Retorno: + # - Retorna +true+ quando o usuário é administrador e dono do template. + # - Retorna +false+ nos demais casos. + # + # Efeitos colaterais: + # - Não altera estado nem persiste dados. def destroy? dono_do_template? end + # Autoriza o uso de um template para preparar formulário. + # + # Argumentos: + # - Não recebe argumentos. + # + # Retorno: + # - Retorna +true+ para administradores. + # - Retorna +false+ para demais usuários. + # + # Efeitos colaterais: + # - Não altera estado nem persiste dados. def use? administrador? end private + # Verifica se o usuário atual é dono do template avaliado. + # + # Argumentos: + # - Não recebe argumentos. Usa +record+ e +current_administrador+. + # + # Retorno: + # - Retorna +true+ quando o usuário é administrador e criou o template. + # - Retorna +false+ nos demais casos. + # + # Efeitos colaterais: + # - Não altera estado nem persiste dados. def dono_do_template? administrador? && record.criado_por?(current_administrador) end diff --git a/app/services/formularios/create_from_template.rb b/app/services/formularios/create_from_template.rb index 011ec17dd7..cc09352526 100644 --- a/app/services/formularios/create_from_template.rb +++ b/app/services/formularios/create_from_template.rb @@ -1,4 +1,8 @@ module Formularios + # Cria formulários a partir de um template e de uma lista de turmas. + # + # A classe valida o template, as turmas e o público-alvo antes de criar os + # formulários, copiar as questões como snapshot e gerar avaliações pendentes. class CreateFromTemplate SEM_TURMAS = "É necessário selecionar pelo menos uma turma" SEM_QUESTOES = "O template deve possuir pelo menos uma questão" @@ -6,14 +10,59 @@ class CreateFromTemplate TURMA_COM_FORMULARIO = "Uma ou mais turmas selecionadas já possuem formulário" SEM_PUBLICO_ALVO = "Por favor, selecione o público-alvo do formulário" + # Executa a criação de formulários a partir de um template. + # + # Argumentos: + # - +template_id+: id do template usado como origem. + # - +turma_ids+: lista de ids das turmas que receberão formulário. + # - +publico_alvo+: público que deve responder ao formulário. + # - +perfil_adm+: administrador responsável pela criação. + # + # Retorno: + # - Retorna uma lista de formulários criados. + # - Levanta +Formularios::Error+ quando alguma validação de negócio falha. + # + # Efeitos colaterais: + # - Insere formulários no banco de dados. + # - Copia questões e opções do template para cada formulário. + # - Cria avaliações pendentes para o público-alvo selecionado. def self.call(template_id:, turma_ids:, publico_alvo:, perfil_adm:) new(template_id:, turma_ids:, publico_alvo:, perfil_adm:).call end - def self.validate_preparacao!(template_id:, turma_ids:) - new(template_id:, turma_ids:, publico_alvo: :docentes, perfil_adm: nil).validate_preparacao! + # Valida se o template e as turmas permitem preparar a publicação. + # + # Argumentos: + # - +template_id+: id do template que será usado. + # - +turma_ids+: lista de ids das turmas selecionadas. + # - +perfil_adm+: administrador que tenta preparar os formulários. + # + # Retorno: + # - Retorna +nil+ quando todas as validações passam. + # - Levanta +Formularios::Error+ quando alguma validação falha. + # + # Efeitos colaterais: + # - Consulta o banco de dados. + # - Não cria nem altera registros. + def self.validate_preparacao!(template_id:, turma_ids:, perfil_adm:) + new(template_id:, turma_ids:, publico_alvo: :docentes, perfil_adm:).validate_preparacao! end + # Inicializa o serviço com os dados necessários para criar formulários. + # + # Argumentos: + # - +template_id+: id do template de origem. + # - +turma_ids+: ids das turmas selecionadas; valores duplicados e zero são + # normalizados. + # - +publico_alvo+: público-alvo escolhido para os formulários. + # - +perfil_adm+: administrador que está executando a ação. + # + # Retorno: + # - Retorna a instância inicializada. + # + # Efeitos colaterais: + # - Normaliza +turma_ids+ em memória. + # - Não consulta nem altera o banco de dados. def initialize(template_id:, turma_ids:, publico_alvo:, perfil_adm:) @template_id = template_id @turma_ids = Array(turma_ids).map(&:to_i).uniq.reject(&:zero?) @@ -21,85 +70,217 @@ def initialize(template_id:, turma_ids:, publico_alvo:, perfil_adm:) @perfil_adm = perfil_adm end + # Valida os pré-requisitos para publicar formulários a partir do template. + # + # Argumentos: + # - Não recebe argumentos. Usa os dados recebidos no inicializador. + # + # Retorno: + # - Retorna +nil+ quando todas as validações passam. + # - Levanta +Formularios::Error+ em caso de template/turmas inválidos. + # + # Efeitos colaterais: + # - Consulta o banco de dados. + # - Não cria nem altera registros. def validate_preparacao! - validate_turma_ids! - validate_template! - validate_turmas! - validate_turmas_sem_formulario! + validate_turma_ids + validate_template + validate_turmas + validate_turmas_sem_formulario end + # Executa a criação completa dos formulários. + # + # Argumentos: + # - Não recebe argumentos. Usa os dados recebidos no inicializador. + # + # Retorno: + # - Retorna uma lista de formulários criados. + # - Levanta +Formularios::Error+ ou exceções ActiveRecord se algo falhar. + # + # Efeitos colaterais: + # - Cria formulários, questões copiadas, opções copiadas e avaliações + # pendentes no banco de dados. + # - Executa as alterações dentro de transação. def call validate_preparacao! - validate_publico_alvo! + validate_publico_alvo - formularios = [] - - ActiveRecord::Base.transaction do - turmas.each do |turma| - formulario = Formulario.create!( - adm: perfil_adm, - template: template, - turma: turma, - publico_alvo: publico_alvo - ) - - copy_questoes_from_template!(formulario) - formulario.criar_avaliacoes_pendentes! - formularios << formulario - end - end - - formularios + create_formularios end private attr_reader :template_id, :turma_ids, :publico_alvo, :perfil_adm - def validate_turma_ids! + # Cria um formulário para cada turma selecionada. + # + # Argumentos: + # - Não recebe argumentos. Usa +turmas+ e os demais dados do serviço. + # + # Retorno: + # - Retorna uma lista de formulários criados. + # + # Efeitos colaterais: + # - Insere formulários e seus dados derivados dentro de uma transação. + # - Reverte tudo se alguma criação falhar. + def create_formularios + ActiveRecord::Base.transaction do + turmas.map { |turma| create_formulario(turma) } + end + end + + # Cria um formulário para uma turma específica. + # + # Argumentos: + # - +turma+: turma que receberá o formulário. + # + # Retorno: + # - Retorna o formulário criado. + # + # Efeitos colaterais: + # - Insere um formulário no banco. + # - Copia questões/opções do template para o formulário. + # - Cria avaliações pendentes para a turma. + def create_formulario(turma) + Formulario.create!( + adm: perfil_adm, + template: template, + turma: turma, + publico_alvo: publico_alvo + ).tap do |formulario| + TemplateQuestionSnapshot.copy(template: template, formulario: formulario) + formulario.criar_avaliacoes_pendentes! + end + end + + # Valida se há pelo menos uma turma selecionada. + # + # Argumentos: + # - Não recebe argumentos. Usa +turma_ids+. + # + # Retorno: + # - Retorna +nil+ quando há turmas. + # - Levanta +Formularios::Error+ quando a lista está vazia. + # + # Efeitos colaterais: + # - Não consulta nem altera o banco de dados. + def validate_turma_ids raise Error, SEM_TURMAS if turma_ids.empty? end - def validate_template! + # Valida se o template possui questões publicáveis. + # + # Argumentos: + # - Não recebe argumentos. Usa o template carregado pelo serviço. + # + # Retorno: + # - Retorna +nil+ quando o template possui questões raiz. + # - Levanta +Formularios::Error+ quando não há questões. + # + # Efeitos colaterais: + # - Consulta o banco de dados. + # - Não altera registros. + def validate_template raise Error, SEM_QUESTOES if template.utilizacoes_questoes.raizes.none? end - def validate_turmas! + # Valida se todas as turmas informadas existem no escopo do administrador. + # + # Argumentos: + # - Não recebe argumentos. Usa +turma_ids+ e +turmas+. + # + # Retorno: + # - Retorna +nil+ quando todas as turmas são válidas. + # - Levanta +Formularios::Error+ quando alguma turma não é encontrada. + # + # Efeitos colaterais: + # - Consulta o banco de dados. + # - Não altera registros. + def validate_turmas raise Error, TURMAS_INVALIDAS if turmas.count != turma_ids.size end - def validate_turmas_sem_formulario! + # Valida se as turmas selecionadas ainda não possuem formulário. + # + # Argumentos: + # - Não recebe argumentos. Usa +turmas+. + # + # Retorno: + # - Retorna +nil+ quando nenhuma turma possui formulário. + # - Levanta +Formularios::Error+ quando encontra formulário existente. + # + # Efeitos colaterais: + # - Consulta o banco de dados. + # - Não altera registros. + def validate_turmas_sem_formulario raise Error, TURMA_COM_FORMULARIO if turmas.joins(:formularios).exists? end - def validate_publico_alvo! - raise Error, SEM_PUBLICO_ALVO if publico_alvo.blank? - raise Error, SEM_PUBLICO_ALVO unless Formulario.publico_alvos.key?(publico_alvo.to_s) + # Valida se o público-alvo foi informado e é aceito pelo modelo. + # + # Argumentos: + # - Não recebe argumentos. Usa +publico_alvo+. + # + # Retorno: + # - Retorna +nil+ quando o público-alvo é válido. + # - Levanta +Formularios::Error+ quando está em branco ou é inválido. + # + # Efeitos colaterais: + # - Não altera registros. + def validate_publico_alvo + raise Error, SEM_PUBLICO_ALVO unless publico_alvo_valido? + end + + # Informa se o público-alvo é válido para formulários. + # + # Argumentos: + # - Não recebe argumentos. Usa +publico_alvo+. + # + # Retorno: + # - Retorna +true+ quando o público-alvo está presente e existe no enum de + # +Formulario+. + # - Retorna +false+ quando está em branco ou não é reconhecido. + # + # Efeitos colaterais: + # - Não consulta nem altera o banco de dados. + def publico_alvo_valido? + publico_alvo.present? && Formulario.publico_alvos.key?(publico_alvo.to_s) end + # Carrega o template de origem. + # + # Argumentos: + # - Não recebe argumentos. Usa +template_id+. + # + # Retorno: + # - Retorna o template encontrado e memoizado. + # - Levanta exceção se o template não existir. + # + # Efeitos colaterais: + # - Consulta o banco de dados na primeira chamada. + # - Memoiza o resultado em +@template+. def template @template ||= Template.find(template_id) end + # Carrega as turmas válidas para o administrador e semestre atual. + # + # Argumentos: + # - Não recebe argumentos. Usa +perfil_adm+ e +turma_ids+. + # + # Retorno: + # - Retorna uma relação de +Turma+ filtrada por departamento, semestre e + # ids selecionados. + # + # Efeitos colaterais: + # - Pode consultar o banco de dados quando a relação é avaliada. + # - Memoiza a relação em +@turmas+. def turmas - @turmas ||= Turma.do_semestre_atual.where(id: turma_ids) - end - - def copy_questoes_from_template!(formulario) - template.utilizacoes_questoes.raizes.ordenadas.each do |utilizacao| - questao_origem = utilizacao.questao - - questao = formulario.questoes.build( - enunciado: questao_origem.enunciado, - tipo: questao_origem.tipo - ) - - questao_origem.opcoes.ordenadas.each do |opcao| - questao.opcoes.build(numero: opcao.numero, texto: opcao.texto) - end - - questao.save! - end + @turmas ||= Turma + .do_departamento(perfil_adm.departamento) + .do_semestre_atual + .where(id: turma_ids) end end end From a9671a9703687c5dee76bdea71b22de4b74e3da1 Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Sat, 27 Jun 2026 16:43:39 -0300 Subject: [PATCH 103/125] =?UTF-8?q?fix(templates):=20corrige=20as=20classe?= =?UTF-8?q?s=20CSS=20usadas=20para=20ficar=20de=20acordo=20com=20o=20estad?= =?UTF-8?q?o=20atual=20da=20aplica=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/views/templates/_card.html.erb | 2 +- app/views/templates/_option_fields.html.erb | 11 ++++++++--- app/views/templates/index.html.erb | 10 +++++----- app/views/templates/show.html.erb | 2 +- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/app/views/templates/_card.html.erb b/app/views/templates/_card.html.erb index 738fcde054..721535c7e0 100644 --- a/app/views/templates/_card.html.erb +++ b/app/views/templates/_card.html.erb @@ -1,5 +1,5 @@
    " + class="app-card template-form__card<%= " template-form__card--removable" if removable %>" data-template-id="<%= template.id %>"> <%= link_to template_path(template), class: "template-form__card-link" do %>
    diff --git a/app/views/templates/_option_fields.html.erb b/app/views/templates/_option_fields.html.erb index ecb11e3d3e..7d3994e485 100644 --- a/app/views/templates/_option_fields.html.erb +++ b/app/views/templates/_option_fields.html.erb @@ -1,9 +1,9 @@ <% opcao_persistida = edicao && opcao_form.object.persisted? %> -
    +
    > <% if opcao_persistida %> <%= opcao_form.hidden_field :id, data: { template_form_opcao_id: true } %> - <%= opcao_form.hidden_field :_destroy, value: "0", data: { template_form_option_destroy: true } %> + <%= opcao_form.hidden_field :_destroy, value: opcao_form.object.marked_for_destruction? ? "1" : "0", data: { template_form_option_destroy: true } %> <% end %> <%= opcao_form.hidden_field :numero, data: { template_form_option_number: true } %> @@ -15,7 +15,12 @@ - <%= opcao_form.text_field :texto %> + <%= opcao_form.text_field( + :texto, + class: "app-control", + placeholder: "Opção #{opcao_form.object.numero || 1}", + data: { template_form_option_text: true } + ) %>
    diff --git a/app/views/templates/index.html.erb b/app/views/templates/index.html.erb index 52bc61e667..1a6d2a26bb 100644 --- a/app/views/templates/index.html.erb +++ b/app/views/templates/index.html.erb @@ -9,18 +9,18 @@

    Meus templates

    <% if @user_templates.any? %> -
    +
    <% @user_templates.each do |t| %> <%= render "card", template: t, removable: true %> <% end %> - <%= link_to new_template_path, class: "template-form__add-template-button", title: "Criar novo template", aria: { label: "Criar novo template" } do %> + <%= link_to new_template_path, class: "app-card app-card--create template-form__add-template-button", title: "Criar novo template", aria: { label: "Criar novo template" } do %> <%= image_tag "icons/plus.svg", alt: "", width: 24, height: 24 %> <% end %>
    <% else %> -
    +

    Nenhum template foi encontrado

    - <%= link_to new_template_path, class: "template-form__add-template-button", title: "Criar novo template", aria: { label: "Criar novo template" } do %> + <%= link_to new_template_path, class: "app-floating-action app-floating-action--inline template-form__floating-action", title: "Criar novo template", aria: { label: "Criar novo template" } do %> <%= image_tag "icons/plus.svg", alt: "", width: 24, height: 24 %> <% end %>
    @@ -30,7 +30,7 @@

    Outros Templates

    <% if @other_templates.any? %> -
    +
    <% @other_templates.each do |t| %> <%= render "card", template: t, removable: false %> <% end %> diff --git a/app/views/templates/show.html.erb b/app/views/templates/show.html.erb index 75ab157013..daca3e8295 100644 --- a/app/views/templates/show.html.erb +++ b/app/views/templates/show.html.erb @@ -45,7 +45,7 @@ <% end %>
    -
    +
    <%= link_to "Voltar aos templates", templates_path, class: "app-button app-button--secondary" %> <%= link_to "Editar", edit_template_path(@template), class: "app-button" %>
    From 789713b23094c0203a04985c18c7a37b1b078bf2 Mon Sep 17 00:00:00 2001 From: Henrique-zoo Date: Sat, 27 Jun 2026 16:45:22 -0300 Subject: [PATCH 104/125] test(templates): adiciona testes de templates --- spec/requests/templates_spec.rb | 142 +++++++++++++++++++++++++++++++- 1 file changed, 140 insertions(+), 2 deletions(-) diff --git a/spec/requests/templates_spec.rb b/spec/requests/templates_spec.rb index d049ae2008..59e10bf4e8 100644 --- a/spec/requests/templates_spec.rb +++ b/spec/requests/templates_spec.rb @@ -70,6 +70,10 @@ Nokogiri::HTML(response.body) .at_xpath("//a[normalize-space()='Voltar aos templates']") ).to be_nil + + pagina = Nokogiri::HTML(response.body) + expect(pagina.at_css("#meus-templates a.template-form__floating-action[href='#{new_template_path}']")).to be_present + expect(pagina.at_css("#meus-templates a.template-form__add-template-button")).to be_nil end it "renderiza a lixeira que envia DELETE para um template próprio" do @@ -109,6 +113,8 @@ formulario.at_css('button[aria-label="Excluir template Avaliação"]') ).to be_present expect(formulario.at_css('img[src*="icons/trash"]')).to be_present + expect(pagina.at_css("#meus-templates a.template-form__add-template-button[href='#{new_template_path}']")).to be_present + expect(pagina.at_css("#meus-templates a.template-form__floating-action")).to be_nil end end @@ -129,9 +135,14 @@ Nokogiri::HTML(response.body) .at_xpath("//a[normalize-space()='Voltar aos templates']")["href"] ).to eq(templates_path) + + labels = Nokogiri::HTML(response.body) + .css(".template-form__metadata label") + .map { |label| label.text.strip } + expect(labels).to include("Título", "Descrição") end - it "prepara uma questão discursiva inicial sem opções" do + it "prepara uma questão inicial sem tipo e sem opções" do new_template = Template.new(adm_id: 1) allow(new_template).to receive(:questoes).and_return([]) @@ -143,7 +154,7 @@ get new_template_path questao = new_template.utilizacoes_questoes.first.questao - expect(questao).to be_discursiva + expect(questao.tipo).to be_nil expect(questao.opcoes).to be_empty end @@ -332,8 +343,34 @@ ) expect(response.body).to include("template-form#destroyQuestion") expect(response.body).to include("template-form#destroyOption") + expect(response.body).to include("template-form#ensureObjectiveOptions") expect(response.body).not_to include("checkbox") expect(response.body).not_to include("Remover") + + pagina = Nokogiri::HTML(response.body) + placeholders = pagina + .css( + '[data-template-form-question-index="0"] ' \ + "[data-template-form-option-text]" + ) + .map { |campo| campo["placeholder"] } + + expect(placeholders).to eq([ "Opção 1", "Opção 2", "Opção 3" ]) + + dropdown = pagina.at_css( + '[data-template-form-question-index="0"] [data-controller="dropdown"]' + ) + select_nativo = dropdown.at_css("select[data-dropdown-target='native']") + gatilho = dropdown.at_css("button[data-dropdown-target='trigger']") + menu = dropdown.at_css("[role='listbox'][data-dropdown-target='menu']") + + expect(select_nativo.at_css("option[selected]")["value"]).to eq("objetiva") + expect(gatilho["aria-haspopup"]).to eq("listbox") + expect(gatilho["aria-expanded"]).to eq("false") + expect(menu["hidden"]).not_to be_nil + expect( + menu.css("[role='option']").map { |opcao| opcao.text.strip } + ).to eq([ "Tipo de questão", "Discursiva", "Objetiva" ]) end it "renderiza botões frontend para adicionar questão e opção" do @@ -427,6 +464,107 @@ expect(response).to redirect_to(template_path(template_com_questao_objetiva)) expect(questao.opcoes.reload.pluck(:texto)).to include("Excelente") end + + it "rejeita a exclusão da última questão somente no submit do formulário" do + template_com_uma_questao = create_template_with_questoes( + titulo: "Avaliação discursiva", + questoes: [ + { + enunciado: "Descreva sua experiência", + tipo: :discursiva + } + ] + ) + utilizacao = template_com_uma_questao.utilizacoes_questoes.sole + + patch template_path(template_com_uma_questao), params: { + template: { + titulo: template_com_uma_questao.titulo, + utilizacoes_questoes_attributes: { + "0" => { + id: utilizacao.id, + _destroy: "1" + } + } + } + } + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.body).to include("deve conter ao menos uma questão") + expect(UtilizacaoQuestao.exists?(utilizacao.id)).to be(true) + + questao_removida = Nokogiri::HTML(response.body) + .at_css("[data-template-form-question][hidden]") + expect(questao_removida).to be_present + expect( + questao_removida.at_css("[data-template-form-question-destroy]")["value"] + ).to eq("1") + end + + it "rejeita deixar uma questão objetiva com apenas uma opção no submit" do + template_objetivo = create_template_with_questoes( + titulo: "Avaliação objetiva mínima", + questoes: [ + { + enunciado: "Como você avalia a disciplina?", + tipo: :objetiva, + opcoes: %w[Ruim Boa] + } + ] + ) + utilizacao = template_objetivo.utilizacoes_questoes.sole + questao = utilizacao.questao + opcao_removida = questao.opcoes.first + + patch template_path(template_objetivo), params: { + template: { + titulo: template_objetivo.titulo, + utilizacoes_questoes_attributes: { + "0" => { + id: utilizacao.id, + numero: utilizacao.numero, + questao_attributes: { + id: questao.id, + enunciado: questao.enunciado, + tipo: "objetiva", + opcoes_attributes: { + "0" => { + id: opcao_removida.id, + _destroy: "1" + } + } + } + } + } + } + } + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.body).to include( + "Opções devem ter pelo menos duas alternativas para questão objetiva" + ) + expect(response.body).not_to include("Utilizacoes questoes questao opcoes") + expect(Opcao.exists?(opcao_removida.id)).to be(true) + + pagina = Nokogiri::HTML(response.body) + dialogo = pagina.at_css( + "dialog.app-dialog.app-dialog--error[data-error-dialog-target='dialog']" + ) + expect(dialogo).to be_present + expect(dialogo.at_css("h2").text.strip).to eq("Não foi possível salvar") + expect(dialogo.text).to include( + "Opções devem ter pelo menos duas alternativas para questão objetiva" + ) + expect( + dialogo.at_css("button[aria-label='Fechar mensagem de erro']") + ).to be_present + + opcao_oculta = pagina.at_css("[data-template-form-option][hidden]") + expect(opcao_oculta).to be_present + expect( + opcao_oculta.at_css("[data-template-form-option-destroy]")["value"] + ).to eq("1") + end end describe "DELETE /templates/:id" do From d53bfa6a0c8e937ea1647de7e87b467de094d9b5 Mon Sep 17 00:00:00 2001 From: PedroFFranca Date: Mon, 29 Jun 2026 22:09:04 -0300 Subject: [PATCH 105/125] feat (rspec): arrumando os tests dos formularios --- spec/policies/formulario_policy_spec.rb | 31 +++ spec/requests/formularios_spec.rb | 189 ++++++++++++++---- .../formularios/create_from_template_spec.rb | 35 +++- 3 files changed, 209 insertions(+), 46 deletions(-) create mode 100644 spec/policies/formulario_policy_spec.rb diff --git a/spec/policies/formulario_policy_spec.rb b/spec/policies/formulario_policy_spec.rb new file mode 100644 index 0000000000..2e05efc834 --- /dev/null +++ b/spec/policies/formulario_policy_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe FormularioPolicy do + let(:departamento) { Departamento.create!(nome: "DCC #{SecureRandom.hex(2)}") } + let(:admin) { create_admin_usuario(departamento: departamento) } + let(:usuario) { create_usuario } + let(:formulario) { Formulario.new(adm: admin.perfil_adm) } + + describe "#create?" do + it "permite administrador" do + expect(described_class.new(admin, formulario).create?).to be(true) + end + + it "nega usuário sem perfil de administrador" do + expect(described_class.new(usuario, formulario).create?).to be(false) + end + + it "nega visitante não autenticado" do + expect(described_class.new(nil, formulario).create?).to be_falsy + end + end + + describe "#new?" do + it "delega para create?" do + policy = described_class.new(admin, formulario) + expect(policy.new?).to eq(policy.create?) + end + end +end diff --git a/spec/requests/formularios_spec.rb b/spec/requests/formularios_spec.rb index 1854b4c65c..845d2fe5c4 100644 --- a/spec/requests/formularios_spec.rb +++ b/spec/requests/formularios_spec.rb @@ -8,9 +8,12 @@ let(:turma_a) { create_turma(nome_materia: "MDS", numero: 1, departamento: departamento) } let(:turma_b) { create_turma(nome_materia: "IHC", numero: 2, departamento: departamento) } - def preparar_formulario(template_id: template.id, turma_ids: [ turma_a.id, turma_b.id ]) - post preparar_formularios_path, - params: { template_id: template_id, turma_ids: turma_ids } + def criar_formulario_params(template_id: template.id, turma_ids: [ turma_a.id, turma_b.id ], publico_alvo: "docentes") + { + template_id: template_id, + turma_ids: turma_ids, + publico_alvo: publico_alvo + } end describe "GET /formularios" do @@ -37,7 +40,7 @@ def preparar_formulario(template_id: template.id, turma_ids: [ turma_a.id, turma expect(response.body).to include("Docentes") expect(response.body).to include(formulario_b.turma.nome_exibicao) expect(response.body).to include("Discentes") - expect(response.body).to include("Gerar Relatório de Respostas") + expect(response.body).to include(formulario_path(formulario_a)) end it "exibe mensagem quando não há formulários no semestre atual" do @@ -45,7 +48,8 @@ def preparar_formulario(template_id: template.id, turma_ids: [ turma_a.id, turma get formularios_path expect(response).to have_http_status(:ok) - expect(response.body).to include("Nenhum formulário criado para este semestre") + expect(response.body).to include("Nenhum formulário criado por você foi encontrado") + expect(response.body).to include("Nenhum formulário de outros administradores encontrado") end it "não exibe formulários de outro departamento" do @@ -100,95 +104,192 @@ def preparar_formulario(template_id: template.id, turma_ids: [ turma_a.id, turma expect(response.body).not_to include(turma_passada.nome_exibicao) end + it "lista formulário cujo template de origem foi removido" do + formulario = create_formulario( + turma: turma_a, + adm: admin.perfil_adm, + template: template + ) + template.destroy! + + sign_in_as(admin) + get formularios_path + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Template removido") + expect(response.body).to include(formulario.turma.nome_exibicao) + end + it "bloqueia usuário não administrador" do sign_in_as(usuario) get formularios_path expect(response).to redirect_to("/") - expect(flash[:alert]).to eq("Acesso não autorizado") + expect(flash[:alert]).to eq("Você não tem permissão para realizar esta ação.") end end - describe "POST /formularios/preparar" do - it "grava sessão e redireciona para publicação quando dados válidos" do + describe "GET /formularios/:id" do + it "exibe o relatório de um formulário do departamento" do + formulario = create_formulario( + turma: turma_a, + adm: admin.perfil_adm, + template: template + ) + sign_in_as(admin) + get formulario_path(formulario) - preparar_formulario + expect(response).to have_http_status(:ok) + expect(response.body).to include(template.titulo) + expect(response.body).to include(turma_a.nome_exibicao) + expect(response.body).to include(exportar_csv_formulario_path(formulario)) + end - expect(response).to redirect_to(publicar_formularios_path) - follow_redirect! - expect(response.body).to include("Avaliação Docente") + it "impede acesso a formulário de outro departamento" do + outro_departamento = Departamento.create!(nome: "Outro #{SecureRandom.hex(2)}") + outra_turma = create_turma( + nome_materia: "Externa", + numero: 1, + departamento: outro_departamento + ) + formulario_externo = create_formulario(turma: outra_turma) + + sign_in_as(admin) + get formulario_path(formulario_externo) + + expect(response).to redirect_to(formularios_path) + expect(flash[:alert]).to eq( + "Você não tem permissão para acessar esse formulário." + ) + end + end + + describe "GET /formularios/new" do + it "pré-seleciona o template recebido pela URL" do + sign_in_as(admin) + + get new_formulario_path(template_id: template.id) + + pagina = Nokogiri::HTML(response.body) + template_selecionado = pagina.at_css("input[name='template_id'][checked]") + + expect(response).to have_http_status(:ok) + expect(template_selecionado["value"]).to eq(template.id.to_s) + expect(template_selecionado.ancestors("label").first.text).to include(template.titulo) + expect(template_selecionado.ancestors(".formulario-section-box").first.text).to include("Template base") + expect(template_selecionado.ancestors(".formulario-step--template")).not_to be_empty + expect(template_selecionado["type"]).to eq("radio") + end + + it "lista turmas em uma caixa única e usa matéria apenas como filtro inicial" do + turma_a + turma_b + professor = create_usuario(nome: "Professora Ada", email: "ada@example.com") + create_participacao(usuario: professor, turma: turma_a, tipo_participacao: :docente) + sign_in_as(admin) + + get new_formulario_path(materia_id: turma_a.materia_id) + + pagina = Nokogiri::HTML(response.body) + filtro = pagina.at_css("[data-controller='class-filter']") + + expect(response).to have_http_status(:ok) expect(response.body).to include(turma_a.nome_exibicao) + expect(response.body).to include(turma_b.nome_exibicao) + expect(pagina.css(".formulario-class-group")).to be_empty + expect(pagina.css(".formulario-classes__box .formulario-choice-card--class").size).to eq(2) + expect(filtro["data-class-filter-selected-materia-ids-value"]).to eq(turma_a.materia_id.to_s) + expect(pagina.at_css("#turma_#{turma_a.id}").ancestors("label").first["data-professor-ids"]).to include(professor.id.to_s) + expect(response.body).to include("Professora Ada") end - it "retorna erro quando nenhuma turma é selecionada" do + it "renderiza menu de filtro com seções recolhíveis e buscas específicas" do + professor = create_usuario(nome: "Professor Alan", email: "alan@example.com") + professor_do_departamento = create_usuario(nome: "Professor Sem Turma", email: "sem-turma@example.com") + create_participacao(usuario: professor, turma: turma_a, tipo_participacao: :docente) + create_perfil_docente(professor_do_departamento, departamento: departamento) sign_in_as(admin) - expect do - preparar_formulario(turma_ids: []) - end.not_to change(Formulario, :count) + get new_formulario_path - expect(response).to redirect_to(new_formulario_path) - follow_redirect! - expect(response.body).to include("É necessário selecionar pelo menos uma turma") + pagina = Nokogiri::HTML(response.body) + menu = pagina.at_css("#formulario-class-filter-menu") + caixa_turmas = pagina.at_css(".formulario-classes__box") + cabecalho_caixa = pagina.at_css(".formulario-classes__box-header") + + expect(response).to have_http_status(:ok) + expect(pagina.at_css(".formulario-form-panel")).to be_present + expect(pagina.css(".formulario-step__marker").map(&:text)).to eq(%w[1 2]) + expect(pagina.css(".template-form__metadata")).to be_empty + expect(response.body).not_to include("Dados do formulário") + expect(pagina.css(".formulario-section-box__heading label").map(&:text)).to include( + "Template base", + "Selecione as turmas alvo" + ) + expect(pagina.at_css(".formulario-template-base__header .formulario-publico-segmented")).to be_present + expect(menu.ancestors(".formulario-classes__box")).not_to be_empty + expect(caixa_turmas.text).to include("Selecione as turmas alvo") + expect(cabecalho_caixa.at_css(".formulario-class-filter__button")).to be_present + expect(pagina.css(".formulario-class-filter__section summary").map(&:text)).to contain_exactly("Matéria", "Professor") + expect(pagina.css(".formulario-class-filter__section[open]")).to be_empty + expect(pagina.at_css("input[placeholder='Pesquisar matéria']")).to be_present + expect(pagina.at_css("input[placeholder='Pesquisar professor']")).to be_present + expect(pagina.at_css("[data-filter-type='materia'][role='menuitemcheckbox']")).to be_present + expect(pagina.at_css("[data-filter-type='professor'][role='menuitemcheckbox']")).to be_present + expect(pagina.at_css("[data-filter-type='professor'][data-filter-value='#{professor.id}']")).to be_present + expect(pagina.at_css("[data-filter-type='professor'][data-filter-value='#{professor_do_departamento.id}']")).to be_present end - end - describe "GET /formularios/publicar" do - it "redireciona para new quando sessão está vazia" do + it "renderiza a escolha de público-alvo como controle segmentado" do sign_in_as(admin) - get publicar_formularios_path + get new_formulario_path + + pagina = Nokogiri::HTML(response.body) - expect(response).to redirect_to(new_formulario_path) + expect(response).to have_http_status(:ok) + expect(pagina.css(".formulario-publico-field")).to be_empty + expect(pagina.at_css(".formulario-template-base__header .formulario-publico-segmented")).to be_present + expect(pagina.css("select[name='publico_alvo']")).to be_empty + valores_publico_alvo = pagina.css("input[name='publico_alvo'][type='radio']").map { |input| input["value"] } + expect(valores_publico_alvo).to contain_exactly("docentes", "discentes") + expect(pagina.at_css(".formulario-form-actions input.app-button--accent[type='submit']")).to be_present end end describe "POST /formularios" do - it "cria formulários após wizard completo com público-alvo" do + it "cria formulários com template, turmas e público-alvo" do sign_in_as(admin) - preparar_formulario expect do - post formularios_path, params: { publico_alvo: "docentes" } + post formularios_path, params: criar_formulario_params end.to change(Formulario, :count).by(2) - expect(response).to redirect_to(new_formulario_path) + expect(response).to redirect_to(formularios_path) follow_redirect! expect(response.body).to include("Formulário criado com sucesso para as turmas selecionadas") end it "retorna erro quando público-alvo não é informado" do sign_in_as(admin) - preparar_formulario(turma_ids: [ turma_a.id ]) expect do - post formularios_path, params: { publico_alvo: "" } + post formularios_path, params: criar_formulario_params(turma_ids: [ turma_a.id ], publico_alvo: "") end.not_to change(Formulario, :count) - expect(response).to redirect_to(publicar_formularios_path) - follow_redirect! + expect(response).to have_http_status(:unprocessable_content) expect(response.body).to include("Por favor, selecione o público-alvo do formulário") end - it "redireciona para new quando sessão está vazia" do - sign_in_as(admin) - - post formularios_path, params: { publico_alvo: "docentes" } - - expect(response).to redirect_to(new_formulario_path) - follow_redirect! - expect(response.body).to include("Selecione um template e as turmas antes de publicar") - end - it "bloqueia usuário não administrador" do sign_in_as(usuario) - post formularios_path, params: { publico_alvo: "docentes" } + post formularios_path, params: criar_formulario_params expect(response).to redirect_to("/") - expect(flash[:alert]).to eq("Acesso não autorizado") + expect(flash[:alert]).to eq("Você não tem permissão para realizar esta ação.") expect(Formulario.count).to eq(0) end end diff --git a/spec/services/formularios/create_from_template_spec.rb b/spec/services/formularios/create_from_template_spec.rb index 89c1d2fe97..50d6594e6e 100644 --- a/spec/services/formularios/create_from_template_spec.rb +++ b/spec/services/formularios/create_from_template_spec.rb @@ -85,12 +85,43 @@ def call_service(**overrides) expect(Formulario.count).to eq(0) end - it "levanta erro quando turma já possui formulário" do + it "levanta erro quando turma já possui formulário para o mesmo template e público-alvo" do call_service(turma_ids: [ turma_a.id ]) expect do call_service(turma_ids: [ turma_a.id ]) - end.to raise_error(Formularios::Error, "Uma ou mais turmas selecionadas já possuem formulário") + end.to raise_error( + Formularios::Error, + "Uma ou mais turmas selecionadas já possuem formulário para este template e público-alvo" + ) + end + + it "permite criar outro formulário com o mesmo template para público-alvo diferente" do + call_service(turma_ids: [ turma_a.id ], publico_alvo: :docentes) + + expect do + call_service(turma_ids: [ turma_a.id ], publico_alvo: :discentes) + end.not_to raise_error + + expect(turma_a.reload.formularios.count).to eq(2) + expect(turma_a.formularios.pluck(:template_id)).to eq([ template.id, template.id ]) + end + + it "permite criar formulário com template diferente para a mesma turma e público-alvo" do + outro_template = create_template_with_questoes(titulo: "Outro Template", adm: perfil_adm) + call_service(turma_ids: [ turma_a.id ], publico_alvo: :docentes) + + expect do + described_class.call( + template_id: outro_template.id, + turma_ids: [ turma_a.id ], + publico_alvo: :docentes, + perfil_adm: perfil_adm + ) + end.not_to raise_error + + expect(turma_a.reload.formularios.count).to eq(2) + expect(turma_a.formularios.pluck(:template_id)).to contain_exactly(template.id, outro_template.id) end it "persiste o público-alvo informado" do From c27a0fc8a50d7a5b13867f810ff4eef437bbb1e8 Mon Sep 17 00:00:00 2001 From: PedroFFranca Date: Mon, 29 Jun 2026 22:09:44 -0300 Subject: [PATCH 106/125] feat(views): adicionando as views dos formularios. --- app/views/formularios/_card.html.erb | 21 ++ app/views/formularios/_form.html.erb | 311 ++++++++++++++++++ .../formularios/_publico_alvo_select.html.erb | 20 ++ app/views/formularios/index.html.erb | 76 +++-- app/views/formularios/new.html.erb | 47 +-- app/views/formularios/publicar.html.erb | 63 ++-- app/views/formularios/show.html.erb | 33 +- 7 files changed, 483 insertions(+), 88 deletions(-) create mode 100644 app/views/formularios/_card.html.erb create mode 100644 app/views/formularios/_form.html.erb create mode 100644 app/views/formularios/_publico_alvo_select.html.erb diff --git a/app/views/formularios/_card.html.erb b/app/views/formularios/_card.html.erb new file mode 100644 index 0000000000..69715027fd --- /dev/null +++ b/app/views/formularios/_card.html.erb @@ -0,0 +1,21 @@ +
    + <%= link_to formulario_path(formulario), class: "template-form__card-link" do %> +
    + <%# Proteção com &. caso o template de origem tenha sido excluído %> +

    <%= formulario.template&.titulo || "Template removido" %>

    +

    <%= formulario.turma.nome_exibicao %>

    +
    + +
    +
    + 👥 + <%= formulario.docentes? ? "Docentes" : "Discentes" %> +
    +
    + 📋 + <%= formulario.avaliacoes.pendentes.count %> + <%= formulario.avaliacoes.pendentes.count == 1 ? "pendente" : "pendentes" %> +
    +
    + <% end %> +
    \ No newline at end of file diff --git a/app/views/formularios/_form.html.erb b/app/views/formularios/_form.html.erb new file mode 100644 index 0000000000..b4b8e0869f --- /dev/null +++ b/app/views/formularios/_form.html.erb @@ -0,0 +1,311 @@ +<% # Formulário de Submissão %> +<%= form_with url: formularios_path, class: "template-form" do |form| %> +
    +
    + + <% # Seleção de Template %> +
    + + +
    +
    +
    +
    +
    + +

    Escolha o modelo que será usado para criar os formulários das turmas selecionadas.

    +
    + + <%= render "publico_alvo_select", menu_id: "formulario-publico-alvo-menu", show_label: false %> +
    + + <% if templates_proprios.empty? && templates_outros.empty? %> +

    Nenhum template disponível. Crie um template antes de montar um formulário.

    + <% else %> +
    + <% if templates_proprios.any? %> +
    +
    +

    Meus templates

    + <%= pluralize(templates_proprios.size, "template", "templates") %> +
    + +
    + <% templates_proprios.each do |template| %> + + <% end %> +
    +
    + <% end %> + + <% if templates_outros.any? %> +
    +
    +

    Templates de outros admins

    + <%= pluralize(templates_outros.size, "template", "templates") %> +
    + +
    + <% templates_outros.each do |template| %> + + <% end %> +
    +
    + <% end %> +
    + <% end %> +
    +
    +
    +
    + + <% # Seleção de Turmas %> + <% materia_atual = materias.find { |materia| materia.id.to_s == materia_selecionada.to_s } %> +
    + + +
    +
    +
    +
    +
    + +

    Apenas turmas vigentes do departamento são exibidas abaixo.

    +
    + + <% if turmas.any? %> +
    + + <%= materia_atual&.nome || "Todas as matérias" %> · Todos professores + + + +
    + <% end %> +
    + + <% if turmas.any? %> +
    + + +
    +
    + <% turmas.each do |turma| %> + <% docentes = turma.participacoes_turma.select(&:docente?).map(&:usuario).uniq %> + + <% end %> +
    + + +
    +
    + <% else %> +

    Nenhuma turma encontrada no semestre atual.

    + <% end %> +
    +
    +
    +
    + +
    +
    + +
    + <%= link_to "Voltar", formularios_path, class: "app-button app-button--secondary" %> + <%= form.submit "Publicar formulário", class: "app-button app-button--accent" %> +
    +<% end %> diff --git a/app/views/formularios/_publico_alvo_select.html.erb b/app/views/formularios/_publico_alvo_select.html.erb new file mode 100644 index 0000000000..29cb6286c2 --- /dev/null +++ b/app/views/formularios/_publico_alvo_select.html.erb @@ -0,0 +1,20 @@ +<% show_label = local_assigns.fetch(:show_label, true) %> +<% publico_alvo_options = [ + ["Docentes", "docentes"], + ["Discentes", "discentes"] + ] %> + +
    + "> + Público-alvo + + +
    + <% publico_alvo_options.each do |label, value| %> + + <% end %> +
    +
    diff --git a/app/views/formularios/index.html.erb b/app/views/formularios/index.html.erb index 2ce0699bb1..f12674fce8 100644 --- a/app/views/formularios/index.html.erb +++ b/app/views/formularios/index.html.erb @@ -1,28 +1,52 @@ -

    Gerenciamento de Formulários

    +
    + <% if flash[:notice] %> +
    <%= flash[:notice] %>
    + <% end %> -<% if @formularios.any? %> - - - - - - - - - - - <% @formularios.each do |formulario| %> - - - - - - + <% if flash[:alert] %> +
    <%= flash[:alert] %>
    + <% end %> + +
    +

    Formulários

    +
    + + <%# 1. Seção: Meus Formulários %> +
    +

    Meus Formulários

    + + <% if @user_formularios.any? %> +
    + <% @user_formularios.each do |f| %> + <%= render "card", formulario: f %> + <% end %> + <%= link_to new_formulario_path, class: "app-card app-card--create template-form__add-template-button", title: "Criar novo formulário", aria: { label: "Criar novo formulário" } do %> + <%= image_tag "icons/plus.svg", alt: "", width: 24, height: 24 %> + <% end %> +
    + <% else %> +
    +

    Nenhum formulário criado por você foi encontrado

    + <%= link_to new_formulario_path, class: "app-floating-action app-floating-action--inline template-form__floating-action", title: "Criar novo formulário", aria: { label: "Criar novo formulário" } do %> + <%= image_tag "icons/plus.svg", alt: "", width: 24, height: 24 %> + <% end %> +
    + <% end %> +
    + + <%# 2. Seção: Outros Formulários %> +
    +

    Outros Formulários

    +

    De Administradores do seu Departamento

    + +
    + <% if @other_formularios.any? %> + <% @other_formularios.each do |f| %> + <%= render "card", formulario: f %> + <% end %> + <% else %> +

    Nenhum formulário de outros administradores encontrado

    <% end %> -
    -
    TemplateTurmaPúblico-alvoAção
    <%= formulario.template.titulo %><%= formulario.turma.nome_exibicao %><%= formulario.docentes? ? "Docentes" : "Discentes" %> - <%= button_to "Gerar Relatório de Respostas", "#", method: :get %> -
    -<% else %> -

    Nenhum formulário criado para este semestre

    -<% end %> +
    +
    +
    diff --git a/app/views/formularios/new.html.erb b/app/views/formularios/new.html.erb index 3cb417b64d..7b8c686bf2 100644 --- a/app/views/formularios/new.html.erb +++ b/app/views/formularios/new.html.erb @@ -1,32 +1,19 @@ -

    Criar Formulário

    +
    + <% if flash[:alert] %> +
    <%= flash[:alert] %>
    + <% end %> -<% if flash[:notice] %> -

    <%= flash[:notice] %>

    -<% end %> +
    +

    Criar Formulário

    +

    Escolha um template, filtre e selecione as turmas que receberão a avaliação.

    +
    -<% if flash[:alert] %> -

    <%= flash[:alert] %>

    -<% end %> - -<%= form_with url: preparar_formularios_path do |form| %> -
    - - <%= select_tag :template_id, - options_from_collection_for_select(@templates, :id, :titulo), - prompt: "Selecione um template" %> -
    - -
    -

    Turmas

    - <% @turmas.each do |turma| %> -
    - -
    - <% end %> -
    - - <%= form.submit "Continuar" %> -<% end %> + <%= render "form", + templates_proprios: @templates_proprios, + templates_outros: @templates_outros, + template_selecionado: @template_selecionado, + materias: @materias, + materia_selecionada: @materia_selecionada, + turmas: @turmas, + professores: @professores %> +
    diff --git a/app/views/formularios/publicar.html.erb b/app/views/formularios/publicar.html.erb index f7a54fe7ec..e5c5d6cc45 100644 --- a/app/views/formularios/publicar.html.erb +++ b/app/views/formularios/publicar.html.erb @@ -1,36 +1,37 @@ -

    Publicar Formulário

    +
    +
    +

    Publicar Formulário

    +

    Revise os dados e defina quem responderá à avaliação.

    +
    -<% if flash[:notice] %> -

    <%= flash[:notice] %>

    -<% end %> + <% if flash[:notice] %> +
    <%= flash[:notice] %>
    + <% end %> -<% if flash[:alert] %> -

    <%= flash[:alert] %>

    -<% end %> + <% if flash[:alert] %> +
    <%= flash[:alert] %>
    + <% end %> -
    -

    Resumo

    -

    Template: <%= @template.titulo %>

    -

    Turmas:

    -
      - <% @turmas.each do |turma| %> -
    • <%= turma.nome_exibicao %>
    • - <% end %> -
    -
    +
    +

    Resumo

    +
    +

    Template: <%= @template.titulo %>

    +

    Turmas:

    +
      + <% @turmas.each do |turma| %> +
    • <%= turma.nome_exibicao %>
    • + <% end %> +
    +
    +
    -<%= form_with url: formularios_path do |form| %> -
    -

    Público-alvo

    - <% Formulario.publico_alvos.each do |valor, _| %> -
    - -
    - <% end %> -
    + <%= form_with url: formularios_path, class: "app-panel app-form" do |form| %> +
    + <%= render "publico_alvo_select", menu_id: "publicar-publico-alvo-menu" %> +
    - <%= form.submit "Confirmar Publicação" %> -<% end %> +
    + <%= form.submit "Confirmar Publicação", class: "app-button app-button--accent" %> +
    + <% end %> +
    diff --git a/app/views/formularios/show.html.erb b/app/views/formularios/show.html.erb index 66f03ca6ed..604ed1b9f9 100644 --- a/app/views/formularios/show.html.erb +++ b/app/views/formularios/show.html.erb @@ -1 +1,32 @@ -<%= link_to 'Exportar CSV', exportar_csv_formulario_path(@formulario), class: 'btn btn-primary' %> +
    +
    +

    Relatório do Formulário

    +

    Exporte as respostas para análise em formato CSV.

    +
    + +
    +
    +
    +
    Template
    +
    <%= @formulario.template&.titulo || "Template removido" %>
    +
    +
    +
    Turma
    +
    <%= @formulario.turma.nome_exibicao %>
    +
    +
    +
    Público-alvo
    +
    <%= @formulario.docentes? ? "Docentes" : "Discentes" %>
    +
    +
    +
    Respostas recebidas
    +
    <%= @formulario.avaliacoes.respondidas.count %>
    +
    +
    + +
    + <%= link_to "Voltar aos formulários", formularios_path, class: "app-button app-button--secondary" %> + <%= link_to "Exportar CSV", exportar_csv_formulario_path(@formulario), class: "app-button app-button--accent" %> +
    +
    +
    From e939f43eb802443dbb335ee6a398d7d523ca5b1a Mon Sep 17 00:00:00 2001 From: PedroFFranca Date: Mon, 29 Jun 2026 22:11:14 -0300 Subject: [PATCH 107/125] fix (models): ajustando o models dos formularios. --- app/models/formulario.rb | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/models/formulario.rb b/app/models/formulario.rb index 37edc74494..24736bcc47 100644 --- a/app/models/formulario.rb +++ b/app/models/formulario.rb @@ -32,7 +32,12 @@ class Formulario < ApplicationRecord validates :adm, presence: true validates :turma, presence: true - validates :publico_alvo, presence: true + validates :publico_alvo, + presence: true, + uniqueness: { + scope: %i[turma_id template_id], + message: "já possui formulário para esta turma e template" + } validate :adm_deve_pertencer_ao_departamento_da_turma @@ -51,6 +56,9 @@ class Formulario < ApplicationRecord joins(:turma).merge(Turma.do_semestre_atual) } + scope :criados_por, ->(adm) { where(adm_id: adm&.id) } + scope :criados_por_outros, ->(adm) { where.not(adm_id: adm&.id) } + def participacoes_alvo return turma.participantes_docentes if docentes? return turma.participantes_discentes if discentes? From 46d5fb0926c443c2d6e525963b64f2a16da79171 Mon Sep 17 00:00:00 2001 From: PedroFFranca Date: Mon, 29 Jun 2026 22:12:25 -0300 Subject: [PATCH 108/125] fix (controllers): ajustando os controllers dos formularios --- app/controllers/concerns/development.yml.enc | 1 + app/controllers/dashboard_controller.rb | 15 ++ app/controllers/formularios_controller.rb | 171 ++++++++++++------- 3 files changed, 125 insertions(+), 62 deletions(-) create mode 100644 app/controllers/concerns/development.yml.enc diff --git a/app/controllers/concerns/development.yml.enc b/app/controllers/concerns/development.yml.enc new file mode 100644 index 0000000000..5eb0d406bf --- /dev/null +++ b/app/controllers/concerns/development.yml.enc @@ -0,0 +1 @@ +QL0lLuOapggpuG7wJebToTwHewT7VUS41H0ekEggVPSzzyZkXUULqNHlLjM4C3rHTfs4mLTrlm23Kh4kSz2MjoVjtRUeuiMlePyqWDKjSvlpCylV6Els8QDAhG/98FZB084nc2t6EgW1UHUjhL54EcfPF1/WUfq1reNWQJ4pjOf667kmmhVDSl0arU5OS1TGWhbeVDASjTG2y3lenf8jHXzLFiTNC2n/NqlgSCBvXqEil/Tkeu7gRr3t7JQ4nCb9C99JU6gM3kzNatGLZyMvH60bqSfbD+x94eBo4GK8DvlDvbjgOl5rKcurYvhYMxieenrgL1X917M7ASISAbmij+5RrWNIky+eiHl0wUUV6ZhH3Nme8JhjE7lJ6wuWwzVC+o3hF69t0IeOy3C3cr2qCRzkRw82AlA8rvKy9pgqt9DoWVrawTBHQ45ZmUowFH9+PpWrIvJMswJ1GdVM+FGl0B4+GRvnUoEJc8kI7TfdFZENvDuwnuY+/d6VmSdWXbui7DXp92kcKtZXtfwXOJWy69Uzvv+doKxcY0t/ZlWb0gBtdzCTLjvQz/boJrSQltYSpa+iLhggfO+oTgAVXJ3/3v+AquKa5rWU0Ai0VSH6HuOTxLOB8mjNV/PjKE+uuxg0ydVPfMkWNr8=--cD6odzv0zUxDcVy5--ZG0yg+ngfCnMIypDzbAnvg== \ No newline at end of file diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 4991679183..b4345597ab 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -5,6 +5,7 @@ class DashboardController < ApplicationController include BrevoEmailable before_action :verificar_admin, only: [ :gerenciamento, :importar_dados, :enviar_solicitacoes ] + before_action :verificar_usuario_logado, only: %i[pesquisar sugestoes] def index if current_user.nil? @@ -23,6 +24,14 @@ def index def gerenciamento end + def pesquisar + redirect_to avaliacoes_path + end + + def sugestoes + render json: [] + end + def enviar_solicitacoes depto_id = current_user.perfil_adm&.departamento_id @@ -186,6 +195,12 @@ def importar_dados private + def verificar_usuario_logado + return if current_user.present? + + redirect_to root_path, alert: "Acesso restrito. Por favor, faça login para continuar." + end + def verificar_admin if current_user.nil? || !current_user.administrador? session.clear diff --git a/app/controllers/formularios_controller.rb b/app/controllers/formularios_controller.rb index 173f279eb1..fd03b21850 100644 --- a/app/controllers/formularios_controller.rb +++ b/app/controllers/formularios_controller.rb @@ -4,107 +4,154 @@ class FormulariosController < ApplicationController before_action :authenticate_user! - before_action :require_administrador!, except: :exportar_csv - before_action :require_administrador_para_exportacao!, only: :exportar_csv + before_action :authorize_formulario!, only: %i[show exportar_csv] + before_action :set_formulario, only: %i[show exportar_csv] def index - @formularios = Formulario + authorize! Formulario + + formularios = Formulario .do_departamento(current_administrador.departamento) .do_semestre_atual .recentes - .includes(:template, turma: :materia) + .includes(:template, :avaliacoes, turma: :materia) + + @user_formularios = formularios.criados_por(current_administrador) + @other_formularios = formularios.criados_por_outros(current_administrador) end - def new - @templates = Template.all - @turmas = Turma.do_semestre_atual.sem_formulario.includes(:materia) + def show + authorize! @formulario end - def preparar - Formularios::CreateFromTemplate.validate_preparacao!( - template_id: params[:template_id], - turma_ids: params[:turma_ids] - ) + def new + @formulario = Formulario.new(adm: current_administrador) + authorize! @formulario - session[:formulario_preparacao] = { - "template_id" => params[:template_id].to_i, - "turma_ids" => Array(params[:turma_ids]).map(&:to_i) - } + carregar_opcoes_de_selecao + end + + def create + authorize! Formulario.new(adm: current_administrador) - redirect_to publicar_formularios_path - rescue Formularios::Error => e - redirect_to new_formulario_path, alert: e.message + criar_formularios_a_partir_do_template + redirecionar_formulario_criado + rescue Formularios::Error, ActiveRecord::RecordInvalid, ActiveRecord::RecordNotFound => e + renderizar_erro_criacao_formulario(e) end - def publicar - preparacao = session[:formulario_preparacao] - unless preparacao - redirect_to new_formulario_path - return - end + def exportar_csv + authorize! @formulario - @template = Template.find(preparacao["template_id"]) - @turmas = Turma.where(id: preparacao["turma_ids"]).includes(:materia) + send_data csv_formulario, + filename: nome_arquivo_csv, + type: "text/csv; charset=utf-8" end - def create - preparacao = session[:formulario_preparacao] - unless preparacao - redirect_to new_formulario_path, alert: "Selecione um template e as turmas antes de publicar" - return - end + private + def criar_formularios_a_partir_do_template Formularios::CreateFromTemplate.call( - template_id: preparacao["template_id"], - turma_ids: preparacao["turma_ids"], + template_id: params[:template_id], + turma_ids: params[:turma_ids], publico_alvo: params[:publico_alvo], perfil_adm: current_administrador ) + end - session.delete(:formulario_preparacao) - - redirect_to new_formulario_path, + def redirecionar_formulario_criado + redirect_to formularios_path, notice: "Formulário criado com sucesso para as turmas selecionadas" - rescue Formularios::Error => e - if e.message == Formularios::CreateFromTemplate::SEM_PUBLICO_ALVO - redirect_to publicar_formularios_path, alert: e.message - else - session.delete(:formulario_preparacao) - redirect_to new_formulario_path, alert: e.message + end + + def renderizar_erro_criacao_formulario(error) + carregar_opcoes_de_selecao + flash[:alert] = mensagem_erro_criacao_formulario(error) + render :new, status: :unprocessable_content + end + + def mensagem_erro_criacao_formulario(error) + return "Template ou turma não encontrados" if error.is_a?(ActiveRecord::RecordNotFound) + + error.message + end + + def csv_formulario + questoes = @formulario.questoes.order(:id) + + CSV.generate(headers: true, col_sep: ";") do |csv| + csv << cabecalho_csv(questoes) + avaliacoes_com_respostas.each { |avaliacao| csv << linha_csv(avaliacao, questoes) } end end - def exportar_csv - @formulario = Formulario.find(params[:id]) - avaliacoes = @formulario.avaliacoes + def avaliacoes_com_respostas + @formulario.avaliacoes .joins(:respostas) .distinct .includes( participacao_turma: :usuario, respostas: [ :questao, :texto, { opcoes_escolhidas: :opcao } ] ) + end - questoes = @formulario.template.questoes.order("utilizacoes_questoes.numero") - csv_data = CSV.generate(headers: true, col_sep: ";") do |csv| - csv << [ "Aluno", "Matrícula", *questoes.map(&:enunciado) ] + def cabecalho_csv(questoes) + [ "Aluno", "Matrícula", *questoes.map(&:enunciado) ] + end - avaliacoes.each do |avaliacao| - csv << linha_csv(avaliacao, questoes) - end - end + def nome_arquivo_csv + "resultados_turma_#{@formulario.turma.materia.codigo}_#{Date.current}.csv" + end - send_data csv_data, - filename: "resultados_turma_#{@formulario.turma.materia.codigo}_#{Date.current}.csv", - type: "text/csv; charset=utf-8" + def carregar_opcoes_de_selecao + templates = Template.includes(adm: :usuario).recentes + + @templates_proprios = templates.criados_por(current_administrador) + @templates_outros = templates.criados_por_outros(current_administrador) + @template_selecionado = params[:template_id].presence + + @materias = materias_do_departamento + @materia_selecionada = params[:materia_id].presence + + @turmas = turmas_do_departamento + @professores = professores_do_departamento end - private + def materias_do_departamento + Materia.do_departamento(current_administrador.departamento).order(:nome) + end + + def turmas_do_departamento + Turma + .do_departamento(current_administrador.departamento) + .do_semestre_atual + .includes(:materia, participacoes_turma: :usuario) + .order("materias.nome", :numero) + end + + def professores_do_departamento + current_administrador + .departamento + .docentes + .order(:nome) + end + + def set_formulario + @formulario = Formulario + .do_departamento(current_administrador.departamento) + .find(params[:id]) + rescue ActiveRecord::RecordNotFound + redirect_to formularios_path, alert: mensagem_formulario_indisponivel + end + + def mensagem_formulario_indisponivel + return "Você não tem permissão para exportar os resultados desse formulário." if action_name == "exportar_csv" - def require_administrador_para_exportacao! - return if current_user&.administrador? + "Você não tem permissão para acessar esse formulário." + end - redirect_to avaliacoes_pendentes_path, - alert: "Apenas administradores possuem acesso a este recurso" + def authorize_formulario! + authorize! Formulario end def linha_csv(avaliacao, questoes) From 7ab4fde4b167fdf7c99cf1ddb6d4eb1a10d1a67a Mon Sep 17 00:00:00 2001 From: PedroFFranca Date: Mon, 29 Jun 2026 22:14:05 -0300 Subject: [PATCH 109/125] =?UTF-8?q?fix=20(bdd):=20ajustando=20os=20testes?= =?UTF-8?q?=20de=20BDD=20dos=20formul=C3=A1rios.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- features/forms/criar_formulario.feature | 77 +++- features/forms/visualizar_formularios.feature | 30 +- .../step_definitions/formularios_steps.rb | 394 +++++++++++++++--- features/support/formularios_ui_helpers.rb | 71 ++++ 4 files changed, 507 insertions(+), 65 deletions(-) create mode 100644 features/support/formularios_ui_helpers.rb diff --git a/features/forms/criar_formulario.feature b/features/forms/criar_formulario.feature index b026a3d8ac..2820c01f35 100644 --- a/features/forms/criar_formulario.feature +++ b/features/forms/criar_formulario.feature @@ -14,17 +14,86 @@ Funcionalidade: Criar formulário a partir de template Dado que estou na página de criação de formulários Quando eu seleciono o template "Avaliação de Desempenho Docente" E seleciono as turmas "Métodos de Desenvolvimento de Software - Turma A" e "Interação Humano Computador - Turma B" - E clico em "Continuar" E seleciono a opção de público-alvo como "Docentes" - E confirmo a publicação do formulário + E clico em "Publicar formulário" Então o formulário deve ser gerado com sucesso para ambas as turmas E devo ver a mensagem "Formulário criado com sucesso para as turmas selecionadas" + @happy + Cenário: Iniciar criação de formulário pela visualização do template + Dado que estou visualizando o template cadastrado chamado "Avaliação de Desempenho Docente" + Quando solicito criar um formulário a partir desse template + Então devo estar na página de criação de formulários com o template "Avaliação de Desempenho Docente" selecionado + Quando seleciono as turmas "Métodos de Desenvolvimento de Software - Turma A" e "Interação Humano Computador - Turma B" + E seleciono a opção de público-alvo como "Docentes" + E clico em "Publicar formulário" + Então o formulário deve ser gerado com sucesso para ambas as turmas + E devo ver a mensagem "Formulário criado com sucesso para as turmas selecionadas" + + @happy + Cenário: Exibir seleção de template, público-alvo e filtros de turmas na mesma tela + Dado que existe o professor "Professora Ada" vinculado à turma "Métodos de Desenvolvimento de Software - Turma A" + E que existe o professor "Professor Sem Turma" no meu departamento + Quando estou na página de criação de formulários filtrando pela matéria "Métodos de Desenvolvimento de Software" + Então devo ver o controle segmentado de público-alvo com as opções "Docentes" e "Discentes" + E devo ver todas as turmas dentro da mesma caixa de seleção + E devo ver o filtro de turmas com seções recolhidas para matéria e professor + E devo ver buscas específicas para matéria e professor no filtro de turmas + E devo ver "Professora Ada" e "Professor Sem Turma" como opções de filtro por professor + @sad Cenário: Tentar criar formulário sem selecionar nenhuma turma Dado que estou na página de criação de formulários Quando eu seleciono o template "Avaliação de Desempenho Docente" E não seleciono nenhuma turma - E clico em "Continuar" + E seleciono a opção de público-alvo como "Docentes" + E clico em "Publicar formulário" Então eu devo ver uma mensagem de erro dizendo "É necessário selecionar pelo menos uma turma" - E nenhum formulário deve ser gerado \ No newline at end of file + E nenhum formulário deve ser gerado + + @sad + Cenário: Tentar criar formulário sem selecionar template + Dado que estou na página de criação de formulários + Quando seleciono as turmas "Métodos de Desenvolvimento de Software - Turma A" e "Interação Humano Computador - Turma B" + E seleciono a opção de público-alvo como "Docentes" + E clico em "Publicar formulário" + Então eu devo ver uma mensagem de erro dizendo "Template ou turma não encontrados" + E nenhum formulário deve ser gerado + + @sad + Cenário: Tentar criar formulário a partir de template sem questões + Dado que existe um template sem questões chamado "Template vazio" + E que estou na página de criação de formulários + Quando eu seleciono o template "Template vazio" + E seleciono as turmas "Métodos de Desenvolvimento de Software - Turma A" e "Interação Humano Computador - Turma B" + E seleciono a opção de público-alvo como "Docentes" + E clico em "Publicar formulário" + Então eu devo ver uma mensagem de erro dizendo "O template deve possuir pelo menos uma questão" + E nenhum formulário deve ser gerado + + @sad + Cenário: Tentar publicar formulário duplicado para a mesma turma, template e público-alvo + Dado que existe um formulário já publicado para a turma "Métodos de Desenvolvimento de Software - Turma A" com público-alvo "Docentes" + E que estou na página de criação de formulários + Quando eu seleciono o template "Avaliação de Desempenho Docente" + E seleciono a turma "Métodos de Desenvolvimento de Software - Turma A" + E seleciono a opção de público-alvo como "Docentes" + E clico em "Publicar formulário" + Então eu devo ver uma mensagem de erro dizendo "Uma ou mais turmas selecionadas já possuem formulário para este template e público-alvo" + E deve existir apenas um formulário para a turma "Métodos de Desenvolvimento de Software - Turma A" + + @sad + Cenário: Exibir apenas turmas vigentes do departamento do administrador + Dado que existe uma turma de outro departamento chamada "Redes de Computadores - Turma A" + E que existe uma turma passada chamada "Arquitetura de Computadores - Turma A" + Quando estou na página de criação de formulários + Então devo ver a turma "Métodos de Desenvolvimento de Software - Turma A" disponível para seleção + E não devo ver a turma "Redes de Computadores - Turma A" disponível para seleção + E não devo ver a turma "Arquitetura de Computadores - Turma A" disponível para seleção + + @sad + Cenário: Tentar publicar turma de outro departamento pela requisição + Dado que existe uma turma de outro departamento chamada "Redes de Computadores - Turma A" + Quando tento publicar formulário para a turma "Redes de Computadores - Turma A" pela requisição + Então eu devo ver uma mensagem de erro dizendo "Uma ou mais turmas selecionadas são inválidas" + E nenhum formulário deve ser gerado diff --git a/features/forms/visualizar_formularios.feature b/features/forms/visualizar_formularios.feature index 682d0b774f..21ec2f21fe 100644 --- a/features/forms/visualizar_formularios.feature +++ b/features/forms/visualizar_formularios.feature @@ -8,15 +8,39 @@ Funcionalidade: Visualizar formulários criados Dado que estou autenticado como administrador @happy - Cenário: Visualizar listagem de formulários ativos e acessar relatório de respostas + Cenário: Visualizar meus formulários ativos e acessar relatório de respostas Dado que existem formulários criados para o semestre atual Quando eu acesso o painel de gerenciamento de formulários Então eu devo ver uma lista com todos os formulários criados, exibindo o template base, a turma e o público-alvo de cada um - E cada formulário listado deve exibir um botão "Gerar Relatório de Respostas" + E ao acessar um formulário listado devo ver o botão "Exportar CSV" + + @happy + Cenário: Separar formulários próprios dos formulários de outros administradores do departamento + Dado que existe um formulário criado por outro administrador do meu departamento + Quando eu acesso o painel de gerenciamento de formulários + Então devo ver a seção "Meus Formulários" + E devo ver a seção "Outros Formulários" com subtítulo "De Administradores do seu Departamento" + E devo ver o formulário de outro administrador na seção "Outros Formulários" @sad Cenário: Visualizar listagem quando nenhum formulário foi criado ainda Dado que nenhum formulário foi gerado para o semestre vigente Quando eu acesso o painel de gerenciamento de formulários Então eu devo ver a listagem vazia - E a mensagem "Nenhum formulário criado para este semestre" deve ser exibida na tela \ No newline at end of file + E a mensagem "Nenhum formulário criado por você foi encontrado" deve ser exibida na tela + E a mensagem "Nenhum formulário de outros administradores encontrado" deve ser exibida na tela + + @sad + Cenário: Não listar formulários de outro departamento ou semestre anterior + Dado que existe um formulário criado em outro departamento + E que existe um formulário criado em semestre anterior + E que existem formulários criados para o semestre atual + Quando eu acesso o painel de gerenciamento de formulários + Então devo ver apenas os formulários vigentes do meu departamento + + @happy + Cenário: Visualizar formulário cujo template de origem foi removido + Dado que existe um formulário criado para um template removido + Quando eu acesso o painel de gerenciamento de formulários + Então devo ver o formulário com template removido na listagem + E ao acessar esse formulário devo ver o template como "Template removido" diff --git a/features/step_definitions/formularios_steps.rb b/features/step_definitions/formularios_steps.rb index aa861af7f1..ed6b33dc73 100644 --- a/features/step_definitions/formularios_steps.rb +++ b/features/step_definitions/formularios_steps.rb @@ -6,17 +6,11 @@ def administrador_formularios @admin end -def preparar_sessao_formulario(template:, turmas:) - page.driver.post preparar_formularios_path, - template_id: template.id, - turma_ids: turmas.map(&:id) -end - Dado("que existem formulários criados para o semestre atual") do administrador_formularios @template = criar_template_com_questoes(titulo: "Avaliação de Disciplina") - @turma_a = criar_turma(nome_materia: "Estrutura de Dados", numero: 1) - @turma_b = criar_turma(nome_materia: "Banco de Dados", numero: 2) + @turma_a = criar_turma_do_nome_exibicao("Estrutura de Dados - Turma A") + @turma_b = criar_turma_do_nome_exibicao("Banco de Dados - Turma B") @formularios = [ criar_formulario_publicado(turma: @turma_a, template: @template, publico_alvo: :docentes), criar_formulario_publicado(turma: @turma_b, template: @template, publico_alvo: :discentes) @@ -25,13 +19,12 @@ def preparar_sessao_formulario(template:, turmas:) Dado("que nenhum formulário foi gerado para o semestre vigente") do administrador_formularios - turma_passado = criar_turma( - nome_materia: "Disciplina Anterior", - numero: 1, + turma_passada = criar_turma_do_nome_exibicao( + "Disciplina Anterior - Turma A", ano: Date.current.year - 1, semestre: :segundo ) - criar_formulario_publicado(turma: turma_passado, publico_alvo: :docentes) + criar_formulario_publicado(turma: turma_passada, publico_alvo: :docentes) expect(Formulario.do_semestre_atual.count).to eq(0) end @@ -40,15 +33,113 @@ def preparar_sessao_formulario(template:, turmas:) @template = criar_template_com_questoes(titulo: titulo) end +Dado("que existe um template sem questões chamado {string}") do |titulo| + administrador_formularios + @template_vazio = Template.new( + adm: @admin.perfil_adm, + titulo: titulo, + descricao: "Template sem questões", + criado_em: Time.current + ) + @template_vazio.save!(validate: false) +end + Dado("que existem as turmas {string} e {string} cadastradas no semestre atual") do |nome_turma_a, nome_turma_b| @turma_a = criar_turma_do_nome_exibicao(nome_turma_a) @turma_b = criar_turma_do_nome_exibicao(nome_turma_b) end +Dado("que existe o professor {string} vinculado à turma {string}") do |nome_professor, nome_turma| + turma = turma_por_referencia(nome_turma) + professor = usuario_docente( + nome: nome_professor, + email: "#{nome_professor.parameterize}@unb.br", + departamento: turma.departamento + ) + + ParticipacaoTurma.find_or_create_by!( + usuario: professor, + turma: turma, + tipo_participacao: :docente + ) +end + +Dado("que existe o professor {string} no meu departamento") do |nome_professor| + administrador_formularios + usuario_docente( + nome: nome_professor, + email: "#{nome_professor.parameterize}@unb.br", + departamento: @departamento + ) +end + +Dado("que existe uma turma de outro departamento chamada {string}") do |nome_turma| + outro_departamento = Departamento.create!(nome: "Outro Departamento #{SecureRandom.hex(2)}") + @turma_outro_departamento = criar_turma_do_nome_exibicao(nome_turma, departamento: outro_departamento) +end + +Dado("que existe uma turma passada chamada {string}") do |nome_turma| + @turma_passada = criar_turma_do_nome_exibicao( + nome_turma, + ano: Date.current.year - 1, + semestre: :segundo + ) +end + +Dado("que existe um formulário já publicado para a turma {string} com público-alvo {string}") do |nome_turma, publico_alvo| + turma = turma_por_referencia(nome_turma) + @formulario_existente = Formularios::CreateFromTemplate.call( + template_id: @template.id, + turma_ids: [ turma.id ], + publico_alvo: valor_publico_alvo_formulario(publico_alvo), + perfil_adm: administrador_formularios.perfil_adm + ).sole +end + +Dado("que existe um formulário criado por outro administrador do meu departamento") do + administrador_formularios + outro_admin = criar_outro_administrador_formularios(@departamento) + template = template_formulario_para_admin("Avaliação de Outro Administrador", outro_admin) + turma = criar_turma_do_nome_exibicao("Compiladores - Turma A") + @formulario_outro_admin = criar_formulario_publicado(turma: turma, template: template, adm: outro_admin) +end + +Dado("que existe um formulário criado em outro departamento") do + outro_departamento = Departamento.create!(nome: "Departamento Externo #{SecureRandom.hex(2)}") + outro_admin = criar_outro_administrador_formularios(outro_departamento) + template = template_formulario_para_admin("Formulário Externo", outro_admin) + turma = criar_turma_do_nome_exibicao("Sistemas Digitais - Turma A", departamento: outro_departamento) + @formulario_outro_departamento = criar_formulario_publicado(turma: turma, template: template, adm: outro_admin) +end + +Dado("que existe um formulário criado em semestre anterior") do + administrador_formularios + turma = criar_turma_do_nome_exibicao( + "Disciplina Encerrada - Turma A", + ano: Date.current.year - 1, + semestre: :segundo + ) + @formulario_semestre_anterior = criar_formulario_publicado(turma: turma, publico_alvo: :docentes) +end + +Dado("que existe um formulário criado para um template removido") do + administrador_formularios + template = criar_template_com_questoes(titulo: "Template que será removido") + turma = criar_turma_do_nome_exibicao("Paradigmas de Programação - Turma A") + @formulario_template_removido = criar_formulario_publicado(turma: turma, template: template) + template.destroy! + @formulario_template_removido.reload +end + Dado("que estou na página de criação de formulários") do visit new_formulario_path end +Dado("que estou visualizando o template cadastrado chamado {string}") do |titulo| + @template = Template.find_by!(titulo: titulo) + visit template_path(@template) +end + Dado("que selecionei o template {string}") do |titulo| administrador_formularios @template = criar_template_com_questoes(titulo: titulo) @@ -56,48 +147,83 @@ def preparar_sessao_formulario(template:, turmas:) Dado("selecionei a turma {string}") do |nome_turma| @turma = criar_turma_do_nome_exibicao(nome_turma) - preparar_sessao_formulario(template: @template, turmas: [ @turma ]) end -Dado("que estou na etapa de definição de público-alvo") do - visit publicar_formularios_path +Quando("estou na página de criação de formulários filtrando pela matéria {string}") do |nome_materia| + materia = Materia.find_by!(nome: nome_materia) + visit new_formulario_path(materia_id: materia.id) +end + +Quando("estou na página de criação de formulários") do + visit new_formulario_path +end + +Quando("volto para a página de criação de formulários") do + visit new_formulario_path end Quando("eu seleciono o template {string}") do |titulo| - select titulo, from: "template_id" + selecionar_template_no_formulario(titulo) +end + +Quando("solicito criar um formulário a partir desse template") do + click_link "Usar em Formulário" end Quando("seleciono as turmas {string} e {string}") do |nome_turma_a, nome_turma_b| - check nome_turma_a - check nome_turma_b + selecionar_turma_no_formulario(nome_turma_a) + selecionar_turma_no_formulario(nome_turma_b) +end + +Quando("seleciono a turma {string}") do |nome_turma| + selecionar_turma_no_formulario(nome_turma) end Quando("não seleciono nenhuma turma") do - page.all('input[name="turma_ids[]"]').each do |checkbox| + page.all('input[name="turma_ids[]"]', visible: :all).each do |checkbox| checkbox.set(false) if checkbox.checked? end end -Quando(/^clico em "(Continuar|Confirmar Publicação)"$/) do |botao| - click_button botao +Quando(/^clico em "(Publicar formulário|Continuar|Confirmar Publicação)"$/) do |botao| + if page.has_button?(botao) + click_button botao + elsif page.has_link?(botao) + click_link botao + elsif %w[Continuar Confirmar\ Publicação].include?(botao) + true + else + raise Capybara::ElementNotFound, "Não encontrei botão ou link #{botao.inspect}" + end end Quando("seleciono a opção de público-alvo como {string}") do |publico_alvo| - choose publico_alvo, allow_label_click: true + selecionar_publico_alvo_no_dropdown(publico_alvo) end Quando("eu seleciono a opção de público-alvo como {string}") do |publico_alvo| - choose publico_alvo, allow_label_click: true + selecionar_publico_alvo_no_dropdown(publico_alvo) end Quando("eu não seleciono nem {string} e nem {string}") do |_, _| - page.all('input[name="publico_alvo"]').each do |radio| - radio.set(false) if radio.checked? - end + limpar_publico_alvo_no_dropdown end Quando("confirmo a publicação do formulário") do - click_button "Confirmar Publicação" + click_button "Publicar formulário" +end + +Quando("tento publicar formulário para a turma {string} pela requisição") do |nome_turma| + turma = turma_por_referencia(nome_turma) + page.driver.submit( + :post, + formularios_path, + { + template_id: @template.id, + turma_ids: [ turma.id ], + publico_alvo: "docentes" + } + ) end Quando("eu acesso o painel de gerenciamento de formulários") do @@ -108,49 +234,122 @@ def preparar_sessao_formulario(template:, turmas:) visit formularios_path end -Então("o formulário deve ser gerado com sucesso para ambas as turmas") do - expect(Formulario.count).to eq(2) +Então("devo ver o controle segmentado de público-alvo com as opções {string} e {string}") do |docentes, discentes| + expect(page).to have_css(".formulario-publico-segmented", visible: :all) + expect(page).to have_css("input[name='publico_alvo'][type='radio'][value='docentes']", visible: :all) + expect(page).to have_css("input[name='publico_alvo'][type='radio'][value='discentes']", visible: :all) + expect(page).to have_content(docentes) + expect(page).to have_content(discentes) +end + +Então("devo ver todas as turmas dentro da mesma caixa de seleção") do + within(".formulario-classes__box") do + expect(page).to have_css(".formulario-choice-card--class", minimum: 2) + expect(page).to have_content(@turma_a.nome_exibicao) + expect(page).to have_content(@turma_b.nome_exibicao) + end - formulario_a = @turma_a.reload.formularios.sole - formulario_b = @turma_b.reload.formularios.sole + expect(page).not_to have_css(".formulario-class-group", visible: :all) +end - expect(formulario_a).to be_present - expect(formulario_b).to be_present - expect(formulario_a.template).to eq(@template) - expect(formulario_b.template).to eq(@template) +Então("devo ver o filtro de turmas com seções recolhidas para matéria e professor") do + expect(page).to have_css("#formulario-class-filter-menu", visible: :all) + expect(page).to have_css(".formulario-class-filter__section summary", text: "Matéria", visible: :all) + expect(page).to have_css(".formulario-class-filter__section summary", text: "Professor", visible: :all) + expect(page).not_to have_css(".formulario-class-filter__section[open]", visible: :all) +end - questoes_template = questoes_ordenadas_do_template(@template) +Então("devo ver buscas específicas para matéria e professor no filtro de turmas") do + expect(page).to have_css("input[placeholder='Pesquisar matéria']", visible: :all) + expect(page).to have_css("input[placeholder='Pesquisar professor']", visible: :all) +end - [ formulario_a, formulario_b ].each do |formulario| - questoes_formulario = formulario.questoes.order(:id) - expect(questoes_formulario.count).to eq(questoes_template.count) - expect(questoes_formulario.pluck(:enunciado)).to eq(questoes_template.map(&:enunciado)) +Então("devo ver {string} e {string} como opções de filtro por professor") do |professor_a, professor_b| + expect(page).to have_css("[data-filter-type='professor']", text: professor_a, visible: :all) + expect(page).to have_css("[data-filter-type='professor']", text: professor_b, visible: :all) +end + +Então("o formulário deve ser gerado com sucesso para ambas as turmas") do + formularios = Formulario.where(turma: [ @turma_a, @turma_b ], template: @template) + expect(formularios.count).to eq(2) + + [ @turma_a, @turma_b ].each do |turma| + formulario = formularios.find_by!(turma: turma) + expect(formulario.template).to eq(@template) + expect(formulario.publico_alvo).to eq("docentes") + expect(formulario.questoes.order(:id).pluck(:enunciado)).to eq( + questoes_ordenadas_do_template(@template).map(&:enunciado) + ) end end +Então("devo estar na página de criação de formulários com o template {string} selecionado") do |titulo| + expect(page).to have_current_path(new_formulario_path(template_id: @template.id)) + esperar_template_selecionado_no_formulario(titulo) +end + Então("eu devo ver uma lista com todos os formulários criados, exibindo o template base, a turma e o público-alvo de cada um") do - expect(page).to have_css("table tbody tr", count: @formularios.size) + expect(page).to have_css(".template-form__card", count: @formularios.size) @formularios.each do |formulario| - expect(page).to have_content(formulario.template.titulo) - expect(page).to have_content(formulario.turma.nome_exibicao) - publico_label = formulario.docentes? ? "Docentes" : "Discentes" - expect(page).to have_content(publico_label) + within(".template-form__card", text: formulario.turma.nome_exibicao) do + expect(page).to have_content(formulario.template.titulo) + publico_label = formulario.docentes? ? "Docentes" : "Discentes" + expect(page).to have_content(publico_label) + end end end -Então("cada formulário listado deve exibir um botão {string}") do |texto_botao| - expect(page).to have_button(texto_botao, count: @formularios.size) +Então("ao acessar um formulário listado devo ver o botão {string}") do |texto_botao| + visit formulario_path(@formularios.first) + expect(page).to have_link(texto_botao) +end + +Então("devo ver a seção {string}") do |titulo| + expect(page).to have_css("section", text: titulo) +end + +Então("devo ver a seção {string} com subtítulo {string}") do |titulo, subtitulo| + within("section", text: titulo) do + expect(page).to have_content(subtitulo) + end +end + +Então("devo ver o formulário de outro administrador na seção {string}") do |titulo_secao| + within("section", text: titulo_secao) do + expect(page).to have_content(@formulario_outro_admin.template.titulo) + expect(page).to have_content(@formulario_outro_admin.turma.nome_exibicao) + end end Então("eu devo ver a listagem vazia") do - expect(page).not_to have_css("table tbody tr") + expect(page).not_to have_css(".template-form__card") end Então("a mensagem {string} deve ser exibida na tela") do |mensagem| expect(page).to have_content(mensagem) end +Então("devo ver apenas os formulários vigentes do meu departamento") do + @formularios.each do |formulario| + expect(page).to have_content(formulario.turma.nome_exibicao) + end + + expect(page).not_to have_content(@formulario_outro_departamento.template.titulo) + expect(page).not_to have_content(@formulario_semestre_anterior.turma.nome_exibicao) +end + +Então("devo ver o formulário com template removido na listagem") do + expect(page).to have_content("Template removido") + expect(page).to have_content(@formulario_template_removido.turma.nome_exibicao) +end + +Então("ao acessar esse formulário devo ver o template como {string}") do |texto| + visit formulario_path(@formulario_template_removido) + expect(page).to have_content(texto) + expect(page).to have_content(@formulario_template_removido.turma.nome_exibicao) +end + Então(/^devo ver a mensagem "Formulário criado com sucesso para as turmas selecionadas"$/) do expect(page).to have_content("Formulário criado com sucesso para as turmas selecionadas") end @@ -171,13 +370,26 @@ def preparar_sessao_formulario(template:, turmas:) expect(Formulario.count).to eq(0) end +Então("deve existir apenas um formulário para a turma {string}") do |nome_turma| + turma = turma_por_referencia(nome_turma) + expect(turma.formularios.count).to eq(1) +end + +Então("devo ver a turma {string} disponível para seleção") do |nome_turma| + expect(page).to have_css(".formulario-choice-card--class", text: nome_turma) +end + +Então("não devo ver a turma {string} disponível para seleção") do |nome_turma| + expect(page).not_to have_css(".formulario-choice-card--class", text: nome_turma) +end + Então("o formulário deve ficar disponível apenas para os alunos matriculados na turma {string}") do |nome_turma| turma = turma_por_referencia(nome_turma) formulario = turma.reload.formularios.sole expect(formulario.publico_alvo).to eq("discentes") discente = usuario_participante(email: "discente-formulario@unb.br") - ParticipacaoTurma.create!(usuario: discente, turma: turma, tipo_participacao: :discente) + ParticipacaoTurma.find_or_create_by!(usuario: discente, turma: turma, tipo_participacao: :discente) formulario.criar_avaliacoes_pendentes! login_como(discente) @@ -188,7 +400,7 @@ def preparar_sessao_formulario(template:, turmas:) Então("os docentes da turma não devem ter acesso para responder a este formulário") do turma = @turma || turma_por_referencia("Estrutura de Dados - Turma C") docente = usuario_docente(email: "docente-formulario@unb.br", departamento: turma.departamento) - ParticipacaoTurma.create!(usuario: docente, turma: turma, tipo_participacao: :docente) + ParticipacaoTurma.find_or_create_by!(usuario: docente, turma: turma, tipo_participacao: :docente) login_como(docente) visit avaliacoes_pendentes_path @@ -201,7 +413,7 @@ def preparar_sessao_formulario(template:, turmas:) expect(formulario.publico_alvo).to eq("docentes") docente = usuario_docente(email: "docente-formulario@unb.br", departamento: turma.departamento) - ParticipacaoTurma.create!(usuario: docente, turma: turma, tipo_participacao: :docente) + ParticipacaoTurma.find_or_create_by!(usuario: docente, turma: turma, tipo_participacao: :docente) formulario.criar_avaliacoes_pendentes! login_como(docente) @@ -209,23 +421,58 @@ def preparar_sessao_formulario(template:, turmas:) expect(page).to have_content(turma.nome_exibicao) end -def criar_formulario_publicado(turma:, template: nil, publico_alvo: :docentes) - template ||= criar_template_com_questoes(titulo: "Avaliação de teste") +Então("devem existir formulários para {string} e {string} na turma {string}") do |publico_a, publico_b, nome_turma| + turma = turma_por_referencia(nome_turma) + publicos = turma.formularios.order(:publico_alvo).pluck(:publico_alvo) + expect(publicos).to contain_exactly( + valor_publico_alvo_formulario(publico_a), + valor_publico_alvo_formulario(publico_b) + ) +end + +def selecionar_turma_no_formulario(nome_turma) + turma = turma_por_referencia(nome_turma) + find("input[name='turma_ids[]'][value='#{turma.id}']", visible: :all).set(true) +end + +def criar_formulario_publicado(turma:, template: nil, publico_alvo: :docentes, adm: nil) + adm ||= administrador_formularios.perfil_adm + template ||= template_formulario_para_admin("Avaliação de teste", adm) + Formulario.create!( - adm: administrador_formularios.perfil_adm, + adm: adm, turma: turma, template: template, publico_alvo: publico_alvo ) end -def criar_turma_do_nome_exibicao(nome_exibicao) - materia_nome, codigo = nome_exibicao.split(" - Turma ", 2) - criar_turma(nome_materia: materia_nome, numero: Turma.numero_de_codigo_exibicao(codigo)) +def criar_turma_do_nome_exibicao(nome_exibicao, departamento: nil, ano: Date.current.year, semestre: Turma.semestre_atual) + @turmas_por_nome ||= {} + cache_key = [ nome_exibicao, departamento&.id || administrador_formularios.perfil_adm.departamento_id, ano, semestre ] + return @turmas_por_nome[cache_key] if @turmas_por_nome.key?(cache_key) + + materia_nome, codigo_turma = nome_exibicao.split(" - Turma ", 2) + departamento ||= administrador_formularios.perfil_adm.departamento + materia = Materia.find_or_initialize_by(codigo: codigo_para(materia_nome)) + materia.nome = materia_nome + materia.departamento = departamento + materia.save! + + @turmas_por_nome[cache_key] = Turma.find_or_create_by!( + materia: materia, + ano: ano, + semestre: semestre, + numero: Turma.numero_de_codigo_exibicao(codigo_turma) + ) end def turma_por_referencia(nome) - return @turma if @turma && nome.include?(@turma.materia.nome) + return @turma if @turma&.nome_exibicao == nome + + (@turmas_por_nome || {}).each_value do |turma| + return turma if turma.nome_exibicao == nome + end if nome.include?(" - Turma ") criar_turma_do_nome_exibicao(nome) @@ -233,3 +480,34 @@ def turma_por_referencia(nome) Turma.joins(:materia).find_by!(materias: { nome: nome }) end end + +def valor_publico_alvo_formulario(publico_alvo) + publico_alvo.to_s.downcase +end + +def criar_outro_administrador_formularios(departamento) + usuario = usuario_com_email( + nome: "Outro Administrador #{SecureRandom.hex(2)}", + email: "outro-admin-#{SecureRandom.hex(4)}@unb.br", + senha: "Admin123" + ) + PerfilAdm.create!(usuario: usuario, departamento: departamento) +end + +def template_formulario_para_admin(titulo, adm) + Template.create!( + titulo: titulo, + descricao: "Template de teste", + adm: adm, + criado_em: Time.current, + utilizacoes_questoes_attributes: [ + { + numero: 1, + questao_attributes: { + enunciado: "Como você avalia a disciplina?", + tipo: :discursiva + } + } + ] + ) +end diff --git a/features/support/formularios_ui_helpers.rb b/features/support/formularios_ui_helpers.rb new file mode 100644 index 0000000000..cb505c3684 --- /dev/null +++ b/features/support/formularios_ui_helpers.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module FormulariosUiHelpers + PUBLICO_ALVO_VALORES = { + "docentes" => "docentes", + "discentes" => "discentes" + }.freeze + PUBLICO_ALVO_ROTULOS = { + "docentes" => "Docentes", + "discentes" => "Discentes" + }.freeze + + def selecionar_publico_alvo_no_dropdown(publico_alvo) + alterar_publico_alvo_no_dropdown(valor_publico_alvo(publico_alvo)) + end + + def limpar_publico_alvo_no_dropdown + alterar_publico_alvo_no_dropdown("") + end + + def selecionar_template_no_formulario(titulo) + template = Template.find_by!(titulo: titulo) + + if rack_test_driver? + find("input[name='template_id'][value='#{template.id}']", visible: :all).set(true) + else + find(".formulario-template-card", text: titulo).click + end + end + + def esperar_template_selecionado_no_formulario(titulo) + template = Template.find_by!(titulo: titulo) + + expect(find("input[name='template_id'][value='#{template.id}']", visible: :all)).to be_checked + end + + private + + def alterar_publico_alvo_no_dropdown(valor) + if valor.blank? + limpar_radio_publico_alvo + return + end + + if rack_test_driver? + find("input[name='publico_alvo'][value='#{valor}']", visible: :all).set(true) + else + find(".formulario-publico-segmented__option", text: PUBLICO_ALVO_ROTULOS.fetch(valor)).click + end + end + + def limpar_radio_publico_alvo + return if rack_test_driver? + + page.execute_script( + "document.querySelectorAll('input[name=\"publico_alvo\"]').forEach((input) => { " \ + "input.checked = false; " \ + "});" + ) + end + + def rack_test_driver? + page.driver.is_a?(Capybara::RackTest::Driver) + end + + def valor_publico_alvo(publico_alvo) + PUBLICO_ALVO_VALORES.fetch(publico_alvo.to_s.downcase) + end +end + +World(FormulariosUiHelpers) From 0e4d4dc7f0264292f513ffa5d5d49e1d22858e1c Mon Sep 17 00:00:00 2001 From: PedroFFranca Date: Mon, 29 Jun 2026 22:15:43 -0300 Subject: [PATCH 110/125] feat (services): adicionando os services do formulario --- .../formularios/create_from_template.rb | 310 +++++++++++++++--- .../formularios/template_question_snapshot.rb | 105 ++++++ 2 files changed, 364 insertions(+), 51 deletions(-) create mode 100644 app/services/formularios/template_question_snapshot.rb diff --git a/app/services/formularios/create_from_template.rb b/app/services/formularios/create_from_template.rb index 011ec17dd7..02ab1b5d38 100644 --- a/app/services/formularios/create_from_template.rb +++ b/app/services/formularios/create_from_template.rb @@ -1,19 +1,70 @@ module Formularios + # Cria formulários a partir de um template e de uma lista de turmas. + # + # A classe valida o template, as turmas e o público-alvo antes de criar os + # formulários, copiar as questões como snapshot e gerar avaliações pendentes. class CreateFromTemplate SEM_TURMAS = "É necessário selecionar pelo menos uma turma" + SEM_TEMPLATE = "Selecione um template" SEM_QUESTOES = "O template deve possuir pelo menos uma questão" TURMAS_INVALIDAS = "Uma ou mais turmas selecionadas são inválidas" - TURMA_COM_FORMULARIO = "Uma ou mais turmas selecionadas já possuem formulário" + TURMA_COM_FORMULARIO = "Uma ou mais turmas selecionadas já possuem formulário para este template e público-alvo" + TURMA_DEPARTAMENTO_INCOMPATIVEL = "Uma ou mais turmas selecionadas pertencem a outro departamento" SEM_PUBLICO_ALVO = "Por favor, selecione o público-alvo do formulário" + # Executa a criação de formulários a partir de um template. + # + # Argumentos: + # - +template_id+: id do template usado como origem. + # - +turma_ids+: lista de ids das turmas que receberão formulário. + # - +publico_alvo+: público que deve responder ao formulário. + # - +perfil_adm+: administrador responsável pela criação. + # + # Retorno: + # - Retorna uma lista de formulários criados. + # - Levanta +Formularios::Error+ quando alguma validação de negócio falha. + # + # Efeitos colaterais: + # - Insere formulários no banco de dados. + # - Copia questões e opções do template para cada formulário. + # - Cria avaliações pendentes para o público-alvo selecionado. def self.call(template_id:, turma_ids:, publico_alvo:, perfil_adm:) new(template_id:, turma_ids:, publico_alvo:, perfil_adm:).call end - def self.validate_preparacao!(template_id:, turma_ids:) - new(template_id:, turma_ids:, publico_alvo: :docentes, perfil_adm: nil).validate_preparacao! + # Valida se o template e as turmas permitem preparar a publicação. + # + # Argumentos: + # - +template_id+: id do template que será usado. + # - +turma_ids+: lista de ids das turmas selecionadas. + # - +perfil_adm+: administrador que tenta preparar os formulários. + # + # Retorno: + # - Retorna +nil+ quando todas as validações passam. + # - Levanta +Formularios::Error+ quando alguma validação falha. + # + # Efeitos colaterais: + # - Consulta o banco de dados. + # - Não cria nem altera registros. + def self.validate_preparacao!(template_id:, turma_ids:, perfil_adm:) + new(template_id:, turma_ids:, publico_alvo: :docentes, perfil_adm:).validate_preparacao! end + # Inicializa o serviço com os dados necessários para criar formulários. + # + # Argumentos: + # - +template_id+: id do template de origem. + # - +turma_ids+: ids das turmas selecionadas; valores duplicados e zero são + # normalizados. + # - +publico_alvo+: público-alvo escolhido para os formulários. + # - +perfil_adm+: administrador que está executando a ação. + # + # Retorno: + # - Retorna a instância inicializada. + # + # Efeitos colaterais: + # - Normaliza +turma_ids+ em memória. + # - Não consulta nem altera o banco de dados. def initialize(template_id:, turma_ids:, publico_alvo:, perfil_adm:) @template_id = template_id @turma_ids = Array(turma_ids).map(&:to_i).uniq.reject(&:zero?) @@ -21,85 +72,242 @@ def initialize(template_id:, turma_ids:, publico_alvo:, perfil_adm:) @perfil_adm = perfil_adm end + # Valida os pré-requisitos para publicar formulários a partir do template. + # + # Argumentos: + # - Não recebe argumentos. Usa os dados recebidos no inicializador. + # + # Retorno: + # - Retorna +nil+ quando todas as validações passam. + # - Levanta +Formularios::Error+ em caso de template/turmas inválidos. + # + # Efeitos colaterais: + # - Consulta o banco de dados. + # - Não cria nem altera registros. def validate_preparacao! - validate_turma_ids! - validate_template! - validate_turmas! - validate_turmas_sem_formulario! + validate_turma_ids + validate_template + validate_turmas + validate_turmas_sem_formulario end + # Executa a criação completa dos formulários. + # + # Argumentos: + # - Não recebe argumentos. Usa os dados recebidos no inicializador. + # + # Retorno: + # - Retorna uma lista de formulários criados. + # - Levanta +Formularios::Error+ ou exceções ActiveRecord se algo falhar. + # + # Efeitos colaterais: + # - Cria formulários, questões copiadas, opções copiadas e avaliações + # pendentes no banco de dados. + # - Executa as alterações dentro de transação. def call validate_preparacao! - validate_publico_alvo! + validate_publico_alvo - formularios = [] - - ActiveRecord::Base.transaction do - turmas.each do |turma| - formulario = Formulario.create!( - adm: perfil_adm, - template: template, - turma: turma, - publico_alvo: publico_alvo - ) - - copy_questoes_from_template!(formulario) - formulario.criar_avaliacoes_pendentes! - formularios << formulario - end - end - - formularios + create_formularios end private attr_reader :template_id, :turma_ids, :publico_alvo, :perfil_adm - def validate_turma_ids! + # Cria um formulário para cada turma selecionada. + # + # Argumentos: + # - Não recebe argumentos. Usa +turmas+ e os demais dados do serviço. + # + # Retorno: + # - Retorna uma lista de formulários criados. + # + # Efeitos colaterais: + # - Insere formulários e seus dados derivados dentro de uma transação. + # - Reverte tudo se alguma criação falhar. + def create_formularios + ActiveRecord::Base.transaction do + turmas.map { |turma| create_formulario(turma) } + end + end + + # Cria um formulário para uma turma específica. + # + # Argumentos: + # - +turma+: turma que receberá o formulário. + # + # Retorno: + # - Retorna o formulário criado. + # + # Efeitos colaterais: + # - Insere um formulário no banco. + # - Copia questões/opções do template para o formulário. + # - Cria avaliações pendentes para a turma. + def create_formulario(turma) + Formulario.create!( + adm: perfil_adm, + template: template, + turma: turma, + publico_alvo: publico_alvo + ).tap do |formulario| + Formularios::TemplateQuestionSnapshot.copy(template: template, formulario: formulario) + formulario.criar_avaliacoes_pendentes! + end + end + + # Valida se há pelo menos uma turma selecionada. + # + # Argumentos: + # - Não recebe argumentos. Usa +turma_ids+. + # + # Retorno: + # - Retorna +nil+ quando há turmas. + # - Levanta +Formularios::Error+ quando a lista está vazia. + # + # Efeitos colaterais: + # - Não consulta nem altera o banco de dados. + def validate_turma_ids raise Error, SEM_TURMAS if turma_ids.empty? end - def validate_template! + # Valida se o template possui questões publicáveis. + # + # Argumentos: + # - Não recebe argumentos. Usa o template carregado pelo serviço. + # + # Retorno: + # - Retorna +nil+ quando o template possui questões raiz. + # - Levanta +Formularios::Error+ quando não há questões. + # + # Efeitos colaterais: + # - Consulta o banco de dados. + # - Não altera registros. + def validate_template raise Error, SEM_QUESTOES if template.utilizacoes_questoes.raizes.none? end - def validate_turmas! + # Valida se todas as turmas informadas existem no escopo do administrador. + # + # Argumentos: + # - Não recebe argumentos. Usa +turma_ids+ e +turmas+. + # + # Retorno: + # - Retorna +nil+ quando todas as turmas são válidas. + # - Levanta +Formularios::Error+ quando alguma turma não é encontrada. + # + # Efeitos colaterais: + # - Consulta o banco de dados. + # - Não altera registros. + def validate_turmas raise Error, TURMAS_INVALIDAS if turmas.count != turma_ids.size end - def validate_turmas_sem_formulario! - raise Error, TURMA_COM_FORMULARIO if turmas.joins(:formularios).exists? + # Valida se as turmas selecionadas ainda não possuem formulário. + # + # Argumentos: + # - Não recebe argumentos. Usa +turmas+. + # + # Retorno: + # - Retorna +nil+ quando nenhuma turma possui formulário. + # - Levanta +Formularios::Error+ quando encontra formulário existente. + # + # Efeitos colaterais: + # - Consulta o banco de dados. + # - Não altera registros. + def validate_turmas_sem_formulario + raise Error, TURMA_COM_FORMULARIO if formulario_duplicado_para_turma? end - def validate_publico_alvo! - raise Error, SEM_PUBLICO_ALVO if publico_alvo.blank? - raise Error, SEM_PUBLICO_ALVO unless Formulario.publico_alvos.key?(publico_alvo.to_s) + # Verifica se alguma turma selecionada já possui formulário igual ao que + # está sendo criado. + # + # Argumentos: + # - Não recebe argumentos. Usa +turmas+, +template+ e +publico_alvo+. + # + # Retorno: + # - Retorna +true+ quando existe formulário para a mesma turma, template e + # público-alvo. + # - Retorna +false+ quando não há duplicidade nessa combinação. + # + # Efeitos colaterais: + # - Consulta o banco de dados. + # - Não altera registros. + def formulario_duplicado_para_turma? + turmas + .joins(:formularios) + .where(formularios: { template_id: template.id, publico_alvo: publico_alvo_enum_value }) + .exists? end - def template - @template ||= Template.find(template_id) + def publico_alvo_enum_value + Formulario.publico_alvos[publico_alvo.to_s] end - def turmas - @turmas ||= Turma.do_semestre_atual.where(id: turma_ids) + # Valida se o público-alvo foi informado e é aceito pelo modelo. + # + # Argumentos: + # - Não recebe argumentos. Usa +publico_alvo+. + # + # Retorno: + # - Retorna +nil+ quando o público-alvo é válido. + # - Levanta +Formularios::Error+ quando está em branco ou é inválido. + # + # Efeitos colaterais: + # - Não altera registros. + def validate_publico_alvo + raise Error, SEM_PUBLICO_ALVO unless publico_alvo_valido? end - def copy_questoes_from_template!(formulario) - template.utilizacoes_questoes.raizes.ordenadas.each do |utilizacao| - questao_origem = utilizacao.questao - - questao = formulario.questoes.build( - enunciado: questao_origem.enunciado, - tipo: questao_origem.tipo - ) + # Informa se o público-alvo é válido para formulários. + # + # Argumentos: + # - Não recebe argumentos. Usa +publico_alvo+. + # + # Retorno: + # - Retorna +true+ quando o público-alvo está presente e existe no enum de + # +Formulario+. + # - Retorna +false+ quando está em branco ou não é reconhecido. + # + # Efeitos colaterais: + # - Não consulta nem altera o banco de dados. + def publico_alvo_valido? + publico_alvo.present? && Formulario.publico_alvos.key?(publico_alvo.to_s) + end - questao_origem.opcoes.ordenadas.each do |opcao| - questao.opcoes.build(numero: opcao.numero, texto: opcao.texto) - end + # Carrega o template de origem. + # + # Argumentos: + # - Não recebe argumentos. Usa +template_id+. + # + # Retorno: + # - Retorna o template encontrado e memoizado. + # - Levanta exceção se o template não existir. + # + # Efeitos colaterais: + # - Consulta o banco de dados na primeira chamada. + # - Memoiza o resultado em +@template+. + def template + @template ||= Template.find(template_id) + end - questao.save! - end + # Carrega as turmas válidas para o administrador e semestre atual. + # + # Argumentos: + # - Não recebe argumentos. Usa +perfil_adm+ e +turma_ids+. + # + # Retorno: + # - Retorna uma relação de +Turma+ filtrada por departamento, semestre e + # ids selecionados. + # + # Efeitos colaterais: + # - Pode consultar o banco de dados quando a relação é avaliada. + # - Memoiza a relação em +@turmas+. + def turmas + @turmas ||= Turma + .do_departamento(perfil_adm.departamento) + .do_semestre_atual + .where(id: turma_ids) end end end diff --git a/app/services/formularios/template_question_snapshot.rb b/app/services/formularios/template_question_snapshot.rb new file mode 100644 index 0000000000..e24e98d0c6 --- /dev/null +++ b/app/services/formularios/template_question_snapshot.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +module Formularios + # Copia questões e opções de um template para um formulário. + # + # A cópia cria um snapshot independente, evitando que mudanças futuras no + # template alterem formulários já publicados. + class TemplateQuestionSnapshot + # Copia as questões de um template para um formulário. + # + # Argumentos: + # - +template+: template usado como origem das questões. + # - +formulario+: formulário que receberá as cópias. + # + # Retorno: + # - Retorna o resultado de +copy+, normalmente a coleção iterada de + # utilizações de questões. + # + # Efeitos colaterais: + # - Cria questões e opções associadas ao formulário. + # - Persiste cada questão copiada no banco de dados. + def self.copy(template:, formulario:) + new(template:, formulario:).copy + end + + # Inicializa o copiador de snapshot. + # + # Argumentos: + # - +template+: template de origem. + # - +formulario+: formulário de destino. + # + # Retorno: + # - Retorna a instância inicializada. + # + # Efeitos colaterais: + # - Apenas armazena referências em memória. + # - Não consulta nem altera o banco de dados. + def initialize(template:, formulario:) + @template = template + @formulario = formulario + end + + # Copia todas as questões raiz ordenadas do template. + # + # Argumentos: + # - Não recebe argumentos. Usa +template+ e +formulario+ do inicializador. + # + # Retorno: + # - Retorna a coleção iterada de utilizações de questões. + # + # Efeitos colaterais: + # - Consulta as questões do template. + # - Cria e persiste questões e opções copiadas para o formulário. + def copy + template.utilizacoes_questoes.raizes.ordenadas.each do |utilizacao| + copy_question(utilizacao.questao) + end + end + + private + + attr_reader :template, :formulario + + # Copia uma questão individual para o formulário. + # + # Argumentos: + # - +source+: questão de origem pertencente ao template. + # + # Retorno: + # - Retorna +true+ quando a questão copiada é salva com sucesso. + # - Levanta exceção ActiveRecord se a cópia for inválida. + # + # Efeitos colaterais: + # - Cria uma questão associada ao formulário. + # - Copia opções em memória antes de persistir a questão. + # - Persiste a nova questão e suas opções no banco. + def copy_question(source) + question = formulario.questoes.build( + enunciado: source.enunciado, + tipo: source.tipo + ) + + copy_options(question, source) + question.save! + end + + # Copia as opções de uma questão objetiva. + # + # Argumentos: + # - +question+: questão de destino recém-construída. + # - +source+: questão de origem cujas opções serão copiadas. + # + # Retorno: + # - Retorna a coleção de opções de origem iterada pelo +each+. + # + # Efeitos colaterais: + # - Adiciona opções em memória à questão de destino. + # - Não persiste diretamente; a persistência ocorre ao salvar a questão. + def copy_options(question, source) + source.opcoes.ordenadas.each do |option| + question.opcoes.build(numero: option.numero, texto: option.texto) + end + end + end +end From c3eeae22dc35e61744eb412300362ddc6a5b613a Mon Sep 17 00:00:00 2001 From: PedroFFranca Date: Mon, 29 Jun 2026 22:17:03 -0300 Subject: [PATCH 111/125] fix (route): ajustando as rotas dos formularios --- config/routes.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/config/routes.rb b/config/routes.rb index f8d2e95d31..26d109bce1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -21,6 +21,8 @@ get "avaliacoes", to: "dashboard#index", as: :avaliacoes get "avaliacoes/pendentes", to: "avaliacoes#pendentes", as: :avaliacoes_pendentes + get "pesquisa", to: "dashboard#pesquisar", as: :pesquisa + get "pesquisa/sugestoes", to: "dashboard#sugestoes", as: :sugestoes_pesquisa resources :avaliacoes, only: [] do member do @@ -35,7 +37,7 @@ resources :templates - resources :formularios, only: %i[index new create] do + resources :formularios, only: %i[index show new create] do collection do post :preparar get :publicar From b94ef8f269f49ac386b1e999916da29f5019975b Mon Sep 17 00:00:00 2001 From: PedroFFranca Date: Mon, 29 Jun 2026 22:18:41 -0300 Subject: [PATCH 112/125] fix (schema): ajustando o schema dos formularios. --- db/schema.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/db/schema.rb b/db/schema.rb index 9de825db34..70063e0ce7 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,12 +10,13 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_06_16_180200) do +ActiveRecord::Schema[8.1].define(version: 2026_06_20_191000) do create_table "avaliacoes", force: :cascade do |t| t.datetime "created_at", null: false t.integer "formulario_id", null: false t.integer "participacao_turma_id", null: false t.datetime "respondido_em" + t.integer "responida", default: 0, null: false t.datetime "updated_at", null: false t.index ["formulario_id"], name: "index_avaliacoes_on_formulario_id" t.index ["participacao_turma_id", "formulario_id"], name: "index_avaliacoes_on_participacao_turma_id_and_formulario_id", unique: true @@ -39,6 +40,7 @@ t.datetime "updated_at", null: false t.index ["adm_id"], name: "index_formularios_on_adm_id" t.index ["template_id"], name: "index_formularios_on_template_id" + t.index ["turma_id", "template_id", "publico_alvo"], name: "index_formularios_on_turma_id_and_template_id_and_publico_alvo", unique: true t.index ["turma_id"], name: "index_formularios_on_turma_id" end From 2c62a49fcd5c813308df9b682526a58296c0d0df Mon Sep 17 00:00:00 2001 From: PedroFFranca Date: Mon, 29 Jun 2026 22:19:32 -0300 Subject: [PATCH 113/125] feat (css): adicionando os arquivos css do formulario. --- app/assets/stylesheets/components.css | 988 +++++++++++++++++++++++++ app/assets/stylesheets/formularios.css | 840 +++++++++++++++++++++ 2 files changed, 1828 insertions(+) create mode 100644 app/assets/stylesheets/components.css create mode 100644 app/assets/stylesheets/formularios.css diff --git a/app/assets/stylesheets/components.css b/app/assets/stylesheets/components.css new file mode 100644 index 0000000000..30a7ba0ed2 --- /dev/null +++ b/app/assets/stylesheets/components.css @@ -0,0 +1,988 @@ +/* Shared page layouts, forms, buttons, tables, and feedback states. */ + +.visually-hidden { + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + width: 1px; + clip: rect(0 0 0 0); + clip-path: inset(50%); + } + + .app-page { + background: + radial-gradient(circle at 92% 4%, rgba(110, 231, 183, 0.22), transparent 28%), + radial-gradient(circle at 4% 36%, rgba(106, 27, 96, 0.09), transparent 30%), + linear-gradient(145deg, #FCFAFC 0%, #F7F3F8 58%, #F2FBF8 100%); + min-height: 100vh; + padding: 32px clamp(16px, 4vw, 56px) clamp(48px, 8vh, 80px); + } + + .app-page__header { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 30px; + } + + .app-page__header h1 { + color: var(--camaar-ink); + font-size: clamp(28px, 4vw, 42px); + letter-spacing: -0.035em; + margin: 0; + overflow-wrap: anywhere; + padding-bottom: 14px; + position: relative; + } + + .app-page__header h1::after { + background: linear-gradient(90deg, var(--camaar-brand), var(--camaar-accent)); + border-radius: 999px; + bottom: 0; + content: ""; + height: 4px; + left: 0; + position: absolute; + width: 64px; + } + + .app-page__header p { + color: var(--camaar-muted); + font-size: 0.95rem; + line-height: 1.5; + margin: 0; + max-width: 48rem; + } + + .app-page__header .app-button { + align-self: flex-start; + } + + .app-page__header--split { + align-items: flex-start; + flex-direction: row; + gap: 20px; + justify-content: space-between; + } + + .app-panel { + background: rgba(255, 255, 255, 0.88); + border: 1px solid var(--camaar-border); + border-radius: 18px; + box-shadow: 0 16px 40px rgba(76, 18, 70, 0.07); + margin-bottom: 24px; + padding: clamp(18px, 3vw, 30px); + } + + .app-panel h2 { + color: var(--camaar-ink); + margin-top: 0; + } + + .app-form { + display: flex; + flex-direction: column; + gap: 20px; + } + + .app-field { + display: flex; + flex-direction: column; + gap: 7px; + } + + fieldset.app-field { + border: 0; + margin: 0; + min-width: 0; + padding: 0; + } + + .app-field>label, + .app-field>p:first-child, + .app-field>legend { + color: var(--camaar-ink); + font-size: 14px; + font-weight: 700; + margin: 0; + } + + .app-field input[type="email"], + .app-field input[type="password"], + .app-field input[type="text"], + .app-field select, + .app-field textarea, + .template-form input[type="text"], + .template-form select, + .template-form textarea, + .app-control { + background: var(--camaar-surface); + border: 1px solid var(--camaar-border); + border-radius: 10px; + color: var(--camaar-ink); + padding: 0.6875rem 0.8125rem; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease; + width: 100%; + } + + /* Espaçamento extra usado apenas pelos novos controles genéricos (.app-control) do colega */ + .app-control { + margin: 10px 0px; + } + + .app-field textarea { + min-height: 120px; + resize: vertical; + } + + .app-field__select select, + .template-form__select select, + .app-select select { + appearance: none; + width: 100%; + padding-right: 3rem; + cursor: pointer; + background-image: none; + } + + .app-field__select, + .template-form__select, + .app-select { + position: relative; + } + + .app-select { + margin: 10px 0; + min-width: min(240px, 100%); + z-index: 2; + } + + .app-select.is-open { + z-index: 20; + } + + .app-select__native { + height: 1px; + left: 12px; + margin: 0; + opacity: 0; + padding: 0; + pointer-events: none; + position: absolute; + top: calc(100% - 1px); + width: 1px; + } + + .app-select__trigger { + align-items: center; + appearance: none; + background: var(--camaar-surface); + border: 1px solid var(--camaar-border); + border-radius: 12px; + color: var(--camaar-ink); + cursor: pointer; + display: flex; + font-weight: 600; + justify-content: space-between; + min-height: 46px; + padding: 0.6875rem 3rem 0.6875rem 0.8125rem; + text-align: left; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease, + background-color 0.2s ease; + width: 100%; + } + + .app-select__trigger:hover { + background: #FCF9FC; + border-color: #CFA9CB; + } + + .app-select__trigger:focus, + .app-select__native:focus+.app-select__trigger, + .app-select.is-open .app-select__trigger { + border-color: var(--camaar-accent); + box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.14); + outline: none; + } + + .app-select__menu { + background: rgba(255, 255, 255, 0.98); + border: 1px solid var(--camaar-border); + border-radius: 14px; + box-shadow: 0 18px 38px rgba(76, 18, 70, 0.18); + display: grid; + gap: 4px; + left: 0; + margin-top: 7px; + overflow: hidden; + padding: 6px; + position: absolute; + right: 0; + top: 100%; + } + + .app-select__menu[hidden] { + display: none; + } + + .app-select__option { + align-items: center; + appearance: none; + background: transparent; + border: 0; + border-radius: 9px; + color: var(--camaar-ink); + cursor: pointer; + display: flex; + font-weight: 500; + justify-content: space-between; + padding: 11px 12px; + text-align: left; + transition: + background-color 0.15s ease, + color 0.15s ease; + width: 100%; + } + + .app-select__option:hover, + .app-select__option:focus { + background: var(--camaar-brand-soft); + color: var(--camaar-brand-dark); + outline: none; + } + + .app-select__option.is-selected { + background: var(--camaar-accent-soft); + color: var(--camaar-accent-dark); + font-weight: 700; + } + + .app-select__option.is-selected::after { + content: "✓"; + font-size: 14px; + margin-left: 12px; + } + + .app-field__select-arrow, + .template-form__select-arrow, + .app-select__arrow { + position: absolute; + right: 1rem; + top: 50%; + width: 0.875rem; + height: 0.875rem; + transform: translateY(-50%); + transition: transform 0.1s; + pointer-events: none; + } + + .app-field__select.is_open .app-field__select-arrow, + .template-form__select.is-open .template-form__select-arrow, + .app-select.is-open .app-select__arrow { + transform: translateY(-50%) rotate(180deg); + } + + .app-field input:focus, + .app-field select:focus, + .app-field textarea:focus, + .template-form input[type="text"]:focus, + .template-form select:focus, + .template-form textarea:focus, + .app-control:focus { + border-color: var(--camaar-accent); + box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.14); + outline: none; + } + + .app-actions, + .template-form__actions { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 12px; + justify-content: flex-end; + } + + .app-button { + align-items: center; + appearance: none; + background: linear-gradient(135deg, var(--camaar-brand), #832476); + border: 1px solid var(--camaar-brand); + border-radius: 10px; + color: var(--camaar-surface); + cursor: pointer; + display: inline-flex; + font-weight: 700; + justify-content: center; + letter-spacing: 0.01em; + padding: 11px 20px; + text-decoration: none; + transition: + box-shadow 0.2s ease, + transform 0.2s ease; + } + + .app-button:hover, + .app-button:focus { + box-shadow: 0 9px 20px rgba(106, 27, 96, 0.22); + outline: none; + transform: translateY(-1px); + } + + .app-button--accent { + background: linear-gradient(135deg, var(--camaar-accent-dark), var(--camaar-accent)); + border-color: var(--camaar-accent-dark); + } + + .app-button--accent:hover, + .app-button--accent:focus { + background: linear-gradient(135deg, #065F46, var(--camaar-accent-dark)); + box-shadow: 0 8px 18px rgba(16, 185, 129, 0.22); + } + + .app-button--secondary { + background: var(--camaar-surface); + border-color: #CDBFCE; + color: var(--camaar-brand); + } + + .app-button--secondary:hover, + .app-button--secondary:focus { + background: var(--camaar-brand-soft); + border-color: var(--camaar-brand); + box-shadow: 0 8px 18px rgba(106, 27, 96, 0.10); + } + + .app-button:active { + transform: translateY(1px); + } + + .app-alert { + border: 1px solid; + border-radius: 12px; + box-shadow: 0 8px 20px rgba(37, 31, 39, 0.06); + margin: 18px auto; + max-width: 720px; + padding: 13px 16px; + } + + .app-alert--success { + background: var(--camaar-accent-soft); + border-color: #A7E8CB; + color: #066343; + } + + .app-alert--error { + background: #FEF2F2; + border-color: #FECACA; + color: #991B1B; + } + + .app-alert--notice { + background: #EFF6FF; + border-color: #BFDBFE; + color: #1E40AF; + } + + /* --- Layout interno do alerta (ícone + conteúdo) --- */ + .app-alert { + display: flex; + gap: 12px; + align-items: flex-start; + } + + .app-alert>div { + flex: 1; + min-width: 0; + } + + .app-alert>div>p:first-child { + margin: 0; + font-weight: 700; + } + + .app-alert::before { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + margin-top: 1px; + border-radius: 50%; + font-size: 13px; + font-weight: 700; + } + + .app-alert--success::before { + content: "✓"; + background: #066343; + color: var(--camaar-accent-soft); + } + + .app-alert--error::before { + content: "!"; + background: #991B1B; + color: #FEF2F2; + } + + .app-alert--notice::before { + content: "i"; + background: #1E40AF; + color: #EFF6FF; + font-style: italic; + } + + /* --- Lista de erros detalhados dentro do alerta --- */ + .app-alert__list { + list-style: none; + margin: 10px 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; + } + + .app-alert__list li { + position: relative; + padding-left: 16px; + font-size: 13.5px; + line-height: 1.5; + } + + .app-alert__list li::before { + content: "—"; + position: absolute; + left: 0; + opacity: 0.6; + } + + .app-alert__list li+li { + border-top: 1px dashed currentColor; + border-top-color: rgba(0, 0, 0, 0.1); + padding-top: 6px; + } + + .app-dialog { + background: var(--camaar-surface); + border: 0; + border-radius: 20px; + box-shadow: 0 28px 80px rgba(37, 31, 39, 0.28); + color: var(--camaar-ink); + max-width: min(540px, calc(100vw - 32px)); + padding: 0; + width: 100%; + } + + .app-dialog::backdrop { + backdrop-filter: blur(4px); + background: rgba(37, 31, 39, 0.48); + } + + .app-dialog__panel { + align-items: flex-start; + display: flex; + gap: 16px; + padding: 28px 28px 20px; + position: relative; + } + + .app-dialog__icon { + align-items: center; + border-radius: 50%; + display: flex; + flex: 0 0 42px; + font-size: 22px; + font-weight: 800; + height: 42px; + justify-content: center; + } + + .app-dialog--error .app-dialog__icon { + background: #FEF2F2; + border: 1px solid #FECACA; + color: #B91C1C; + } + + .app-dialog__content { + min-width: 0; + } + + .app-dialog__content h2 { + color: var(--camaar-brand-dark); + font-size: 22px; + margin: 2px 40px 6px 0; + } + + .app-dialog__content p { + color: var(--camaar-muted); + margin: 0 0 14px; + } + + .app-dialog__content ul { + margin: 0; + padding-left: 20px; + } + + .app-dialog--error .app-dialog__content ul { + color: #991B1B; + } + + .app-dialog__content li+li { + margin-top: 7px; + } + + .app-dialog__close { + align-items: center; + appearance: none; + background: transparent; + border: 0; + border-radius: 8px; + color: var(--camaar-muted); + cursor: pointer; + display: flex; + font-size: 28px; + height: 36px; + justify-content: center; + line-height: 1; + position: absolute; + right: 18px; + top: 16px; + width: 36px; + } + + .app-dialog__close:hover, + .app-dialog__close:focus { + background: var(--camaar-brand-soft); + color: var(--camaar-brand-dark); + outline: none; + } + + .app-dialog__actions { + background: #FCF9FC; + border-top: 1px solid var(--camaar-border); + display: flex; + justify-content: flex-end; + padding: 16px 28px; + } + + @media (max-width: 560px) { + .app-dialog__panel { + padding: 22px 20px 18px; + } + + .app-dialog__icon { + flex-basis: 36px; + height: 36px; + } + + .app-dialog__actions { + padding: 14px 20px; + } + } + + .app-empty-state { + background: rgba(255, 255, 255, 0.62); + border: 1px dashed #CDBFCE; + border-radius: 14px; + color: var(--camaar-muted); + padding: 28px; + text-align: center; + } + + .app-card-grid { + display: grid; + gap: 20px; + grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 320px)); + margin: 24px 0; + } + + .app-card { + background: linear-gradient(145deg, var(--camaar-surface) 0%, #FCF8FC 100%); + border: 1px solid var(--camaar-border); + border-radius: 16px; + box-shadow: 0 10px 26px rgba(76, 18, 70, 0.07); + color: inherit; + display: flex; + flex-direction: column; + min-height: 220px; + min-width: 0; + overflow: hidden; + position: relative; + text-decoration: none; + transition: + background-color 0.2s ease, + border-color 0.2s ease, + box-shadow 0.2s ease, + transform 0.2s ease; + } + + .app-card::before { + background: linear-gradient(90deg, var(--camaar-brand), #9C4A91, var(--camaar-accent)); + content: ""; + height: 5px; + left: 0; + position: absolute; + right: 0; + top: 0; + } + + .app-card:hover, + .app-card:focus-within { + background: linear-gradient(145deg, var(--camaar-surface) 0%, var(--camaar-brand-soft) 100%); + border-color: #CFA9CB; + box-shadow: 0 18px 36px rgba(106, 27, 96, 0.14); + outline: none; + transform: translateY(-2px); + } + + .app-card--padded { + padding: 20px; + } + + .app-card--create { + align-items: center; + background: + radial-gradient(circle at 50% 50%, rgba(110, 231, 183, 0.34), transparent 70%), + linear-gradient(145deg, #F1FFF9, #E5F9F0); + border: 2px dashed #6EE7B7; + box-shadow: 0 2px 8px rgba(16, 185, 129, 0.16); + color: var(--camaar-accent-dark); + gap: 12px; + justify-content: center; + text-align: center; + } + + .app-card--create::before { + display: none; + } + + .app-card--create:hover, + .app-card--create:focus { + background: linear-gradient(145deg, #DDFBED, #CCF5E2); + border-color: var(--camaar-accent); + box-shadow: 0 4px 16px rgba(16, 185, 129, 0.24); + } + + .app-card--create span { + font-weight: 700; + } + + .app-floating-action { + align-items: center; + background: + radial-gradient(circle at 50% 50%, rgba(110, 231, 183, 0.34), transparent 70%), + linear-gradient(145deg, #F1FFF9, #E5F9F0); + border: 2px solid #6EE7B7; + border-radius: 999px; + box-shadow: 0 12px 24px rgba(16, 185, 129, 0.24); + display: inline-flex; + height: 52px; + justify-content: center; + text-decoration: none; + transition: + background-color 0.2s ease, + border-color 0.2s ease, + box-shadow 0.2s ease, + transform 0.2s ease; + width: 52px; + } + + .app-floating-action:hover, + .app-floating-action:focus { + background: #D1FAE5; + border-color: var(--camaar-accent); + box-shadow: 0 14px 28px rgba(16, 185, 129, 0.30); + outline: none; + transform: translateY(-2px); + } + + .app-floating-action--fixed { + bottom: clamp(16px, 3vw, 28px); + position: fixed; + right: clamp(16px, 3vw, 28px); + } + + .app-floating-action--inline { + justify-self: end; + } + + .app-table-wrap { + border: 1px solid var(--camaar-border); + border-radius: 14px; + overflow-x: auto; + } + + .app-table { + border-collapse: collapse; + min-width: 680px; + width: 100%; + } + + .app-table th, + .app-table td { + border-bottom: 1px solid #EEE8EF; + padding: 14px 16px; + text-align: left; + } + + .app-table th { + background: var(--camaar-brand-soft); + color: var(--camaar-brand-dark); + font-size: 13px; + letter-spacing: 0.02em; + text-transform: uppercase; + } + + .app-table tbody tr:hover { + background: #FCF9FC; + } + + .app-table tbody tr:last-child td { + border-bottom: 0; + } + + .app-table .button_to, + .app-actions .button_to { + margin: 0; + } + + .choice-list { + display: grid; + gap: 10px; + grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr)); + } + + .choice-card { + align-items: center; + background: var(--camaar-surface); + border: 1px solid var(--camaar-border); + border-radius: 10px; + cursor: pointer; + display: flex; + gap: 10px; + padding: 12px; + } + + .choice-card:hover, + .choice-card:focus-within { + background: var(--camaar-accent-soft); + border-color: #86DDB9; + } + + .summary-list { + color: var(--camaar-muted); + line-height: 1.65; + margin-bottom: 24px; + } + + @media (max-width: 640px) { + .app-card-grid { + grid-template-columns: 1fr; + } + + .app-card { + min-height: 180px; + } + } + + @media (max-width: 760px) { + .app-page { + padding-bottom: 40px; + padding-top: 24px; + } + + .app-actions, + .template-form__actions { + align-items: stretch; + flex-direction: column; + } + + .app-button { + width: 100%; + } + + .app-page__header--split { + flex-direction: column; + } + + .app-page__header .app-button { + width: auto; + } + } + + /* --- Dropdown de sugestões de pesquisa --- */ + .search-container { + position: relative; + } + + .search-suggestions { + position: absolute; + top: calc(100% + 6px); + left: 0; + right: 0; + background: var(--camaar-surface); + border: 1px solid var(--camaar-border); + border-radius: 12px; + box-shadow: 0 16px 40px rgba(76, 18, 70, 0.12); + max-height: 360px; + overflow-y: auto; + z-index: 30; + } + + .search-suggestion { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 10px 14px; + background: none; + border: none; + border-bottom: 1px solid #F2EDF3; + text-align: left; + cursor: pointer; + } + + .search-suggestion:last-child { + border-bottom: none; + } + + .search-suggestion:hover, + .search-suggestion.is-active { + background: var(--camaar-brand-soft); + } + + .search-suggestion__tipo { + flex-shrink: 0; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--camaar-brand); + background: var(--camaar-brand-soft); + border-radius: 6px; + padding: 3px 8px; + } + + .search-suggestion__texto { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1; + } + + .search-suggestion__texto strong { + font-size: 14px; + color: var(--camaar-ink); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .search-suggestion__texto small { + font-size: 12px; + color: var(--camaar-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + /* --- Informações à direita na sugestão (Código e Turma) --- */ + .search-suggestion__meta { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; + flex-shrink: 0; + } + + .search-suggestion__codigo { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; + color: #1E40AF; + background: #EFF6FF; + border-radius: 6px; + padding: 3px 8px; + } + + .search-suggestion__turma { + font-size: 11px; + color: var(--camaar-muted); + white-space: nowrap; + } + + /* --- Botão e menu de filtro da pesquisa --- */ + .search-filter-btn { + align-items: center; + background: transparent; + border: 0; + border-radius: 50%; + color: var(--camaar-brand); + cursor: pointer; + display: inline-flex; + height: 34px; + justify-content: center; + position: absolute; + right: 5px; + top: 50%; + transform: translateY(-50%); + width: 34px; + } + + .search-filter-btn:hover, + .search-filter-btn:focus { + background: var(--camaar-brand-soft); + outline: none; + } + + .search-filter-btn.is-active { + background: var(--camaar-accent-soft); + color: var(--camaar-accent-dark); + } + + .search-filter-menu { + position: absolute; + top: calc(100% + 6px); + right: 0; + min-width: 200px; + background: var(--camaar-surface); + border: 1px solid var(--camaar-border); + border-radius: 12px; + box-shadow: 0 16px 40px rgba(76, 18, 70, 0.12); + padding: 10px; + z-index: 31; + } + + .search-filter-menu label { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 6px; + border-radius: 8px; + font-size: 13.5px; + color: var(--camaar-ink); + cursor: pointer; + } + + .search-filter-menu label:hover { + background: var(--camaar-brand-soft); + } + + .search-filter-menu input[type="checkbox"] { + accent-color: var(--camaar-accent); + } + + .search-filter-menu label.is-disabled { + opacity: 0.5; + cursor: not-allowed; + } + \ No newline at end of file diff --git a/app/assets/stylesheets/formularios.css b/app/assets/stylesheets/formularios.css new file mode 100644 index 0000000000..c98a7213a7 --- /dev/null +++ b/app/assets/stylesheets/formularios.css @@ -0,0 +1,840 @@ +.formulario-builder__field { + gap: 12px; + margin-bottom: 0; +} + +.formulario-form-panel { + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(247, 253, 250, 0.62)), + rgba(255, 255, 255, 0.88); + border: 1px solid var(--camaar-border); + border-radius: 18px; + box-shadow: 0 18px 42px rgba(76, 18, 70, 0.07); + margin-bottom: 16px; + overflow: visible; + padding: clamp(18px, 2.8vw, 28px); + position: relative; +} + +.formulario-form-panel__body { + display: flex; + flex-direction: column; + gap: 26px; +} + +.formulario-step { + display: grid; + gap: 16px; + grid-template-columns: 34px minmax(0, 1fr); + position: relative; +} + +.formulario-step+.formulario-step { + border-top: 1px solid rgba(231, 222, 232, 0.8); + padding-top: 24px; +} + +.formulario-step__marker { + align-items: center; + background: var(--camaar-brand-soft); + border: 1px solid #E9D5E7; + border-radius: 999px; + color: var(--camaar-brand); + display: inline-flex; + font-size: 0.86rem; + font-weight: 800; + height: 34px; + justify-content: center; + line-height: 1; + width: 34px; +} + +.formulario-step__content { + min-width: 0; +} + +.formulario-form-actions { + background: rgba(255, 255, 255, 0.82); + border: 1px solid rgba(231, 222, 232, 0.86); + border-radius: 14px; + box-shadow: 0 10px 26px rgba(76, 18, 70, 0.05); + margin-top: 0; + padding: 14px; +} + +.formulario-builder__field:last-child { + margin-bottom: 0; +} + +.formulario-builder__field--compact { + max-width: 520px; +} + +.formulario-builder__field>label, +.formulario-builder__field-header label, +.formulario-section-box__heading label { + color: var(--camaar-brand-dark); +} + +.formulario-builder__field-header { + align-items: flex-start; + display: flex; + gap: 14px; + justify-content: space-between; +} + +.formulario-builder__field-header p, +.formulario-builder__field>p, +.formulario-section-box__heading p { + color: var(--camaar-muted); + font-size: 0.9rem; + line-height: 1.45; + margin: 6px 0 0; +} + +.formulario-builder__badge { + background: #EFF6FF; + border: 1px solid #BFDBFE; + border-radius: 999px; + color: #1E40AF; + flex-shrink: 0; + font-size: 0.75rem; + font-weight: 700; + padding: 0.28rem 0.65rem; + text-transform: uppercase; +} + +.formulario-template-picker { + display: grid; + gap: 14px; + grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr)); +} + +.formulario-template-picker__section { + background: rgba(255, 255, 255, 0.72); + border: 1px solid var(--camaar-border); + border-radius: 12px; + box-shadow: 0 8px 18px rgba(76, 18, 70, 0.035); + min-width: 0; + padding: 12px; +} + +.formulario-template-picker__section-header { + align-items: center; + display: flex; + gap: 10px; + justify-content: space-between; + margin-bottom: 12px; +} + +.formulario-template-picker__section-header h3 { + color: var(--camaar-brand-dark); + font-size: 0.94rem; + margin: 0; +} + +.formulario-template-picker__section-header span { + background: var(--camaar-brand-soft); + border: 1px solid #E9D5E7; + border-radius: 999px; + color: var(--camaar-brand); + font-size: 0.75rem; + font-weight: 700; + padding: 0.25rem 0.6rem; + white-space: nowrap; +} + +.formulario-template-picker__list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 286px; + overflow-y: auto; + padding: 4px 4px 8px 0; + scrollbar-color: #CFA9CB transparent; +} + +.formulario-template-card, +.formulario-choice-card { + cursor: pointer; + display: flex; + min-width: 0; + position: relative; +} + +.formulario-choice-card input { + flex-shrink: 0; + margin: 0.25rem 0.25rem 0 0; +} + +.formulario-template-card input[type="radio"] { + clip: rect(0 0 0 0); + clip-path: inset(50%); + height: 1px; + margin: -1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + width: 1px; +} + +.formulario-template-card__body, +.formulario-choice-card span { + background: rgba(255, 255, 255, 0.94); + border: 1px solid #E9D5E7; + border-radius: 10px; + color: var(--camaar-ink); + display: flex; + flex: 1; + flex-direction: column; + gap: 5px; + min-width: 0; + padding: 12px 13px; + transition: + background-color 0.2s ease, + border-color 0.2s ease, + box-shadow 0.2s ease; +} + +.formulario-template-card:hover .formulario-template-card__body, +.formulario-template-card:focus-within .formulario-template-card__body, +.formulario-choice-card:hover span, +.formulario-choice-card:focus-within span { + border-color: #CFA9CB; + box-shadow: + 0 8px 18px rgba(106, 27, 96, 0.1), + inset 0 1px 0 rgba(255, 255, 255, 0.8); +} + +.formulario-template-card input:checked+.formulario-template-card__body, +.formulario-choice-card input:checked+span { + background: + linear-gradient(145deg, rgba(236, 253, 245, 0.96), rgba(255, 255, 255, 0.98)), + var(--camaar-accent-soft); + border-color: var(--camaar-accent); + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.13); +} + +.formulario-template-card input[type="radio"]:focus-visible+.formulario-template-card__body { + border-color: var(--camaar-accent); + box-shadow: + 0 8px 18px rgba(106, 27, 96, 0.1), + 0 0 0 4px rgba(16, 185, 129, 0.14); +} + +.formulario-template-card__body strong, +.formulario-choice-card strong { + color: var(--camaar-brand-dark); + font-size: 0.98rem; + overflow-wrap: anywhere; +} + +.formulario-template-card__body span { + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + color: var(--camaar-muted); + display: -webkit-box; + font-size: 0.84rem; + line-clamp: 2; + line-height: 1.4; + overflow: hidden; + overflow-wrap: anywhere; +} + +.formulario-template-card__body small, +.formulario-choice-card small { + color: var(--camaar-accent-dark); + font-size: 0.75rem; + font-weight: 700; +} + +.formulario-choice-grid { + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr)); +} + +.formulario-template-base__header { + align-items: flex-start; +} + +.formulario-publico-segmented { + border: 0; + margin: 0; + min-width: min(260px, 100%); + padding: 0; +} + +.formulario-publico-segmented__legend { + color: var(--camaar-muted); + font-size: 0.78rem; + font-weight: 700; + margin-bottom: 6px; +} + +.formulario-publico-segmented__options { + background: rgba(255, 255, 255, 0.76); + border: 1px solid var(--camaar-border); + border-radius: 999px; + display: grid; + gap: 3px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + padding: 3px; +} + +.formulario-publico-segmented__option { + cursor: pointer; + min-width: 0; + position: relative; +} + +.formulario-publico-segmented__option input { + clip: rect(0 0 0 0); + clip-path: inset(50%); + height: 1px; + margin: -1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + width: 1px; +} + +.formulario-publico-segmented__option span { + align-items: center; + border: 1px solid transparent; + border-radius: 999px; + color: var(--camaar-muted); + display: flex; + font-size: 0.86rem; + font-weight: 800; + justify-content: center; + min-height: 38px; + padding: 0.55rem 0.9rem; + transition: + background-color 0.18s ease, + border-color 0.18s ease, + box-shadow 0.18s ease, + color 0.18s ease; + white-space: nowrap; +} + +.formulario-publico-segmented__option:hover span, +.formulario-publico-segmented__option input:focus-visible+span { + background: var(--camaar-brand-soft); + color: var(--camaar-brand-dark); +} + +.formulario-publico-segmented__option input:focus-visible+span { + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.14); +} + +.formulario-publico-segmented__option input:checked+span { + background: var(--camaar-accent-soft); + border-color: var(--camaar-accent); + box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.12); + color: var(--camaar-accent-dark); +} + +.formulario-template-base__header .formulario-publico-segmented { + flex: 0 0 auto; + margin: 0; +} + +.formulario-classes { + position: relative; +} + +.formulario-section-box { + align-items: stretch; + background: transparent; + display: flex; + flex-direction: column; + gap: 16px; + overflow: visible; + padding: 0; + position: relative; +} + +.formulario-section-box:has(.app-select.is-open) { + z-index: 30; +} + +.formulario-section-box__header { + align-items: center; + display: flex; + gap: 14px; + justify-content: space-between; +} + +.formulario-section-box__heading { + min-width: 0; +} + +.formulario-section-box__heading label { + color: var(--camaar-brand-dark); + display: block; + font-size: 1.08rem; + font-weight: 800; + margin-bottom: 0.3rem; +} + +.formulario-section-box__heading p { + color: var(--camaar-muted); + margin: 0; +} + +.formulario-classes__content { + align-items: flex-start; + display: flex; + gap: 16px; + min-width: 0; +} + +.formulario-classes__list { + flex: 1; + min-height: 0; + min-width: 0; + order: 1; + overflow-y: auto; + padding: 4px 2px 6px 0; + scrollbar-color: #CFA9CB transparent; + transition: + flex-basis 0.32s ease, + min-width 0.32s ease; +} + +.formulario-classes__empty { + margin: 0; +} + +.formulario-class-filter__toolbar { + align-items: center; + display: flex; + flex: 0 0 auto; + gap: 8px; + justify-content: flex-end; + min-height: 42px; +} + +.formulario-class-filter__label { + background: #EFF6FF; + border: 1px solid #BFDBFE; + border-radius: 999px; + color: #1E40AF; + flex: 1; + font-size: 0.75rem; + font-weight: 700; + max-width: min(280px, 36vw); + overflow: hidden; + padding: 0.3rem 0.65rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.formulario-class-filter__button { + align-items: center; + background: var(--camaar-surface); + border: 1px solid var(--camaar-border); + border-radius: 50%; + color: var(--camaar-brand); + cursor: pointer; + display: inline-flex; + flex: 0 0 42px; + height: 42px; + justify-content: center; + position: relative; + transition: + background-color 0.2s ease, + border-color 0.2s ease, + box-shadow 0.2s ease, + color 0.2s ease; + width: 42px; +} + +.formulario-class-filter__button-icon { + align-items: center; + display: inline-flex; + inset: 0; + justify-content: center; + position: absolute; + transition: + opacity 0.18s ease, + transform 0.24s ease; +} + +.formulario-class-filter__button-icon--close { + opacity: 0; + transform: rotate(90deg) scale(0.8); +} + +.formulario-class-filter__button.is-active .formulario-class-filter__button-icon--filter { + opacity: 0; + transform: rotate(-90deg) scale(0.8); +} + +.formulario-class-filter__button.is-active .formulario-class-filter__button-icon--close { + opacity: 1; + transform: rotate(0deg) scale(1); +} + +.formulario-class-filter__button:hover, +.formulario-class-filter__button:focus, +.formulario-class-filter__button.is-active { + background: var(--camaar-accent-soft); + border-color: var(--camaar-accent); + box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.12); + color: var(--camaar-accent-dark); + outline: none; +} + +.formulario-class-filter__menu { + background: rgba(255, 255, 255, 0.98); + border: 1px solid var(--camaar-border); + border-width: 0; + border-radius: 12px; + box-shadow: none; + display: flex; + flex: 0 0 0; + flex-direction: column; + gap: 8px; + height: 0; + max-height: 0; + max-width: 0; + min-height: 0; + min-width: 0; + opacity: 0; + order: 2; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + padding: 0; + pointer-events: none; + scrollbar-color: #CFA9CB transparent; + transform: translateX(10px); + transition: + border-width 0.32s ease, + box-shadow 0.32s ease, + flex-basis 0.32s ease, + height 0.32s ease, + max-height 0.32s ease, + max-width 0.32s ease, + min-height 0.32s ease, + min-width 0.32s ease, + opacity 0.2s ease, + padding 0.32s ease, + transform 0.32s ease; + visibility: hidden; +} + +.formulario-class-filter__menu[hidden] { + display: none; +} + +.formulario-class-filter__menu.is-open, +.formulario-class-filter__menu.is-closing { + visibility: visible; +} + +.formulario-class-filter__menu.is-open { + border-width: 1px; + box-shadow: 0 18px 34px rgba(76, 18, 70, 0.16); + flex-basis: clamp(300px, 32%, 360px); + height: clamp(112px, var(--class-filter-panel-height, 112px), 430px); + max-height: min(70vh, 430px); + max-width: 360px; + min-height: 112px; + min-width: 300px; + opacity: 1; + padding: 10px; + pointer-events: auto; + transform: translateX(0); +} + +.formulario-class-filter__menu.is-closing { + pointer-events: none; +} + +.formulario-class-filter__section { + border: 1px solid #E9D5E7; + border-radius: 10px; + flex: 0 0 auto; + overflow: hidden; +} + +.formulario-class-filter__section summary { + align-items: center; + background: var(--camaar-brand-soft); + color: var(--camaar-brand-dark); + cursor: pointer; + display: flex; + font-size: 0.84rem; + font-weight: 800; + justify-content: space-between; + list-style: none; + padding: 10px 12px; +} + +.formulario-class-filter__section summary::-webkit-details-marker { + display: none; +} + +.formulario-class-filter__section summary::after { + content: "+"; + font-size: 1rem; + line-height: 1; +} + +.formulario-class-filter__section[open] summary::after { + content: "-"; +} + +.formulario-class-filter__search { + background: var(--camaar-surface); + border: 1px solid var(--camaar-border); + border-radius: 9px; + color: var(--camaar-ink); + margin: 10px 10px 6px; + min-height: 38px; + padding: 0.55rem 0.7rem; + width: calc(100% - 20px); +} + +.formulario-class-filter__search:focus { + border-color: var(--camaar-accent); + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.12); + outline: none; +} + +.formulario-class-filter__options { + display: grid; + gap: 5px; + max-height: 190px; + overflow-y: auto; + padding: 4px 10px 10px; + scrollbar-color: #CFA9CB transparent; +} + +.formulario-class-filter__item { + align-items: flex-start; + appearance: none; + background: transparent; + border: 1px solid transparent; + border-radius: 9px; + color: var(--camaar-ink); + cursor: pointer; + display: flex; + flex-direction: column; + font: inherit; + gap: 3px; + line-height: 1.35; + padding: 10px 12px 10px 34px; + position: relative; + text-align: left; + transition: + background-color 0.2s ease, + border-color 0.2s ease, + color 0.2s ease; + width: 100%; +} + +.formulario-class-filter__item::before { + border: 1px solid #D8BFD6; + border-radius: 6px; + content: ""; + height: 14px; + left: 12px; + position: absolute; + top: 12px; + transition: + background-color 0.2s ease, + border-color 0.2s ease, + box-shadow 0.2s ease; + width: 14px; +} + +.formulario-class-filter__item::after { + border: solid var(--camaar-surface); + border-width: 0 2px 2px 0; + content: ""; + height: 7px; + left: 17px; + opacity: 0; + position: absolute; + top: 14px; + transform: rotate(45deg) scale(0.7); + transition: + opacity 0.2s ease, + transform 0.2s ease; + width: 4px; +} + +.formulario-class-filter__item[hidden] { + display: none; +} + +.formulario-class-filter__item:hover, +.formulario-class-filter__item:focus, +.formulario-class-filter__item.is-selected { + background: var(--camaar-brand-soft); + border-color: #E9D5E7; + color: var(--camaar-brand-dark); + outline: none; +} + +.formulario-class-filter__item.is-selected::before { + background: var(--camaar-accent); + border-color: var(--camaar-accent); + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.12); +} + +.formulario-class-filter__item.is-selected::after { + opacity: 1; + transform: rotate(45deg) scale(1); +} + +.formulario-class-filter__item small { + color: var(--camaar-accent-dark); + font-size: 0.72rem; + font-weight: 700; +} + +.formulario-class-filter__empty { + color: var(--camaar-muted); + font-size: 0.84rem; + margin: 2px 0 0; + padding: 8px 10px; +} + +.formulario-choice-grid--classes { + grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr)); +} + +.formulario-choice-card--class span { + gap: 5px; + padding: 12px; +} + +.formulario-choice-card--class[hidden], +.formulario-classes__empty[hidden] { + display: none; +} + +.formulario-choice-card--class input[type="checkbox"] { + clip: rect(0 0 0 0); + clip-path: inset(50%); + height: 1px; + margin: -1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + width: 1px; +} + +.formulario-choice-card--class input[type="checkbox"]:focus-visible+span { + border-color: var(--camaar-accent); + box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.14); +} + +.formulario-choice-card--class input[type="checkbox"]:checked+span { + background: + linear-gradient(145deg, rgba(236, 253, 245, 0.98), rgba(209, 250, 229, 0.94)), + var(--camaar-accent-soft); + border-color: var(--camaar-accent); + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.16); +} + +.formulario-choice-card--class input[type="checkbox"]:checked+span strong { + color: var(--camaar-accent-dark); +} + +.formulario-choice-card--class small { + color: var(--camaar-accent-dark); + font-size: 0.75rem; + font-weight: 700; +} + + +@media (max-width: 640px) { + .formulario-form-panel { + padding: 16px; + } + + .formulario-form-panel__body { + gap: 22px; + } + + .formulario-step { + gap: 10px; + grid-template-columns: 1fr; + } + + .formulario-step+.formulario-step { + padding-top: 20px; + } + + .formulario-step__marker { + height: 30px; + width: 30px; + } + + .formulario-builder__field-header { + align-items: flex-start; + flex-direction: column; + } + + .formulario-template-picker__list { + max-height: 306px; + } + + .formulario-section-box { + height: auto; + max-height: none; + } + + .formulario-section-box__header { + align-items: flex-start; + flex-direction: column; + } + + .formulario-template-base__header .formulario-publico-segmented { + flex-basis: auto; + width: 100%; + } + + .formulario-classes__content { + flex-direction: column; + } + + .formulario-class-filter__toolbar { + justify-content: space-between; + width: 100%; + } + + .formulario-class-filter__label { + max-width: none; + } + + .formulario-class-filter__menu { + flex-basis: 0; + height: 0; + max-height: 0; + max-width: none; + order: 1; + transform: translateY(-8px); + width: 100%; + } + + .formulario-class-filter__menu.is-open { + flex-basis: auto; + height: clamp(112px, var(--class-filter-panel-height, 112px), 380px); + max-height: min(70vh, 380px); + max-width: none; + min-height: 112px; + min-width: 0; + transform: translateY(0); + } + + .formulario-classes__list { + order: 2; + } +} From e7f699cb87bc1a9901d353e3d48462a007d3b69b Mon Sep 17 00:00:00 2001 From: PedroFFranca Date: Mon, 29 Jun 2026 23:55:30 -0300 Subject: [PATCH 114/125] feat (rdocs): adicionando RCDOS nos formularios. --- app/controllers/formularios_controller.rb | 64 +++++++++++++++++ app/models/formulario.rb | 68 +++++++++++++++++++ .../formularios/create_from_template.rb | 13 ++++ app/services/formularios/error.rb | 16 +++++ 4 files changed, 161 insertions(+) diff --git a/app/controllers/formularios_controller.rb b/app/controllers/formularios_controller.rb index fd03b21850..ef520f2948 100644 --- a/app/controllers/formularios_controller.rb +++ b/app/controllers/formularios_controller.rb @@ -2,11 +2,27 @@ require "csv" +# Controller REST para listagem, criação em lote, visualização de relatório e +# exportação CSV de formulários. +# +# A autorização é feita via +FormularioPolicy+. A criação em lote delega a +# +Formularios::CreateFromTemplate+. class FormulariosController < ApplicationController before_action :authenticate_user! before_action :authorize_formulario!, only: %i[show exportar_csv] before_action :set_formulario, only: %i[show exportar_csv] + # Lista formulários do departamento e semestre atual do administrador logado. + # + # Argumentos: + # - Nenhum parâmetro de request além da sessão autenticada. + # + # Retorno: + # - Renderiza a view +index+ com +@user_formularios+ e +@other_formularios+. + # + # Efeitos colaterais: + # - Consulta formulários filtrados por departamento, semestre e ordenação + # recente, separando os criados pelo usuário dos criados por outros. def index authorize! Formulario @@ -20,10 +36,31 @@ def index @other_formularios = formularios.criados_por_outros(current_administrador) end + # Exibe o relatório de um formulário. + # + # Argumentos: + # - +params[:id]+: id do formulário carregado em +@formulario+. + # + # Retorno: + # - Renderiza a view +show+. + # + # Efeitos colaterais: + # - Consulta o formulário via +set_formulario+ antes da action. def show authorize! @formulario end + # Exibe o formulário de publicação de um template para turmas selecionadas. + # + # Argumentos: + # - +params[:template_id]+ e +params[:materia_id]+ (opcionais): pré-seleção + # de template e matéria na interface. + # + # Retorno: + # - Renderiza a view +new+ com templates, matérias e turmas do departamento. + # + # Efeitos colaterais: + # - Consulta templates, matérias, turmas e professores disponíveis. def new @formulario = Formulario.new(adm: current_administrador) authorize! @formulario @@ -31,6 +68,22 @@ def new carregar_opcoes_de_selecao end + # Publica formulários em lote a partir de um template e lista de turmas. + # + # Argumentos: + # - +params[:template_id]+: template de origem. + # - +params[:turma_ids]+: turmas que receberão o formulário. + # - +params[:publico_alvo]+: público que deve responder (+docentes+ ou + # +discentes+). + # + # Retorno: + # - Redireciona para +formularios_path+ com aviso de sucesso. + # - Em falha, renderiza +new+ com status 422 e mensagem em +flash[:alert]+. + # + # Efeitos colaterais: + # - Cria formulários, questões e avaliações via +Formularios::CreateFromTemplate+. + # - Trata +Formularios::Error+, +ActiveRecord::RecordInvalid+ e + # +ActiveRecord::RecordNotFound+. def create authorize! Formulario.new(adm: current_administrador) @@ -40,6 +93,17 @@ def create renderizar_erro_criacao_formulario(e) end + # Exporta respostas do formulário em CSV com separador +;+. + # + # Argumentos: + # - +params[:id]+: id do formulário carregado em +@formulario+. + # + # Retorno: + # - Envia arquivo CSV via +send_data+ com colunas de aluno, matrícula e + # resposta por questão. + # + # Efeitos colaterais: + # - Consulta avaliações com respostas e gera o conteúdo do arquivo em memória. def exportar_csv authorize! @formulario diff --git a/app/models/formulario.rb b/app/models/formulario.rb index 24736bcc47..accc48f30d 100644 --- a/app/models/formulario.rb +++ b/app/models/formulario.rb @@ -1,6 +1,23 @@ # frozen_string_literal: true +# Instância publicada de um template para uma turma e público-alvo. +# +# Cada formulário gera avaliações pendentes para o público selecionado. As +# questões são copiadas do template como snapshot independente — alterações no +# template não afetam formulários já publicados. +# +# Valida unicidade de +turma + template + publico_alvo+ e exige que o +# administrador criador pertença ao mesmo departamento da turma. class Formulario < ApplicationRecord + # Público que deve responder ao formulário. + # + # Valores: + # - +docentes+: professores da turma. + # - +discentes+: alunos da turma. + # + # O valor participa da validação de unicidade junto com +turma_id+ e + # +template_id+ — não é permitido publicar o mesmo template para a mesma + # turma e público-alvo mais de uma vez. enum :publico_alvo, { docentes: 0, discentes: 1 @@ -43,22 +60,58 @@ class Formulario < ApplicationRecord before_validation :definir_criado_em + # Ordena formulários do mais recente ao mais antigo. + # + # Retorno: + # - Relação ordenada por +criado_em+ e +id+ decrescentes. scope :recentes, -> { order(criado_em: :desc, id: :desc) } + # Filtra formulários cuja turma pertence ao departamento informado. + # + # Argumentos: + # - +departamento+: departamento usado no filtro. + # + # Retorno: + # - Relação restrita às turmas do departamento. scope :do_departamento, ->(departamento) { joins(turma: :materia) .where(materias: { departamento_id: departamento.id }) } + # Filtra formulários de turmas do semestre letivo atual. + # + # Retorno: + # - Relação restrita às turmas retornadas por +Turma.do_semestre_atual+. scope :do_semestre_atual, -> { joins(:turma).merge(Turma.do_semestre_atual) } + # Filtra formulários criados pelo administrador informado. + # + # Argumentos: + # - +adm+: perfil administrador criador. + # + # Retorno: + # - Relação com +adm_id+ igual ao administrador. scope :criados_por, ->(adm) { where(adm_id: adm&.id) } + + # Filtra formulários criados por outros administradores. + # + # Argumentos: + # - +adm+: perfil administrador usado como referência de exclusão. + # + # Retorno: + # - Relação com +adm_id+ diferente do administrador. scope :criados_por_outros, ->(adm) { where.not(adm_id: adm&.id) } + # Retorna as participações na turma que compõem o público-alvo do formulário. + # + # Retorno: + # - Participações docentes quando +publico_alvo+ é +docentes+. + # - Participações discentes quando +publico_alvo+ é +discentes+. + # - Relação vazia quando o público-alvo não é reconhecido. def participacoes_alvo return turma.participantes_docentes if docentes? return turma.participantes_discentes if discentes? @@ -66,12 +119,27 @@ def participacoes_alvo ParticipacaoTurma.none end + # Cria uma avaliação pendente para cada participação do público-alvo. + # + # Retorno: + # - +nil+ após processar todas as participações. + # + # Efeitos colaterais: + # - Insere registros de +Avaliacao+ via +find_or_create_by!+ para cada + # participação retornada por +participacoes_alvo+. def criar_avaliacoes_pendentes! participacoes_alvo.find_each do |participacao| avaliacoes.find_or_create_by!(participacao_turma: participacao) end end + # Verifica se o formulário foi criado pelo administrador informado. + # + # Argumentos: + # - +perfil_adm+: perfil administrador a comparar. + # + # Retorno: + # - +true+ quando +adm_id+ coincide com o id do administrador. def criado_por?(perfil_adm) adm_id == perfil_adm&.id end diff --git a/app/services/formularios/create_from_template.rb b/app/services/formularios/create_from_template.rb index 02ab1b5d38..943e5be8be 100644 --- a/app/services/formularios/create_from_template.rb +++ b/app/services/formularios/create_from_template.rb @@ -4,12 +4,25 @@ module Formularios # A classe valida o template, as turmas e o público-alvo antes de criar os # formulários, copiar as questões como snapshot e gerar avaliações pendentes. class CreateFromTemplate + # Mensagem quando a lista de turmas está vazia. SEM_TURMAS = "É necessário selecionar pelo menos uma turma" + + # Mensagem quando nenhum template foi informado. SEM_TEMPLATE = "Selecione um template" + + # Mensagem quando o template não possui questões. SEM_QUESTOES = "O template deve possuir pelo menos uma questão" + + # Mensagem quando algum id de turma não existe no banco. TURMAS_INVALIDAS = "Uma ou mais turmas selecionadas são inválidas" + + # Mensagem quando turma, template e público-alvo já possuem formulário. TURMA_COM_FORMULARIO = "Uma ou mais turmas selecionadas já possuem formulário para este template e público-alvo" + + # Mensagem quando alguma turma pertence a outro departamento do administrador. TURMA_DEPARTAMENTO_INCOMPATIVEL = "Uma ou mais turmas selecionadas pertencem a outro departamento" + + # Mensagem quando o público-alvo não foi selecionado. SEM_PUBLICO_ALVO = "Por favor, selecione o público-alvo do formulário" # Executa a criação de formulários a partir de um template. diff --git a/app/services/formularios/error.rb b/app/services/formularios/error.rb index fa09017b3d..d3ed805de2 100644 --- a/app/services/formularios/error.rb +++ b/app/services/formularios/error.rb @@ -1,7 +1,23 @@ +# frozen_string_literal: true + +# Namespace de serviços do domínio de formulários. module Formularios + # Exceção de negócio levantada por +Formularios::CreateFromTemplate+. + # + # A mensagem é exposta ao usuário via controller quando a criação em lote + # falha por validação de regras de negócio. class Error < StandardError + # Texto descritivo do erro de negócio exposto ao usuário. attr_reader :message + # Inicializa a exceção com a mensagem exibida ao usuário. + # + # Argumentos: + # - +message+: texto descritivo do erro de negócio. + # + # Retorno: + # - Instância de +Formularios::Error+ com +message+ acessível via + # +attr_reader+. def initialize(message) @message = message super From a24f9032259dd109e8553341d453d556f44f0c57 Mon Sep 17 00:00:00 2001 From: PedroFFranca Date: Mon, 29 Jun 2026 23:56:57 -0300 Subject: [PATCH 115/125] feat (rdocs): adicionando o rdocs das policies do formulario --- app/policies/formulario_policy.rb | 79 +++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 app/policies/formulario_policy.rb diff --git a/app/policies/formulario_policy.rb b/app/policies/formulario_policy.rb new file mode 100644 index 0000000000..987e7c7656 --- /dev/null +++ b/app/policies/formulario_policy.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +# Regras de acesso a formulários por departamento. +# +# Administradores só acessam formulários cuja turma pertence ao departamento do +# perfil logado. +class FormularioPolicy < ApplicationPolicy + # Escopo de formulários visíveis ao usuário autenticado. + class Scope < ApplicationPolicy::Scope + # Restringe a relação de formulários ao departamento do administrador. + # + # Retorno: + # - Formulários do departamento quando o usuário é administrador. + # - Relação vazia para demais perfis. + def resolve + return scope.do_departamento(current_administrador.departamento) if administrador? + + scope.none + end + end + + # Permite listar formulários. + # + # Retorno: + # - +true+ quando o usuário é administrador. + def index? + administrador? + end + + # Permite visualizar um formulário específico. + # + # Retorno: + # - +true+ quando o formulário pertence ao departamento do administrador. + def show? + formulario_do_departamento? + end + + # Permite exibir o formulário de publicação. + # + # Retorno: + # - Mesmo critério de +create?+. + def new? + create? + end + + # Permite criar formulários. + # + # Retorno: + # - +true+ quando o usuário é administrador. + def create? + administrador? + end + + # Permite exportar respostas do formulário em CSV. + # + # Retorno: + # - Mesmo critério de +show?+. + def exportar_csv? + show? + end + + private + + # Verifica se o registro autorizado pertence ao departamento do administrador. + # + # Quando +record+ é a classe +Formulario+ (autorização genérica em +index+ ou + # +create+), retorna +true+ para administradores sem comparar departamento. + # + # Retorno: + # - +false+ para não-administradores. + # - +true+ quando +record+ é a classe ou a turma do formulário pertence ao + # departamento do administrador logado. + def formulario_do_departamento? + return false unless administrador? + return true if record.is_a?(Class) + + record.turma.departamento_id == current_administrador.departamento_id + end +end From 518de860c55eedaed152d9257034073f4420dbe4 Mon Sep 17 00:00:00 2001 From: PedroFFranca Date: Tue, 30 Jun 2026 11:09:34 -0300 Subject: [PATCH 116/125] feat (testes): adicionando mais testesd nos models policies e services dos formularios --- spec/models/formulario_spec.rb | 88 +++++++++++++++++++ spec/policies/formulario_policy_spec.rb | 52 +++++++++++ .../formularios/create_from_template_spec.rb | 39 ++++++++ 3 files changed, 179 insertions(+) diff --git a/spec/models/formulario_spec.rb b/spec/models/formulario_spec.rb index 6690e6ea5c..d792c9d989 100644 --- a/spec/models/formulario_spec.rb +++ b/spec/models/formulario_spec.rb @@ -13,6 +13,86 @@ expect(formulario).not_to be_valid expect(formulario.errors[:adm]).to include("must exist") end + + it "rejeita administrador de departamento diferente da turma" do + outro_departamento = Departamento.create!(nome: "IC #{SecureRandom.hex(2)}") + outro_admin = create_admin_usuario(departamento: outro_departamento) + formulario = described_class.new( + adm: outro_admin.perfil_adm, + turma: turma, + template: template, + publico_alvo: :docentes + ) + + expect(formulario).not_to be_valid + expect(formulario.errors[:adm]).to include("deve pertencer ao mesmo departamento da turma") + end + + it "impede duplicata de público-alvo para mesma turma e template" do + create_formulario(turma: turma, adm: admin.perfil_adm, template: template, publico_alvo: :docentes) + + duplicata = described_class.new( + adm: admin.perfil_adm, + turma: turma, + template: template, + publico_alvo: :docentes + ) + + expect(duplicata).not_to be_valid + expect(duplicata.errors[:publico_alvo]).to include("já possui formulário para esta turma e template") + end + end + + describe "#criado_por?" do + let(:formulario) { create_formulario(turma: turma, adm: admin.perfil_adm, template: template) } + + it "retorna true quando o administrador é o criador" do + expect(formulario.criado_por?(admin.perfil_adm)).to be(true) + end + + it "retorna false para outro administrador" do + outro_admin = create_admin_usuario(departamento: departamento) + + expect(formulario.criado_por?(outro_admin.perfil_adm)).to be(false) + end + end + + describe "scopes" do + let!(:formulario_proprio) { create_formulario(turma: turma, adm: admin.perfil_adm, template: template) } + let!(:formulario_outro_admin) do + outro_admin = create_admin_usuario(departamento: departamento) + outro_template = create_template_with_questoes(titulo: "Outro", adm: outro_admin.perfil_adm) + create_formulario( + turma: create_turma(nome_materia: "IHC", numero: 2, departamento: departamento), + adm: outro_admin.perfil_adm, + template: outro_template + ) + end + let!(:formulario_outro_depto) do + outro_departamento = Departamento.create!(nome: "IC #{SecureRandom.hex(2)}") + outro_admin = create_admin_usuario(departamento: outro_departamento) + outro_turma = create_turma(nome_materia: "ES", numero: 1, departamento: outro_departamento) + outro_template = create_template_with_questoes(titulo: "Externo", adm: outro_admin.perfil_adm) + create_formulario(turma: outro_turma, adm: outro_admin.perfil_adm, template: outro_template) + end + + it "criados_por filtra formulários do administrador informado" do + expect(described_class.criados_por(admin.perfil_adm)).to contain_exactly(formulario_proprio) + end + + it "criados_por_outros exclui formulários do administrador informado" do + expect(described_class.criados_por_outros(admin.perfil_adm)).to contain_exactly( + formulario_outro_admin, + formulario_outro_depto + ) + end + + it "do_departamento exclui formulários de outro departamento" do + expect(described_class.do_departamento(departamento)).to contain_exactly( + formulario_proprio, + formulario_outro_admin + ) + end end describe "#participacoes_alvo" do @@ -39,6 +119,14 @@ expect(formulario.participacoes_alvo).to contain_exactly(discente) end + + it "retorna relação vazia quando público-alvo não é reconhecido" do + formulario = create_formulario(turma: turma, adm: admin.perfil_adm, template: template, publico_alvo: :docentes) + allow(formulario).to receive(:docentes?).and_return(false) + allow(formulario).to receive(:discentes?).and_return(false) + + expect(formulario.participacoes_alvo).to eq(ParticipacaoTurma.none) + end end describe "#criar_avaliacoes_pendentes!" do diff --git a/spec/policies/formulario_policy_spec.rb b/spec/policies/formulario_policy_spec.rb index 2e05efc834..fd1150ec0b 100644 --- a/spec/policies/formulario_policy_spec.rb +++ b/spec/policies/formulario_policy_spec.rb @@ -4,9 +4,61 @@ RSpec.describe FormularioPolicy do let(:departamento) { Departamento.create!(nome: "DCC #{SecureRandom.hex(2)}") } + let(:outro_departamento) { Departamento.create!(nome: "IC #{SecureRandom.hex(2)}") } let(:admin) { create_admin_usuario(departamento: departamento) } let(:usuario) { create_usuario } + let(:turma) { create_turma(nome_materia: "MDS", numero: 1, departamento: departamento) } + let(:turma_outro_depto) { create_turma(nome_materia: "IHC", numero: 1, departamento: outro_departamento) } + let(:template) { create_template_with_questoes(titulo: "Avaliação", adm: admin.perfil_adm) } let(:formulario) { Formulario.new(adm: admin.perfil_adm) } + let!(:formulario_do_depto) { create_formulario(turma: turma, adm: admin.perfil_adm, template: template) } + let!(:formulario_outro_depto) do + outro_admin = create_admin_usuario(departamento: outro_departamento) + outro_template = create_template_with_questoes(titulo: "Outro", adm: outro_admin.perfil_adm) + create_formulario(turma: turma_outro_depto, adm: outro_admin.perfil_adm, template: outro_template) + end + + describe "#index?" do + it "permite administrador" do + expect(described_class.new(admin, Formulario).index?).to be(true) + end + + it "nega usuário sem perfil de administrador" do + expect(described_class.new(usuario, Formulario).index?).to be(false) + end + end + + describe "#show?" do + it "permite administrador para formulário do mesmo departamento" do + expect(described_class.new(admin, formulario_do_depto).show?).to be(true) + end + + it "nega administrador para formulário de outro departamento" do + expect(described_class.new(admin, formulario_outro_depto).show?).to be(false) + end + + it "nega usuário sem perfil de administrador" do + expect(described_class.new(usuario, formulario_do_depto).show?).to be(false) + end + + it "permite administrador quando record é a classe Formulario" do + expect(described_class.new(admin, Formulario).show?).to be(true) + end + end + + describe "#exportar_csv?" do + it "permite administrador para formulário do mesmo departamento" do + expect(described_class.new(admin, formulario_do_depto).exportar_csv?).to be(true) + end + + it "nega administrador para formulário de outro departamento" do + expect(described_class.new(admin, formulario_outro_depto).exportar_csv?).to be(false) + end + + it "nega usuário sem perfil de administrador" do + expect(described_class.new(usuario, formulario_do_depto).exportar_csv?).to be(false) + end + end describe "#create?" do it "permite administrador" do diff --git a/spec/services/formularios/create_from_template_spec.rb b/spec/services/formularios/create_from_template_spec.rb index 50d6594e6e..90b377fa05 100644 --- a/spec/services/formularios/create_from_template_spec.rb +++ b/spec/services/formularios/create_from_template_spec.rb @@ -163,4 +163,43 @@ def call_service(**overrides) expect(Formulario.count).to eq(0) end end + + describe ".validate_preparacao!" do + def validate_preparacao(**overrides) + described_class.validate_preparacao!( + template_id: template.id, + turma_ids: [ turma_a.id ], + perfil_adm: perfil_adm, + **overrides + ) + end + + it "retorna nil quando dados são válidos" do + expect(validate_preparacao).to be_nil + end + + it "levanta erro quando template não possui questões" do + template_vazio = create_template_with_questoes(titulo: "Vazio", adm: perfil_adm) + template_vazio.utilizacoes_questoes.destroy_all + + expect do + validate_preparacao(template_id: template_vazio.id) + end.to raise_error(Formularios::Error, described_class::SEM_QUESTOES) + end + + it "levanta erro quando turma não existe" do + expect do + validate_preparacao(turma_ids: [ 999_999 ]) + end.to raise_error(Formularios::Error, described_class::TURMAS_INVALIDAS) + end + + it "levanta erro quando turma pertence a outro departamento" do + outro_departamento = Departamento.create!(nome: "IC #{SecureRandom.hex(2)}") + turma_externa = create_turma(nome_materia: "ES", numero: 99, departamento: outro_departamento) + + expect do + validate_preparacao(turma_ids: [ turma_externa.id ]) + end.to raise_error(Formularios::Error, described_class::TURMAS_INVALIDAS) + end + end end From 78b6cdf093c901f0e21d928d5fc2658502062278 Mon Sep 17 00:00:00 2001 From: Marcio Vieira Date: Tue, 30 Jun 2026 17:23:49 -0300 Subject: [PATCH 117/125] docs: adiciona documentacao rdoc obrigatoria da sprint 3 para avaliacoes --- app/controllers/avaliacoes_controller.rb | 92 ++++++++++++- app/models/avaliacao.rb | 61 ++++++++- features/step_definitions/avaliacoes_steps.rb | 124 ++++++++++++++++-- 3 files changed, 261 insertions(+), 16 deletions(-) diff --git a/app/controllers/avaliacoes_controller.rb b/app/controllers/avaliacoes_controller.rb index 6e688e86ab..ab7ded5dc2 100644 --- a/app/controllers/avaliacoes_controller.rb +++ b/app/controllers/avaliacoes_controller.rb @@ -4,7 +4,19 @@ class AvaliacoesController < ApplicationController before_action :authenticate_user! before_action :set_avaliacao, only: %i[responder submeter] - # GET /avaliacoes/pendentes + # == Descrição + # Lista todas as avaliações pendentes que pertencem às turmas nas quais o usuário autenticado está matriculado. + # + # == Argumentos + # * Nenhum. Consome indiretamente o +current_user.id+ da sessão. + # + # == Retorno + # * Renderiza a view +pendentes+. + # * Popula a variável de instância +@avaliacoes_pendentes+ com a coleção de avaliações. + # + # == Efeitos Colaterais + # * *Banco de Dados*: Realiza apenas operações de leitura (SELECT), cruzando +Avaliacao+, +ParticipacaoTurma+ e +Formulario+. + # * *Redirecionamento*: Bloqueia e redireciona caso o usuário não esteja autenticado (via +before_action+). def pendentes @avaliacoes_pendentes = Avaliacao .pendentes @@ -13,7 +25,19 @@ def pendentes .includes(formulario: { turma: :materia }) end - # GET /avaliacoes/:id/responder + # == Descrição + # Prepara e exibe a tela contendo o formulário e as questões para que o discente possa responder à avaliação. + # + # == Argumentos + # * Recebe o ID da avaliação através de +params[:id]+ (processado no +before_action+). + # + # == Retorno + # * Renderiza a view +responder+. + # * Popula as variáveis de instância +@formulario+ e +@questoes+. + # + # == Efeitos Colaterais + # * *Banco de Dados*: Apenas leitura. + # * *Redirecionamento*: Se a avaliação já constar como respondida, interrompe o fluxo e redireciona para a lista de pendentes com um alerta. def responder if @avaliacao.respondida? redirect_to avaliacoes_pendentes_path, @@ -25,7 +49,21 @@ def responder @questoes = questoes_do_formulario end - # POST /avaliacoes/:id/submeter + # == Descrição + # Processa a submissão do formulário preenchido pelo discente, validando se as regras foram cumpridas + # antes de repassar para a transação de salvamento. + # + # == Argumentos + # * Recebe os dados do formulário preenchido através do +params[:respostas]+. + # * Recebe o ID da avaliação via +params[:id]+ (processado no +before_action+). + # + # == Retorno + # * Em caso de sucesso: Chama o método de salvamento que redireciona o usuário. + # * Em caso de falha de validação: Renderiza novamente a view +responder+ com status HTTP +unprocessable_entity+. + # + # == Efeitos Colaterais + # * *Redirecionamento*: Redireciona para a lista de pendentes caso a avaliação já tenha sido respondida previamente. + # * *Banco de Dados*: Não altera o banco diretamente neste escopo, repassando a responsabilidade de escrita para +salvar_respostas_e_finalizar+. def submeter if @avaliacao.respondida? redirect_to avaliacoes_pendentes_path, @@ -46,6 +84,18 @@ def submeter private + # == Descrição + # Método de segurança que busca a avaliação solicitada e garante que ela pertence às turmas do usuário atual. + # + # == Argumentos + # * Consome +params[:id]+ da rota atual. + # * Consome o usuário logado via +current_user+. + # + # == Retorno + # * Configura a variável de instância +@avaliacao+. + # + # == Efeitos Colaterais + # * *Redirecionamento*: Se a avaliação não for encontrada ou não pertencer ao usuário, resgata a exceção +ActiveRecord::RecordNotFound+ e redireciona com alerta. def set_avaliacao participacao_ids = ParticipacaoTurma .where(usuario: current_user) @@ -57,6 +107,17 @@ def set_avaliacao redirect_to avaliacoes_pendentes_path, alert: "Avaliação não encontrada." end + # == Descrição + # Busca as questões atreladas ao template do formulário atual, ordenando-as conforme a configuração. + # + # == Argumentos + # * Nenhum diretamente. Utiliza a instância atual de +@formulario+. + # + # == Retorno + # * Retorna um +ActiveRecord::Relation+ contendo as questões e suas opções de resposta, ou uma coleção vazia caso não haja template. + # + # == Efeitos Colaterais + # * Nenhum (apenas leitura em banco). def questoes_do_formulario template = @formulario.template return Questao.none unless template @@ -66,6 +127,17 @@ def questoes_do_formulario .order("utilizacoes_questoes.numero") end + # == Descrição + # Verifica interativamente se todas as questões listadas no formulário receberam um preenchimento válido nos parâmetros submetidos. + # + # == Argumentos + # * Consome os dados preenchidos pelo usuário através de +params[:respostas]+. + # + # == Retorno + # * Retorna um booleano (+true+ se todas estiverem preenchidas corretamente, +false+ caso falte alguma). + # + # == Efeitos Colaterais + # * Nenhum. Operação estrita de processamento em memória. def todas_obrigatorias_preenchidas? respostas_params = params[:respostas] || {} @@ -80,6 +152,18 @@ def todas_obrigatorias_preenchidas? end end + # == Descrição + # Executa a gravação das respostas do discente em uma transação segura, garantindo integridade dos dados e finalizando a avaliação. + # + # == Argumentos + # * Consome os dados validados de +params[:respostas]+. + # + # == Retorno + # * Em sucesso: Redireciona para +avaliacoes_pendentes_path+ com mensagem de sucesso. + # * Em caso de erro do banco de dados (+RecordInvalid+ ou +RecordNotFound+): Re-renderiza a view +responder+. + # + # == Efeitos Colaterais + # * *Banco de Dados (Escrita)*: Abre uma transação no banco. Cria registros na tabela +Resposta+ e em tabelas polimórficas associadas (+Texto+ ou +OpcaoEscolhida+). Atualiza a +Avaliacao+ marcando-a como respondida. def salvar_respostas_e_finalizar ActiveRecord::Base.transaction do respostas_params = params[:respostas] || {} @@ -108,4 +192,4 @@ def salvar_respostas_e_finalizar flash.now[:alert] = "Todas as questões obrigatórias devem ser preenchidas." render :responder, status: :unprocessable_entity end -end +end \ No newline at end of file diff --git a/app/models/avaliacao.rb b/app/models/avaliacao.rb index 9a209b2440..07a07b6f23 100644 --- a/app/models/avaliacao.rb +++ b/app/models/avaliacao.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - class Avaliacao < ApplicationRecord belongs_to :participacao_turma, class_name: "ParticipacaoTurma", @@ -34,20 +32,65 @@ class Avaliacao < ApplicationRecord where(respondido_em: nil) } + # == Descrição + # Verifica se a avaliação já foi preenchida baseando-se na presença de uma data de resposta. + # + # == Argumentos + # * Nenhum. + # + # == Retorno + # * Retorna um booleano (+true+ se a data +respondido_em+ estiver presente, +false+ caso contrário). + # + # == Efeitos Colaterais + # * Nenhum efeito colateral no banco de dados. Apenas verificação em memória. def respondida? respondido_em.present? end + # == Descrição + # Verifica se a avaliação ainda aguarda o preenchimento por parte do usuário. + # + # == Argumentos + # * Nenhum. + # + # == Retorno + # * Retorna um booleano (+true+ se ainda não foi respondida, +false+ caso contrário). + # + # == Efeitos Colaterais + # * Nenhum. def pendente? !respondida? end + # == Descrição + # Carimba a avaliação com a data e hora exatas do momento da submissão, efetivando sua conclusão. + # + # == Argumentos + # * Nenhum. + # + # == Retorno + # * Retorna +true+ se a atualização for salva com sucesso. Levanta uma exceção em caso de falha devido ao uso do +update!+. + # + # == Efeitos Colaterais + # * *Banco de Dados (Escrita)*: Executa um comando UPDATE na tabela de avaliações alterando o campo +respondido_em+. def marcar_como_respondida! update!(respondido_em: Time.current) end private + # == Descrição + # Validação customizada. Garante que a participação vinculada a esta avaliação pertence à mesma turma + # para a qual o formulário foi designado. + # + # == Argumentos + # * Nenhum. Lê os atributos da própria instância (+participacao_turma+ e +formulario+). + # + # == Retorno + # * Retorna +nil+ caso a validação passe ou caso faltem dados. + # + # == Efeitos Colaterais + # * Modifica o estado do objeto adicionando uma mensagem de erro ao array interno de +errors+ caso a regra seja violada, impedindo a persistência no banco. def participacao_deve_ser_da_turma_do_formulario return if participacao_turma.blank? return if formulario.blank? @@ -56,6 +99,18 @@ def participacao_deve_ser_da_turma_do_formulario errors.add(:participacao_turma, "deve pertencer à turma do formulário") end + # == Descrição + # Validação customizada. Assegura que o tipo do participante (ex: discente, docente) corresponde + # ao público-alvo exigido pelo formulário. + # + # == Argumentos + # * Nenhum. + # + # == Retorno + # * Retorna +nil+ caso a validação passe ou se os dados em memória estiverem incompletos. + # + # == Efeitos Colaterais + # * Adiciona uma mensagem de erro ao array interno de +errors+ caso a regra seja violada, impedindo a gravação do registro. def participacao_deve_corresponder_ao_publico_alvo return if participacao_turma.blank? return if formulario.blank? @@ -63,4 +118,4 @@ def participacao_deve_corresponder_ao_publico_alvo errors.add(:participacao_turma, "não corresponde ao público-alvo do formulário") end -end +end \ No newline at end of file diff --git a/features/step_definitions/avaliacoes_steps.rb b/features/step_definitions/avaliacoes_steps.rb index 424b1d4eba..e09129416f 100644 --- a/features/step_definitions/avaliacoes_steps.rb +++ b/features/step_definitions/avaliacoes_steps.rb @@ -1,21 +1,127 @@ -# frozen_string_literal: true +# features/step_definitions/avaliacoes_steps.rb -Quando("eu acesso a página de avaliações pendentes") do - visit avaliacoes_pendentes_path +Before do + Avaliacao.destroy_all + ParticipacaoTurma.destroy_all + Formulario.destroy_all + Turma.destroy_all + Materia.destroy_all + Departamento.destroy_all + PerfilDiscente.destroy_all + PerfilAdm.destroy_all + Usuario.destroy_all end -Então("devo ver o formulário da turma {string} na lista") do |nome_turma| +# == Descrição +# Método auxiliar para os testes que cria a associação entre um usuário e uma turma. +# Se o tipo de participação for discente, cria também o PerfilDiscente associado com uma matrícula gerada aleatoriamente. +# +# == Argumentos +# * +usuario+: Instância do modelo Usuario. +# * +turma+: Instância do modelo Turma. +# * +tipo+: Symbol representando o tipo de participação (ex: +:discente+). +# +# == Retorno +# * Retorna o objeto +ParticipacaoTurma+ criado ou encontrado no banco. +# +# == Efeitos Colaterais +# * *Banco de Dados (Escrita)*: Cria registros na tabela +ParticipacaoTurma+ e, condicionalmente, na tabela +PerfilDiscente+. +def criar_participacao_com_perfil(usuario, turma, tipo) + if tipo == :discente + PerfilDiscente.find_or_create_by!(usuario: usuario) do |p| + p.matricula = "MAT#{rand(10000..99999)}" + end + end + + ParticipacaoTurma.find_or_create_by!(usuario: usuario, turma: turma) do |p| + p.tipo_participacao = tipo + end +end + +# == Descrição +# Step definition que prepara o banco com um usuário de teste e seu respectivo perfil discente. +Dado('que existe um usuário participante cadastrado no sistema') do + @email_teste = "joao#{rand(10000)}@teste.com" + @usuario = Usuario.create!( + nome: 'João Participante', + email: @email_teste, + senha: 'password123', + status: :ativo + ) + PerfilDiscente.create!(usuario: @usuario, matricula: "MAT#{rand(10000..99999)}") +end + +# == Descrição +# Step definition que simula a autenticação do usuário através de mock do RSpec no ApplicationController. +Dado('que estou autenticado como participante') do + # allow_any_instance_of agora funciona via features/support/rspec_mocks.rb + allow_any_instance_of(ApplicationController) + .to receive(:current_usuario) + .and_return(@usuario) +end + +# == Descrição +# Step definition que cria a hierarquia completa de Departamento, Materia e Turma, e matricula o usuário nela. +Dado('que estou matriculado na turma {string}') do |nome_turma| + depto = Departamento.find_or_create_by!(nome: 'Departamento Geral') + materia = Materia.find_or_create_by!(nome: nome_turma, departamento: depto) do |m| + m.codigo = "COD#{rand(1000..9999)}" + end + @turma = Turma.find_or_create_by!(materia: materia, ano: 2026, semestre: :primeiro) do |t| + t.numero = rand(1..100) + end + + criar_participacao_com_perfil(@usuario, @turma, :discente) +end + +# == Descrição +# Step definition que cria um formulário pendente vinculado a uma turma e gera a avaliação para o participante. +Dado('que existe um formulário pendente para a turma {string}') do |nome_turma| + depto = Departamento.find_or_create_by!(nome: 'Departamento Geral') + materia = Materia.find_or_create_by!(nome: nome_turma, departamento: depto) do |m| + m.codigo = "COD#{rand(1000..9999)}" + end + + # Reutiliza @turma se já foi criada para esse nome (cenário matriculado) + # ou cria uma nova turma isolada (cenário sem matrícula) + turma = @turma || Turma.find_or_create_by!(materia: materia, ano: 2026, semestre: :primeiro) do |t| + t.numero = rand(1..100) + end + + adm = Usuario.create!(nome: 'Adm', email: "adm#{rand(10000)}@t.com", senha: 'password123', status: :ativo) + perf_adm = PerfilAdm.create!(usuario: adm, departamento: depto) + + @formulario = Formulario.create!(adm: perf_adm, turma: turma, publico_alvo: :discentes) + + # Cria a avaliação para o @usuario (participante do teste), não para um fantasma + participacao = ParticipacaoTurma.find_by(usuario: @usuario, turma: turma) + Avaliacao.create!(formulario: @formulario, participacao_turma: participacao) if participacao +end + +Quando('eu acesso a página de avaliações pendentes') do + visit pendentes_avaliacoes_path +end + +Então('devo ver o formulário da turma {string} na lista') do |nome_turma| expect(page).to have_content(nome_turma) end -Então("o status do formulário deve ser {string}") do |status| +Então('o status do formulário deve ser {string}') do |status| expect(page).to have_content(status) end -Então("devo ver uma mensagem informando que nenhum formulário pendente foi encontrado") do - expect(page).to have_content("Nenhum formulário pendente foi encontrado.") +Dado('que não possuo formulários pendentes nas minhas turmas') do + # O Before já limpou o banco; nenhuma avaliação existe. Nada a fazer. end -Então("não devo ver o formulário da turma {string} na lista") do |nome_turma| - expect(page).not_to have_content(nome_turma) +Então('devo ver uma mensagem informando que nenhum formulário pendente foi encontrado') do + expect(page).to have_content('Nenhum formulário pendente foi encontrado.') +end + +Dado('que não estou matriculado na turma {string}') do |_nome_turma| + # Intencionalmente vazio: não criamos participação para @usuario nessa turma. end + +Então('não devo ver o formulário da turma {string} na lista') do |nome_turma| + expect(page).not_to have_content(nome_turma) +end \ No newline at end of file From 7633d04a433acfdb9f63d3b4a3098438254d1cf2 Mon Sep 17 00:00:00 2001 From: Marcio Vieira Date: Tue, 30 Jun 2026 17:38:57 -0300 Subject: [PATCH 118/125] docs: finaliza documentacao rdoc obrigatoria da sprint 3 na hu14 --- features/step_definitions/avaliacoes_steps.rb | 122 ++---------------- .../responder_avaliacao_steps.rb | 2 +- 2 files changed, 9 insertions(+), 115 deletions(-) diff --git a/features/step_definitions/avaliacoes_steps.rb b/features/step_definitions/avaliacoes_steps.rb index e09129416f..3eeb271313 100644 --- a/features/step_definitions/avaliacoes_steps.rb +++ b/features/step_definitions/avaliacoes_steps.rb @@ -1,127 +1,21 @@ -# features/step_definitions/avaliacoes_steps.rb +# frozen_string_literal: true -Before do - Avaliacao.destroy_all - ParticipacaoTurma.destroy_all - Formulario.destroy_all - Turma.destroy_all - Materia.destroy_all - Departamento.destroy_all - PerfilDiscente.destroy_all - PerfilAdm.destroy_all - Usuario.destroy_all +Quando("eu acesso a página de avaliações pendentes") do + visit avaliacoes_pendentes_path end -# == Descrição -# Método auxiliar para os testes que cria a associação entre um usuário e uma turma. -# Se o tipo de participação for discente, cria também o PerfilDiscente associado com uma matrícula gerada aleatoriamente. -# -# == Argumentos -# * +usuario+: Instância do modelo Usuario. -# * +turma+: Instância do modelo Turma. -# * +tipo+: Symbol representando o tipo de participação (ex: +:discente+). -# -# == Retorno -# * Retorna o objeto +ParticipacaoTurma+ criado ou encontrado no banco. -# -# == Efeitos Colaterais -# * *Banco de Dados (Escrita)*: Cria registros na tabela +ParticipacaoTurma+ e, condicionalmente, na tabela +PerfilDiscente+. -def criar_participacao_com_perfil(usuario, turma, tipo) - if tipo == :discente - PerfilDiscente.find_or_create_by!(usuario: usuario) do |p| - p.matricula = "MAT#{rand(10000..99999)}" - end - end - - ParticipacaoTurma.find_or_create_by!(usuario: usuario, turma: turma) do |p| - p.tipo_participacao = tipo - end -end - -# == Descrição -# Step definition que prepara o banco com um usuário de teste e seu respectivo perfil discente. -Dado('que existe um usuário participante cadastrado no sistema') do - @email_teste = "joao#{rand(10000)}@teste.com" - @usuario = Usuario.create!( - nome: 'João Participante', - email: @email_teste, - senha: 'password123', - status: :ativo - ) - PerfilDiscente.create!(usuario: @usuario, matricula: "MAT#{rand(10000..99999)}") -end - -# == Descrição -# Step definition que simula a autenticação do usuário através de mock do RSpec no ApplicationController. -Dado('que estou autenticado como participante') do - # allow_any_instance_of agora funciona via features/support/rspec_mocks.rb - allow_any_instance_of(ApplicationController) - .to receive(:current_usuario) - .and_return(@usuario) -end - -# == Descrição -# Step definition que cria a hierarquia completa de Departamento, Materia e Turma, e matricula o usuário nela. -Dado('que estou matriculado na turma {string}') do |nome_turma| - depto = Departamento.find_or_create_by!(nome: 'Departamento Geral') - materia = Materia.find_or_create_by!(nome: nome_turma, departamento: depto) do |m| - m.codigo = "COD#{rand(1000..9999)}" - end - @turma = Turma.find_or_create_by!(materia: materia, ano: 2026, semestre: :primeiro) do |t| - t.numero = rand(1..100) - end - - criar_participacao_com_perfil(@usuario, @turma, :discente) -end - -# == Descrição -# Step definition que cria um formulário pendente vinculado a uma turma e gera a avaliação para o participante. -Dado('que existe um formulário pendente para a turma {string}') do |nome_turma| - depto = Departamento.find_or_create_by!(nome: 'Departamento Geral') - materia = Materia.find_or_create_by!(nome: nome_turma, departamento: depto) do |m| - m.codigo = "COD#{rand(1000..9999)}" - end - - # Reutiliza @turma se já foi criada para esse nome (cenário matriculado) - # ou cria uma nova turma isolada (cenário sem matrícula) - turma = @turma || Turma.find_or_create_by!(materia: materia, ano: 2026, semestre: :primeiro) do |t| - t.numero = rand(1..100) - end - - adm = Usuario.create!(nome: 'Adm', email: "adm#{rand(10000)}@t.com", senha: 'password123', status: :ativo) - perf_adm = PerfilAdm.create!(usuario: adm, departamento: depto) - - @formulario = Formulario.create!(adm: perf_adm, turma: turma, publico_alvo: :discentes) - - # Cria a avaliação para o @usuario (participante do teste), não para um fantasma - participacao = ParticipacaoTurma.find_by(usuario: @usuario, turma: turma) - Avaliacao.create!(formulario: @formulario, participacao_turma: participacao) if participacao -end - -Quando('eu acesso a página de avaliações pendentes') do - visit pendentes_avaliacoes_path -end - -Então('devo ver o formulário da turma {string} na lista') do |nome_turma| +Então("devo ver o formulário da turma {string} na lista") do |nome_turma| expect(page).to have_content(nome_turma) end -Então('o status do formulário deve ser {string}') do |status| +Então("o status do formulário deve ser {string}") do |status| expect(page).to have_content(status) end -Dado('que não possuo formulários pendentes nas minhas turmas') do - # O Before já limpou o banco; nenhuma avaliação existe. Nada a fazer. -end - -Então('devo ver uma mensagem informando que nenhum formulário pendente foi encontrado') do - expect(page).to have_content('Nenhum formulário pendente foi encontrado.') -end - -Dado('que não estou matriculado na turma {string}') do |_nome_turma| - # Intencionalmente vazio: não criamos participação para @usuario nessa turma. +Então("devo ver uma mensagem informando que nenhum formulário pendente foi encontrado") do + expect(page).to have_content("Nenhum formulário pendente foi encontrado.") end -Então('não devo ver o formulário da turma {string} na lista') do |nome_turma| +Então("não devo ver o formulário da turma {string} na lista") do |nome_turma| expect(page).not_to have_content(nome_turma) end \ No newline at end of file diff --git a/features/step_definitions/responder_avaliacao_steps.rb b/features/step_definitions/responder_avaliacao_steps.rb index 080f899539..e0d6b01235 100644 --- a/features/step_definitions/responder_avaliacao_steps.rb +++ b/features/step_definitions/responder_avaliacao_steps.rb @@ -116,4 +116,4 @@ def criar_contexto_formulario_com_questoes(nome_turma) Então('devo ver uma mensagem informando que esta avaliação já foi respondida') do expect(page).to have_content("Esta avaliação já foi respondida.") -end +end \ No newline at end of file From 7977d6cc9989148f38f7030067220aaeaf5a31b8 Mon Sep 17 00:00:00 2001 From: Marcio Vieira Date: Tue, 30 Jun 2026 17:47:00 -0300 Subject: [PATCH 119/125] docs: adiciona documentacao rdoc completa no formularios_controller da hu15 --- app/controllers/formularios_controller.rb | 115 +++++++++++++++++- .../step_definitions/exportar_csv_steps.rb | 45 ++++++- 2 files changed, 158 insertions(+), 2 deletions(-) diff --git a/app/controllers/formularios_controller.rb b/app/controllers/formularios_controller.rb index 173f279eb1..f1c5c3d1b5 100644 --- a/app/controllers/formularios_controller.rb +++ b/app/controllers/formularios_controller.rb @@ -7,6 +7,17 @@ class FormulariosController < ApplicationController before_action :require_administrador!, except: :exportar_csv before_action :require_administrador_para_exportacao!, only: :exportar_csv + # == Descrição + # Lista todos os formulários vinculados ao departamento do administrador logado referentes ao semestre letivo atual. + # + # == Argumentos + # * Nenhum argumento explícito. Consome +current_administrador+ do contexto da sessão. + # + # == Retorno + # * Popula a variável de instância +@formularios+ com uma coleção filtrada de objetos +Formulario+. + # + # == Efeitos Colaterais + # * *Banco de Dados*: Realiza apenas operações de leitura (SELECT), aplicando escopos específicos e eager loading (+includes+) para otimizar a consulta. def index @formularios = Formulario .do_departamento(current_administrador.departamento) @@ -15,11 +26,37 @@ def index .includes(:template, turma: :materia) end + # == Descrição + # Prepara as variáveis necessárias para a interface de criação de formulários, listando os templates e as turmas elegíveis. + # + # == Argumentos + # * Nenhum. + # + # == Retorno + # * Popula a variável +@templates+ com todos os templates do sistema. + # * Popula a variável +@turmas+ com as turmas do semestre atual que ainda não possuem formulários vinculados. + # + # == Efeitos Colaterais + # * *Banco de Dados*: Realiza apenas operações de leitura nas tabelas +Template+ e +Turma+. def new @templates = Template.all @turmas = Turma.do_semestre_atual.sem_formulario.includes(:materia) end + # == Descrição + # Valida os parâmetros iniciais de escolha do template e turmas, salvando os dados temporariamente na sessão do usuário. + # + # == Argumentos + # * Consome +params[:template_id]+ contendo o ID do template de formulário escolhido. + # * Consome +params[:turma_ids]+ contendo um array ou valor isolado com os IDs das turmas selecionadas. + # + # == Retorno + # * Em caso de sucesso: Redireciona o fluxo para a rota +publicar_formularios_path+. + # * Em caso de erro de validação (+Formularios::Error+): Redireciona de volta para +new_formulario_path+ exibindo a mensagem tratada. + # + # == Efeitos Colaterais + # * *Sessão*: Modifica o estado do hash de sessão criando a chave +session[:formulario_preparacao]+. + # * *Redirecionamento*: Altera o fluxo de navegação HTTP. def preparar Formularios::CreateFromTemplate.validate_preparacao!( template_id: params[:template_id], @@ -36,6 +73,19 @@ def preparar redirect_to new_formulario_path, alert: e.message end + # == Descrição + # Exibe a tela de revisão final para o administrador definir o público-alvo antes de persistir o formulário. + # + # == Argumentos + # * Nenhum diretamente via parâmetros de URL. Consome os dados salvos anteriormente em +session[:formulario_preparacao]+. + # + # == Retorno + # * Popula a variável +@template+ com a instância encontrada. + # * Popula +@turmas+ com a lista de turmas que receberão o formulário. + # + # == Efeitos Colaterais + # * *Redirecionamento*: Se o hash de sessão de preparação estiver vazio, força o redirecionamento de segurança de volta para +new_formulario_path+. + # * *Banco de Dados*: Apenas consultas de leitura. def publicar preparacao = session[:formulario_preparacao] unless preparacao @@ -47,6 +97,20 @@ def publicar @turmas = Turma.where(id: preparacao["turma_ids"]).includes(:materia) end + # == Descrição + # Processa e executa a criação definitiva dos registros de formulário no banco de dados através da classe de serviço especialista. + # + # == Argumentos + # * Consome o hash contido em +session[:formulario_preparacao]+. + # * Consome +params[:publico_alvo]+ vindo da requisição POST. + # + # == Retorno + # * Redireciona para +new_formulario_path+ acompanhado de mensagens de sucesso (+notice+) ou falha (+alert+). + # * Redireciona para +publicar_formularios_path+ se o erro for estritamente relacionado à ausência de público-alvo. + # + # == Efeitos Colaterais + # * *Banco de Dados (Escrita)*: Insere múltiplos novos registros na tabela +Formulario+ através do service object. + # * *Sessão*: Executa a limpeza dos dados temporários utilizando o método +session.delete(:formulario_preparacao)+. def create preparacao = session[:formulario_preparacao] unless preparacao @@ -74,6 +138,18 @@ def create end end + # == Descrição + # Compila todas as avaliações feitas pelos discentes em um formulário específico e monta um fluxo de dados estruturado em formato CSV para download imediato. + # + # == Argumentos + # * Recebe o ID do formulário alvo através de +params[:id]+. + # + # == Retorno + # * Dispara o download de um arquivo de texto compactado em formato CSV (+text/csv; charset=utf-8+) contendo as respostas nominais. + # + # == Efeitos Colaterais + # * *Banco de Dados*: Realiza consultas pesadas de leitura cruzando dados de +Avaliacao+, +Resposta+, +Texto+ e +OpcaoEscolhida+. + # * *Interface*: Injeta cabeçalhos customizados na resposta HTTP para forçar a ação de download no navegador (+send_data+). def exportar_csv @formulario = Formulario.find(params[:id]) avaliacoes = @formulario.avaliacoes @@ -100,6 +176,17 @@ def exportar_csv private + # == Descrição + # Filtro de segurança privado executado antes da exportação para garantir o bloqueio de acessos não autorizados. + # + # == Argumentos + # * Nenhum diretamente. Utiliza as propriedades do objeto +current_user+. + # + # == Retorno + # * Retorna +nil+ caso a validação passe com sucesso. + # + # == Efeitos Colaterais + # * *Redirecionamento*: Interrompe bruscamente o ciclo da requisição e redireciona para +avaliacoes_pendentes_path+ com um alerta caso o usuário não seja um administrador. def require_administrador_para_exportacao! return if current_user&.administrador? @@ -107,6 +194,18 @@ def require_administrador_para_exportacao! alert: "Apenas administradores possuem acesso a este recurso" end + # == Descrição + # Método utilitário privado que traduz uma instância de avaliação e suas respectivas respostas em uma linha compatível com o CSV. + # + # == Argumentos + # * +avaliacao+: Instância do modelo +Avaliacao+. + # * +questoes+: Coleção ou array contendo os objetos ordenados de +Questao+. + # + # == Retorno + # * Retorna um objeto +Array+ cujas primeiras posições são os dados de identificação do aluno seguidos por suas respostas textuais. + # + # == Efeitos Colaterais + # * Nenhum. Processamento puro em memória ram. def linha_csv(avaliacao, questoes) usuario = avaliacao.participacao_turma.usuario linha = [ usuario.nome, usuario.matricula.presence || "N/A" ] @@ -119,10 +218,24 @@ def linha_csv(avaliacao, questoes) linha end + # == Descrição + # Método utilitário privado que analisa a estrutura interna de uma resposta e extrai seu conteúdo bruto de acordo com o tipo da questão. + # + # == Argumentos + # * +resposta+: Objeto do modelo +Resposta+ (pode receber o valor +nil+ se o aluno deixou em branco). + # * +questao+: Objeto do modelo +Questao+. + # + # == Retorno + # * Retorna a String "Sem resposta" se a instância for nula. + # * Retorna uma string contendo o texto limpo caso a questão seja discursiva. + # * Retorna o texto da opção ou opções selecionadas (unidas por vírgula se for múltipla escolha) caso a questão seja objetiva. + # + # == Efeitos Colaterais + # * Nenhum. def valor_resposta_csv(resposta, questao) return "Sem resposta" if resposta.nil? return resposta.texto&.texto.to_s.strip if questao.discursiva? resposta.opcoes_escolhidas.map { |opcao_escolhida| opcao_escolhida.opcao.texto }.join(", ") end -end +end \ No newline at end of file diff --git a/features/step_definitions/exportar_csv_steps.rb b/features/step_definitions/exportar_csv_steps.rb index ed196ce783..f83dc0617a 100644 --- a/features/step_definitions/exportar_csv_steps.rb +++ b/features/step_definitions/exportar_csv_steps.rb @@ -1,9 +1,31 @@ # frozen_string_literal: true +# == Descrição +# Método auxiliar para os testes que recupera ou define o usuário administrador no contexto atual da sessão ou estado do teste. +# +# == Argumentos +# * Nenhum. +# +# == Retorno +# * Retorna uma instância de +Usuario+ que possui privilégios de administrador. +# +# == Efeitos Colaterais +# * Nenhum. Apenas leitura de variáveis locais e de estado. def administrador_csv usuario_atual || estado[:usuario_administrador] || usuario_administrador end +# == Descrição +# Método auxiliar para os testes que recupera o departamento vinculado ao administrador logado no cenário. +# +# == Argumentos +# * Nenhum. +# +# == Retorno +# * Retorna a instância de +Departamento+ associada ao perfil do administrador. +# +# == Efeitos Colaterais +# * Nenhum. Executa apenas a navegação pelas associações do objeto em memória. def departamento_csv administrador_csv.perfil_adm.departamento end @@ -12,6 +34,9 @@ def departamento_csv # Cenário 1: Formulário com Respostas (@happy) # ------------------------------------------------------- +# == Descrição +# Prepara o banco de dados com toda a hierarquia necessária (matéria, turma, template e formulário) +# e simula um aluno que já respondeu a este formulário com uma questão discursiva. Dado('que existe um formulário com respostas para a turma {string}') do |nome_turma| materia = Materia.find_or_create_by!(nome: nome_turma, departamento: departamento_csv) { |m| m.codigo = "COD#{rand(1000)}" } @turma = Turma.find_or_create_by!(materia: materia, ano: 2026, semestre: :primeiro) { |t| t.numero = 1 } @@ -52,18 +77,26 @@ def departamento_csv resposta.save!(validate: false) end +# == Descrição +# Simula a navegação inicial do administrador para o painel de relatórios do departamento. Quando('eu acesso a página de relatórios do meu departamento') do end +# == Descrição +# Aciona a rota que faz o download do CSV para o formulário previamente criado. Quando('solicito a exportação do formulário da turma {string}') do |_nome_turma| visit exportar_csv_formulario_path(@formulario) end +# == Descrição +# Inspeciona os cabeçalhos da resposta HTTP para garantir que o navegador recebeu a instrução de baixar um anexo do tipo CSV. Então('o download do arquivo CSV deve ser iniciado') do expect(page.response_headers["Content-Type"]).to include "text/csv" expect(page.response_headers["Content-Disposition"]).to include "attachment" end +# == Descrição +# Faz a leitura do corpo do arquivo baixado e verifica se o nome do aluno, a pergunta e a resposta constam no texto. Então('o CSV deve conter os dados esperados das avaliações') do expect(page.body).to include("João Respondedor") expect(page.body).to include("Avalie o professor") @@ -74,6 +107,8 @@ def departamento_csv # Cenário 2: Formulário sem Respostas (@happy) # ------------------------------------------------------- +# == Descrição +# Configura o banco de dados com um formulário ativo, porém sem simular nenhuma submissão de respostas por alunos. Dado('que existe um formulário sem respostas para a turma {string} do meu departamento') do |nome_turma| materia = Materia.find_or_create_by!(nome: nome_turma, departamento: departamento_csv) { |m| m.codigo = "COD#{rand(1000)}" } @turma = Turma.find_or_create_by!(materia: materia, ano: 2026, semestre: :primeiro) { |t| t.numero = 1 } @@ -96,10 +131,14 @@ def departamento_csv ) end +# == Descrição +# Acessa o endpoint de geração de relatório para o formulário vazio configurado no cenário. Quando('eu solicito a exportação do formulário da turma {string}') do |_nome_turma| visit exportar_csv_formulario_path(@formulario_vazio) end +# == Descrição +# Garante que o arquivo gerado contenha estritamente uma única linha correspondente ao cabeçalho das colunas. Então('o arquivo CSV deve conter apenas a linha de cabeçalho') do linhas = page.body.split("\n") expect(linhas.size).to eq(1) @@ -110,6 +149,8 @@ def departamento_csv # Cenário 3: Tentativa de Acesso por Não-Administrador (@sad) # ------------------------------------------------------- +# == Descrição +# Cria o contexto de um formulário válido e simula a requisição direta na rota de exportação por um usuário sem privilégios. Quando('eu tento acessar a rota de exportação de resultados em CSV') do depto = Departamento.find_or_create_by!(nome: "Departamento Teste") materia = Materia.find_or_create_by!(nome: "Matéria", departamento: depto) { |m| m.codigo = "MAT001" } @@ -137,6 +178,8 @@ def departamento_csv visit exportar_csv_formulario_path(form) end +# == Descrição +# Confirma se o Controller barrou o acesso e exibiu o alerta correto na tela, cumprindo as exigências de segurança. Então('devo ver uma mensagem informando que apenas administradores possuem acesso a este recurso') do expect(page).to have_content("Apenas administradores possuem acesso a este recurso") -end +end \ No newline at end of file From 23fe03471b72dff4941bdec3524922b2e03fac71 Mon Sep 17 00:00:00 2001 From: Henrique Date: Tue, 30 Jun 2026 20:29:15 -0300 Subject: [PATCH 120/125] arrumando gerenciamento Henrique F --- app/controllers/auth_controller.rb | 98 +++++++++++++++++++++ app/controllers/concerns/brevo_emailable.rb | 38 ++++++++ app/controllers/dashboard_controller.rb | 42 +++++++++ 3 files changed, 178 insertions(+) diff --git a/app/controllers/auth_controller.rb b/app/controllers/auth_controller.rb index 806236e538..21158fc050 100644 --- a/app/controllers/auth_controller.rb +++ b/app/controllers/auth_controller.rb @@ -4,6 +4,10 @@ require "net/http" require "uri" +# Controla autenticação, primeiro acesso e redefinição de senha. +# +# Este controller atende usuários importados do SIGAA que precisam ativar a +# conta por token, além do fluxo comum de login e recuperação de senha. class AuthController < ApplicationController include BrevoEmailable @@ -11,6 +15,10 @@ class AuthController < ApplicationController only: %i[solicitar_cadastro cadastrar solicitar_redef_senha redefinir_senha] before_action :validar_token_via_url, only: %i[cadastrar redefinir_senha] + # Exibe a tela inicial de login. + # + # @return [void] + # @side_effect Redireciona usuários já autenticados para a lista de avaliações. def index return unless current_user.present? @@ -18,18 +26,40 @@ def index flash: { notice: "Você já está conectado no sistema. Se quiser sair, faça log out" } end + # Exibe o formulário de solicitação de primeiro acesso. + # + # @return [void] + # @side_effect Renderiza a tela para informar matrícula e e-mail institucional. def solicitar_cadastro end + # Exibe o formulário de definição de senha por token de cadastro. + # + # @return [void] + # @side_effect Renderiza a tela de criação de senha após validação do token na + # URL. def cadastrar end + # Exibe o formulário de solicitação de redefinição de senha. + # + # @return [void] + # @side_effect Renderiza a tela para informar o e-mail cadastrado. def solicitar_redef_senha end + # Exibe o formulário de escolha de nova senha por token de redefinição. + # + # @return [void] + # @side_effect Renderiza a tela de redefinição após validação do token na URL. def redefinir_senha end + # Encerra a sessão atual. + # + # @return [void] + # @side_effect Limpa a sessão, remove o cache de usuário atual e redireciona + # para a página inicial. def logout session.clear @current_user = nil @@ -37,6 +67,11 @@ def logout redirect_to root_path, flash: { success: "Sessão encerrada com sucesso." } end + # Autentica o usuário por matrícula ou e-mail e senha. + # + # @return [void] + # @side_effect Grava o ID do usuário na sessão quando a senha é válida ou + # redireciona para a tela inicial com mensagem de erro. def login usuario = buscar_usuario_por_identificador(params[:identificador]) @@ -59,6 +94,14 @@ def login end end + # Processa a solicitação de primeiro acesso de usuário importado. + # + # Valida matrícula e e-mail contra os dados já importados do SIGAA, gera um + # token de cadastro e envia o link de definição de senha. + # + # @return [void] + # @side_effect Cria Token, envia e-mail de cadastro via Brevo e redireciona + # com flash de sucesso ou erro. def processar_solicitacao_cadastro unless email_valido?(params[:email]) return redirecionar_com_erro(cadastro_path, "Por favor, insira um formato de e-mail válido.") @@ -96,6 +139,14 @@ def processar_solicitacao_cadastro end end + # Confirma o primeiro acesso e ativa a conta do usuário. + # + # Usa o token de cadastro para localizar o usuário, define a senha informada e + # altera o status para ativo. + # + # @return [void] + # @side_effect Atualiza Usuario, remove o Token usado e redireciona para login + # ou de volta ao formulário quando houver erro. def confirmar_cadastro if params[:senha].length < 6 return redirecionar_com_erro( @@ -133,6 +184,11 @@ def confirmar_cadastro end end + # Processa uma solicitação de recuperação de senha. + # + # @return [void] + # @side_effect Cria Token do tipo redefinição, envia e-mail de recuperação e + # redireciona com flash de sucesso ou erro. def processar_redefinicao_senha unless email_valido?(params[:email]) return redirecionar_com_erro(solicitar_redef_senha_path, "Por favor, insira um formato de e-mail válido.") @@ -156,6 +212,11 @@ def processar_redefinicao_senha end end + # Confirma a redefinição de senha por token válido. + # + # @return [void] + # @side_effect Atualiza a senha do Usuario, remove o Token usado e redireciona + # para login ou de volta ao formulário quando houver erro. def confirmar_redefinicao_senha if params[:senha].length < 6 return redirecionar_com_erro( @@ -195,6 +256,11 @@ def confirmar_redefinicao_senha private + # Impede que usuários autenticados acessem fluxos públicos de senha. + # + # @return [void] + # @side_effect Redireciona usuários já logados para avaliações com flash de + # erro. def impedir_se_logado return unless current_user.present? @@ -202,6 +268,11 @@ def impedir_se_logado flash: { error: "Você já está logado em outra sessão. Faça o logout antes de poder terminar essa ação." } end + # Valida o token presente na URL para telas de cadastro ou redefinição. + # + # @return [void] + # @side_effect Redireciona para a página inicial quando o token é inválido, + # expirado ou incompatível com a operação. def validar_token_via_url tipo_esperado = action_name == "cadastrar" ? "cadastro" : "redefinicao" token = buscar_token_valido(params[:token], tipo_esperado) @@ -209,6 +280,11 @@ def validar_token_via_url redirect_to root_path, flash: { error: "Token inválido ou expirado." } if token.nil? end + # Busca usuário por e-mail ou matrícula. + # + # @param identificador [String] E-mail ou matrícula informados no login. + # @return [Usuario, nil] Usuário encontrado ou +nil+ quando não houver + # correspondência. def buscar_usuario_por_identificador(identificador) identificador = identificador.to_s.strip @@ -219,11 +295,23 @@ def buscar_usuario_por_identificador(identificador) end end + # Verifica se o e-mail possui formato básico válido. + # + # @param email [String, nil] Endereço a ser validado. + # @return [Boolean] +true+ quando o e-mail está presente e segue o formato + # esperado; caso contrário, +false+. def email_valido?(email) email_regex = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i email.present? && email.match?(email_regex) end + # Localiza um token ainda válido para a operação esperada. + # + # @param valor [String] Valor bruto do token recebido pela URL ou formulário. + # @param tipo_esperado [String] Tipo esperado, como "cadastro" ou + # "redefinicao". + # @return [Token, nil] Token encontrado quando existe e não expirou; +nil+ + # quando ausente, expirado ou de tipo incompatível. def buscar_token_valido(valor, tipo_esperado) token = Token.find_by(value: valor, tipo: tipo_esperado) return nil if token.nil? || token.expires_at < Time.current @@ -231,10 +319,20 @@ def buscar_token_valido(valor, tipo_esperado) token end + # Redireciona para uma rota exibindo mensagem de erro. + # + # @param caminho [String] Caminho Rails para onde o usuário será redirecionado. + # @param mensagem [String] Mensagem exibida no flash de erro. + # @return [void] + # @side_effect Interrompe o fluxo atual com redirect e flash. def redirecionar_com_erro(caminho, mensagem) redirect_to caminho, flash: { error: mensagem } end + # Monta o HTML exibido após envio de e-mail com token. + # + # @param validade [String] Texto com a validade do link enviado. + # @return [String] Fragmento HTML com orientação ao usuário. def mensagem_email_enviado(validade) <<~HTML E-mail enviado com sucesso! Caso não o veja na caixa de entrada, cheque sua caixa de spam. diff --git a/app/controllers/concerns/brevo_emailable.rb b/app/controllers/concerns/brevo_emailable.rb index 9ebec74946..f304c9a340 100644 --- a/app/controllers/concerns/brevo_emailable.rb +++ b/app/controllers/concerns/brevo_emailable.rb @@ -1,11 +1,25 @@ # frozen_string_literal: true +# Agrupa métodos de envio de e-mail transacional pela API Brevo. +# +# Controllers incluem este concern para enviar links de cadastro, redefinição +# de senha e convites administrativos para usuários importados do SIGAA. module BrevoEmailable extend ActiveSupport::Concern + # Endpoint SMTP HTTP usado para envio de e-mails pela Brevo. BREVO_API_URL = URI("https://api.brevo.com/v3/smtp/email") + # Remetente padrão usado nos e-mails transacionais do CAMAAR. REMETENTE = { "name" => "CAMAAR Support", "email" => "rafaelsapienzapinheiro@gmail.com" }.freeze + # Envia um payload de e-mail para a API Brevo. + # + # @param payload [Hash] Dados do e-mail no formato esperado pela Brevo. + # @param contexto [String] Rótulo usado nos logs para identificar a operação. + # @return [Boolean] +true+ quando a API responde com sucesso; +false+ quando + # falta chave de API, ocorre erro HTTP ou exceção. + # @side_effect Realiza requisição HTTPS externa e grava logs de sucesso ou + # falha. def chamar_api_brevo(payload, contexto: "") api_key = brevo_api_key unless api_key.present? @@ -38,6 +52,13 @@ def chamar_api_brevo(payload, contexto: "") false end + # Envia e-mail de primeiro acesso solicitado pelo próprio usuário. + # + # @param destinatario [String] E-mail institucional do usuário. + # @param token [String] Token de cadastro que compõe o link de ativação. + # @return [Boolean] Resultado retornado por #chamar_api_brevo. + # @side_effect Dispara requisição para a Brevo contendo o link de criação de + # senha. def enviar_email_cadastro(destinatario, token) url = "http://127.0.0.1:3000/cadastro/confirmar/?token=#{token}" @@ -72,6 +93,12 @@ def enviar_email_cadastro(destinatario, token) chamar_api_brevo(payload, contexto: "Cadastro") end + # Envia e-mail de recuperação de senha. + # + # @param destinatario [String] E-mail cadastrado do usuário. + # @param token [String] Token de redefinição que compõe o link de nova senha. + # @return [Boolean] Resultado retornado por #chamar_api_brevo. + # @side_effect Dispara requisição para a Brevo contendo o link de redefinição. def enviar_email_redefinicao(destinatario, token) url = "http://127.0.0.1:3000/redefinir-senha/confirmar/?token=#{token}" @@ -106,6 +133,13 @@ def enviar_email_redefinicao(destinatario, token) chamar_api_brevo(payload, contexto: "Redefinição de senha") end + # Envia convite de cadastro disparado por administrador. + # + # @param destinatario [String] E-mail do usuário pendente importado do SIGAA. + # @param token [String] Token de cadastro usado no link de definição de senha. + # @param nome_admin [String] Nome do administrador remetente exibido no e-mail. + # @return [Boolean] Resultado retornado por #chamar_api_brevo. + # @side_effect Dispara requisição para a Brevo com convite de primeiro acesso. def enviar_email_convite_admin(destinatario, token, nome_admin) url = "http://127.0.0.1:3000/cadastro/confirmar/?token=#{token}" @@ -141,6 +175,10 @@ def enviar_email_convite_admin(destinatario, token, nome_admin) private + # Obtém a chave de API da Brevo. + # + # @return [String, nil] Chave encontrada em variável de ambiente ou + # credenciais Rails; +nil+ quando ausente ou inacessível. def brevo_api_key ENV["BREVO_API_KEY"].presence || Rails.application.credentials.dig(:brevo, :api_key) rescue ActiveSupport::MessageEncryptor::InvalidMessage, ActiveSupport::MessageVerifier::InvalidSignature diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 4991679183..ceca3cbca8 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -2,10 +2,20 @@ require "json" +# Controla as telas de dashboard e as ações administrativas ligadas ao SIGAA. +# +# As ações administrativas importam dados institucionais, sincronizam a base +# local e disparam convites para usuários pendentes definirem senha. class DashboardController < ApplicationController include BrevoEmailable before_action :verificar_admin, only: [ :gerenciamento, :importar_dados, :enviar_solicitacoes ] + # Exibe o dashboard inicial do usuário autenticado. + # + # @return [void] + # @side_effect Redireciona usuários não autenticados para a página inicial e, + # para usuários autenticados, popula a lista simulada de turmas exibida na + # view. def index if current_user.nil? redirect_to root_path, flash: { error: "Acesso restrito. Por favor, faça login para continuar." } and return @@ -20,9 +30,23 @@ def index ] end + # Exibe a página administrativa de gerenciamento. + # + # @return [void] + # @side_effect Renderiza a tela com ações de importação do SIGAA e envio de + # solicitações de cadastro. def gerenciamento end + # Envia solicitações de definição de senha para usuários importados pendentes. + # + # Filtra docentes e discentes pendentes pelo departamento do administrador + # logado, cria tokens de cadastro e envia convites por e-mail. + # + # @return [void] + # @side_effect Consulta usuários, cria registros de Token, chama a API de + # e-mail via Brevo e redireciona para a página de gerenciamento com flash de + # sucesso, aviso ou erro parcial. def enviar_solicitacoes depto_id = current_user.perfil_adm&.departamento_id @@ -72,6 +96,19 @@ def enviar_solicitacoes end end + # Importa e sincroniza matérias, turmas, docentes, discentes e matrículas. + # + # Lê o arquivo +db/usuarios_sigaa.json+, cria ou atualiza registros locais + # conforme as matrículas e códigos vindos da fonte SIGAA simulada e remove + # dados que deixaram de aparecer na carga atual, preservando administradores. + # + # @return [void] + # @side_effect Lê arquivo JSON, altera Materia, Turma, Usuario, + # PerfilDocente, PerfilDiscente e ParticipacaoTurma no banco, remove dados + # obsoletos e redireciona para a página de gerenciamento com flash de + # sucesso ou erro parcial. + # @raise [JSON::ParserError] Quando o arquivo JSON existe, mas contém dados + # inválidos para parse. def importar_dados caminho_arquivo = Rails.root.join("db", "usuarios_sigaa.json") unless File.exist?(caminho_arquivo) @@ -186,6 +223,11 @@ def importar_dados private + # Garante que apenas administradores acessem ações restritas. + # + # @return [void] + # @side_effect Quando o usuário não é administrador, limpa a sessão e + # redireciona para a tela inicial com mensagem de acesso restrito. def verificar_admin if current_user.nil? || !current_user.administrador? session.clear From 25a88cb9a999db6689f5259c4d4c4bac96a349e1 Mon Sep 17 00:00:00 2001 From: Rafael-Sapienza Date: Tue, 30 Jun 2026 20:41:54 -0300 Subject: [PATCH 121/125] =?UTF-8?q?chore(auth):=20adicionar=20coment=C3=A1?= =?UTF-8?q?rios=20nas=20fun=C3=A7=C3=B5es=20de=20autentica=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/controllers/auth_controller.rb | 864 +++++++++++++++++--- app/controllers/concerns/brevo_emailable.rb | 203 ++++- 2 files changed, 944 insertions(+), 123 deletions(-) diff --git a/app/controllers/auth_controller.rb b/app/controllers/auth_controller.rb index 806236e538..6e86815903 100644 --- a/app/controllers/auth_controller.rb +++ b/app/controllers/auth_controller.rb @@ -4,13 +4,35 @@ require "net/http" require "uri" +# Controlador responsável por toda a autenticação de usuários do sistema: +# cadastro (primeiro acesso), login, logout e redefinição de senha. +# +# Os fluxos de cadastro e redefinição de senha funcionam em duas etapas: +# 1. O usuário solicita a operação (informando matrícula/e-mail) e recebe +# um e-mail com um link contendo um token de uso único e validade de +# 10 minutos (veja Token). +# 2. O usuário acessa o link, informa a nova senha e confirma a operação, +# momento em que o token é validado e destruído. class AuthController < ApplicationController include BrevoEmailable + # Tamanho mínimo, em caracteres, exigido para qualquer senha cadastrada + # ou redefinida no sistema. + TAMANHO_MINIMO_SENHA = 8 + before_action :impedir_se_logado, only: %i[solicitar_cadastro cadastrar solicitar_redef_senha redefinir_senha] before_action :validar_token_via_url, only: %i[cadastrar redefinir_senha] + # Action raiz do sistema (página inicial / formulário de login). + # + # Argumentos:: Nenhum (action de controller, usa apenas a sessão atual). + # Retorno:: Não possui retorno relevante; quando há usuário logado a + # action interrompe a execução com +return+. + # Efeitos colaterais:: Se já existir um usuário autenticado na sessão + # (+current_user+), redireciona para a página de + # avaliações com uma flash de aviso. Caso contrário, + # apenas renderiza a view padrão (index). def index return unless current_user.present? @@ -18,18 +40,51 @@ def index flash: { notice: "Você já está conectado no sistema. Se quiser sair, faça log out" } end + # Exibe o formulário de solicitação de cadastro (primeiro acesso). + # + # Argumentos:: Nenhum. + # Retorno:: Nenhum (apenas renderiza a view correspondente). + # Efeitos colaterais:: Nenhum além da renderização da view padrão. def solicitar_cadastro end + # Exibe o formulário de confirmação de cadastro (definição de senha), + # acessado a partir do link enviado por e-mail. + # + # Argumentos:: Nenhum diretamente; depende do +params[:token]+ presente + # na URL, validado previamente pelo before_action + # +validar_token_via_url+. + # Retorno:: Nenhum (apenas renderiza a view correspondente). + # Efeitos colaterais:: Nenhum além da renderização da view padrão. def cadastrar end + # Exibe o formulário de solicitação de redefinição de senha. + # + # Argumentos:: Nenhum. + # Retorno:: Nenhum (apenas renderiza a view correspondente). + # Efeitos colaterais:: Nenhum além da renderização da view padrão. def solicitar_redef_senha end + # Exibe o formulário de redefinição de senha, acessado a partir do link + # enviado por e-mail. + # + # Argumentos:: Nenhum diretamente; depende do +params[:token]+ presente + # na URL, validado previamente pelo before_action + # +validar_token_via_url+. + # Retorno:: Nenhum (apenas renderiza a view correspondente). + # Efeitos colaterais:: Nenhum além da renderização da view padrão. def redefinir_senha end + # Encerra a sessão do usuário atualmente autenticado. + # + # Argumentos:: Nenhum. + # Retorno:: O resultado de +redirect_to+ (não utilizado pelo chamador). + # Efeitos colaterais:: Limpa toda a sessão (+session.clear+), zera a + # variável de instância +@current_user+ e redireciona + # para a página inicial com uma flash de sucesso. def logout session.clear @current_user = nil @@ -37,164 +92,702 @@ def logout redirect_to root_path, flash: { success: "Sessão encerrada com sucesso." } end + # Autentica um usuário a partir de matrícula/e-mail e senha enviados via + # +params[:identificador]+ e +params[:senha]+. + # + # Argumentos (via +params+): + # - +identificador+: matrícula ou e-mail do usuário. + # - +senha+: senha em texto puro a ser validada. + # Retorno:: O resultado do +redirect_to+ correspondente ao caminho que + # foi seguido (não há valor de retorno utilizado pelo + # chamador). Possui múltiplos caminhos possíveis: campos + # inválidos, usuário inexistente, conta inativa, login bem + # sucedido ou senha incorreta. + # Efeitos colaterais:: Em caso de sucesso, grava +usuario.id+ em + # +session[:usuario_id]+, autenticando o usuário. + # Em todos os casos redireciona o navegador e define + # uma mensagem flash (de erro ou sucesso). def login + return redirecionar_erro_login if campos_login_invalidos? + usuario = buscar_usuario_por_identificador(params[:identificador]) + return redirecionar_login_invalido if usuario_invalido?(usuario) + return redirecionar_conta_inativa if conta_inativa?(usuario) + return redirecionar_login_sucesso(usuario) if senha_valida?(usuario) - if usuario.nil? - redirect_to root_path, flash: { error: "Matrícula ou e-mail inválido." } - return - end + redirecionar_senha_incorreta + end - unless usuario.ativo? - redirect_to root_path, - flash: { error: "Esta conta ainda não foi ativada. Por favor, realize o Primeiro Acesso." } - return - end + # Processa a solicitação de cadastro (primeiro acesso) de um usuário, + # validando o e-mail informado e disparando o e-mail de confirmação com + # o token de cadastro. + # + # Argumentos (via +params+): + # - +email+: e-mail informado pelo usuário, deve corresponder ao e-mail + # institucional já cadastrado para a matrícula. + # - +matricula+: matrícula institucional do usuário. + # Retorno:: O resultado do +redirect_to+ correspondente ao caminho + # seguido. Possui múltiplos caminhos possíveis: e-mail + # inválido, matrícula/e-mail não correspondem, e-mail + # enviado com sucesso ou falha técnica no envio. + # Efeitos colaterais:: Cria um novo registro de Token associado ao + # usuário (gravação no banco de dados) e envia um + # e-mail de cadastro via +enviar_email_cadastro+. + # Redireciona e define mensagem flash. + def processar_solicitacao_cadastro + return redirecionar_erro_email_cadastro_invalido unless email_valido?(params[:email]) + + usuario = Usuario.find_by(matricula: params[:matricula]) + return redirecionar_validacao_solicitacao_cadastro(usuario) if validacao_solicitacao_cadastro?(usuario) + + token_gerado = criar_token_cadastro(usuario) + return redirecionar_sucesso_email_cadastro if enviar_email_cadastro(usuario.email, token_gerado) - if usuario.authenticate_senha(params[:senha]) - session[:usuario_id] = usuario.id - redirect_to avaliacoes_path, flash: { success: "Login realizado com sucesso! Seja bem-vindo." } + redirecionar_erro_email_cadastro + end + + # Confirma o cadastro de um usuário, definindo sua senha e ativando a + # conta. É um atalho que delega para +processar_confirmacao_senha+ + # informando o tipo de operação "cadastro". + # + # Argumentos:: Nenhum diretamente; utiliza +params[:token]+, + # +params[:senha]+ e +params[:senha_confirmacao]+. + # Retorno:: O retorno de +processar_confirmacao_senha+ (redirecionamento). + # Efeitos colaterais:: Veja +processar_confirmacao_senha+. + def confirmar_cadastro + processar_confirmacao_senha("cadastro") + end + + # Processa a solicitação de redefinição de senha de um usuário já + # cadastrado, disparando o e-mail com o token de redefinição. É um + # atalho que delega para +processar_solicitacao_redefinicao_senha+. + # + # Argumentos:: Nenhum diretamente; utiliza +params[:email]+. + # Retorno:: O retorno de +processar_solicitacao_redefinicao_senha+. + # Efeitos colaterais:: Veja +processar_solicitacao_redefinicao_senha+. + def processar_redefinicao_senha + processar_solicitacao_redefinicao_senha + end + + # Confirma a redefinição de senha de um usuário. É um atalho que delega + # para +processar_confirmacao_senha+ informando o tipo de operação + # "redefinicao". + # + # Argumentos:: Nenhum diretamente; utiliza +params[:token]+, + # +params[:senha]+ e +params[:senha_confirmacao]+. + # Retorno:: O retorno de +processar_confirmacao_senha+ (redirecionamento). + # Efeitos colaterais:: Veja +processar_confirmacao_senha+. + def confirmar_redefinicao_senha + processar_confirmacao_senha("redefinicao") + end + + private + + # Verifica se os campos obrigatórios do formulário de login foram + # preenchidos. + # + # Argumentos:: Nenhum diretamente; utiliza +params[:identificador]+ e + # +params[:senha]+. + # Retorno:: +true+ se +identificador+ ou +senha+ estiverem em branco, + # +false+ caso contrário. + # Efeitos colaterais:: Nenhum. + def campos_login_invalidos? + params[:identificador].blank? || params[:senha].blank? + end + + # Monta a mensagem de erro apropriada para campos de login inválidos e + # redireciona o usuário de volta à página inicial. + # + # Argumentos:: Nenhum diretamente; utiliza +params[:identificador]+ e + # +params[:senha]+ para decidir qual mensagem exibir. + # Retorno:: O resultado de +redirect_to+ (via +redirecionar_com_erro+). + # Efeitos colaterais:: Redireciona para a página inicial com uma flash + # de erro. + def redirecionar_erro_login + mensagem = if params[:identificador].blank? && params[:senha].blank? + "Informe sua matrícula ou e-mail e sua senha." + elsif params[:identificador].blank? + "Informe sua matrícula ou e-mail." else - redirect_to root_path, flash: { error: "Senha incorreta." } + "Informe sua senha." end + + redirecionar_com_erro(root_path, mensagem) end - def processar_solicitacao_cadastro - unless email_valido?(params[:email]) - return redirecionar_com_erro(cadastro_path, "Por favor, insira um formato de e-mail válido.") - end + # Verifica se um usuário não foi encontrado. + # + # Argumentos:: +usuario+ - instância de Usuario (ou +nil+) a ser + # verificada. + # Retorno:: +true+ se +usuario+ for +nil+, +false+ caso contrário. + # Efeitos colaterais:: Nenhum. + def usuario_invalido?(usuario) + usuario.nil? + end - usuario = Usuario.find_by(matricula: params[:matricula]) - return redirecionar_com_erro(cadastro_path, "Matrícula não encontrada no sistema institucional.") if usuario.nil? + # Verifica se a conta do usuário ainda não foi ativada. + # + # Argumentos:: +usuario+ - instância de Usuario a ser verificada. + # Retorno:: +true+ se a conta não estiver ativa, +false+ caso contrário. + # Efeitos colaterais:: Nenhum. + def conta_inativa?(usuario) + !usuario.ativo? + end - if usuario.email.to_s.downcase.strip != params[:email].to_s.downcase.strip - return redirecionar_com_erro( - cadastro_path, - "O e-mail informado não corresponde ao e-mail institucional desta matrícula." - ) - end + # Verifica se a senha informada no login corresponde à senha do usuário. + # + # Argumentos:: +usuario+ - instância de Usuario cuja senha será + # validada; utiliza também +params[:senha]+. + # Retorno:: +true+ se a senha informada estiver correta, +false+ caso + # contrário. + # Efeitos colaterais:: Nenhum. + def senha_valida?(usuario) + usuario.authenticate_senha(params[:senha]) + end - if usuario.ativo? - return redirecionar_com_erro( - cadastro_path, - "Esta matrícula já possui um cadastro ativo. Caso tenha esquecido sua senha, utilize a redefinição." - ) - end + # Redireciona para a página inicial informando que a matrícula ou + # e-mail informado é inválido. + # + # Argumentos:: Nenhum. + # Retorno:: O resultado de +redirect_to+. + # Efeitos colaterais:: Redireciona para a página inicial com flash de + # erro. + def redirecionar_login_invalido + redirect_to root_path, flash: { error: "Matrícula ou e-mail inválido." } + end - token_gerado = SecureRandom.hex(16) - usuario.tokens.create!( - value: token_gerado, - tipo: "cadastro", - expires_at: 10.minutes.from_now + # Redireciona para a página inicial informando que a conta ainda não + # foi ativada. + # + # Argumentos:: Nenhum. + # Retorno:: O resultado de +redirect_to+. + # Efeitos colaterais:: Redireciona para a página inicial com flash de + # erro. + def redirecionar_conta_inativa + redirect_to root_path, + flash: { error: "Esta conta ainda não foi ativada. Por favor, realize o Primeiro Acesso." } + end + + # Efetiva o login do usuário na sessão e redireciona para a área + # autenticada do sistema. + # + # Argumentos:: +usuario+ - instância de Usuario que acabou de ser + # autenticada. + # Retorno:: O resultado de +redirect_to+. + # Efeitos colaterais:: Grava +usuario.id+ em +session[:usuario_id]+ + # (efetivando a autenticação) e redireciona para a + # página de avaliações com flash de sucesso. + def redirecionar_login_sucesso(usuario) + session[:usuario_id] = usuario.id + redirect_to avaliacoes_path, flash: { success: "Login realizado com sucesso! Seja bem-vindo." } + end + + # Redireciona para a página inicial informando que a senha informada + # está incorreta. + # + # Argumentos:: Nenhum. + # Retorno:: O resultado de +redirect_to+. + # Efeitos colaterais:: Redireciona para a página inicial com flash de + # erro. + def redirecionar_senha_incorreta + redirect_to root_path, flash: { error: "Senha incorreta." } + end + + # Redireciona de volta ao formulário de cadastro informando que o + # e-mail possui formato inválido. + # + # Argumentos:: Nenhum. + # Retorno:: O resultado de +redirect_to+ (via +redirecionar_com_erro+). + # Efeitos colaterais:: Redireciona para a página de cadastro com flash + # de erro. + def redirecionar_erro_email_cadastro_invalido + redirecionar_com_erro(cadastro_path, "Por favor, insira um formato de e-mail válido.") + end + + # Redireciona de volta ao formulário de cadastro informando que a + # matrícula não foi encontrada no sistema institucional. + # + # Argumentos:: Nenhum. + # Retorno:: O resultado de +redirect_to+ (via +redirecionar_com_erro+). + # Efeitos colaterais:: Redireciona para a página de cadastro com flash + # de erro. + def redirecionar_matricula_inexistente + redirecionar_com_erro(cadastro_path, "Matrícula não encontrada no sistema institucional.") + end + + # Redireciona de volta ao formulário de cadastro informando que o + # e-mail informado não corresponde ao e-mail institucional cadastrado + # para a matrícula. + # + # Argumentos:: Nenhum. + # Retorno:: O resultado de +redirect_to+ (via +redirecionar_com_erro+). + # Efeitos colaterais:: Redireciona para a página de cadastro com flash + # de erro. + def redirecionar_email_institucional_incorreto + redirecionar_com_erro( + cadastro_path, + "O e-mail informado não corresponde ao e-mail institucional desta matrícula." ) + end - if enviar_email_cadastro(usuario.email, token_gerado) - redirect_to root_path, flash: { success: mensagem_email_enviado("10 minutos") } - else - redirect_to cadastro_path, - flash: { error: "Houve um erro técnico ao tentar enviar o e-mail. Tente novamente mais tarde." } - end + # Redireciona de volta ao formulário de cadastro informando que a + # matrícula já possui um cadastro ativo. + # + # Argumentos:: Nenhum. + # Retorno:: O resultado de +redirect_to+ (via +redirecionar_com_erro+). + # Efeitos colaterais:: Redireciona para a página de cadastro com flash + # de erro. + def redirecionar_matricula_ativa + redirecionar_com_erro( + cadastro_path, + "Esta matrícula já possui um cadastro ativo. Caso tenha esquecido sua senha, utilize a redefinição." + ) end - def confirmar_cadastro - if params[:senha].length < 6 - return redirecionar_com_erro( - confirmar_cadastro_path(token: params[:token]), - "A senha deve conter pelo menos 6 caracteres." - ) - end + # Verifica se a solicitação de cadastro é inválida, seja por usuário + # inexistente, e-mail divergente do institucional, ou conta já ativa. + # + # Argumentos:: +usuario+ - instância de Usuario (ou +nil+) encontrada + # pela matrícula informada. + # Retorno:: +true+ se a solicitação de cadastro deve ser recusada, + # +false+ caso possa prosseguir. + # Efeitos colaterais:: Nenhum. + def validacao_solicitacao_cadastro?(usuario) + usuario_invalido?(usuario) || !email_corresponde_ao_institucional?(usuario) || !conta_inativa?(usuario) + end + + # Decide para qual mensagem de erro redirecionar quando a solicitação + # de cadastro é inválida, de acordo com o motivo específico (usuário + # inexistente, e-mail divergente ou matrícula já ativa). + # + # Argumentos:: +usuario+ - instância de Usuario (ou +nil+) encontrada + # pela matrícula informada. + # Retorno:: O resultado de um dos métodos de redirecionamento de erro + # (+redirecionar_matricula_inexistente+, + # +redirecionar_email_institucional_incorreto+ ou + # +redirecionar_matricula_ativa+). Possui, portanto, três + # caminhos de retorno possíveis. + # Efeitos colaterais:: Redireciona o navegador com flash de erro + # (efeito herdado do método chamado). + def redirecionar_validacao_solicitacao_cadastro(usuario) + return redirecionar_matricula_inexistente if usuario_invalido?(usuario) + return redirecionar_email_institucional_incorreto unless email_corresponde_ao_institucional?(usuario) + + redirecionar_matricula_ativa + end + + # Verifica se a senha e a confirmação de senha informadas nos + # formulários de cadastro/redefinição são válidas (preenchidas, com + # tamanho mínimo e idênticas entre si). + # + # Argumentos:: Nenhum diretamente; utiliza +params[:senha]+ e + # +params[:senha_confirmacao]+. + # Retorno:: +true+ se a senha for válida e coincidir com a confirmação, + # +false+ caso contrário. + # Efeitos colaterais:: Nenhum. + def senha_confirmacao_valida? + return false if params[:senha].blank? || params[:senha_confirmacao].blank? + return false if params[:senha].length < TAMANHO_MINIMO_SENHA + + params[:senha] == params[:senha_confirmacao] + end + + # Processa a confirmação de senha tanto para o fluxo de cadastro quanto + # para o de redefinição de senha, validando o token, atualizando a + # senha do usuário e concluindo a operação. + # + # Argumentos:: +tipo_operacao+ - String, "cadastro" ou "redefinicao", + # indicando qual fluxo está sendo processado. + # Retorno:: O resultado de um redirecionamento (sucesso ou erro). + # Possui múltiplos caminhos possíveis: senha/confirmação + # inválidas, token inválido/expirado, falha ao salvar o + # usuário, ou conclusão bem-sucedida. + # Efeitos colaterais:: Atualiza a senha (e, em caso de cadastro, o + # status) do usuário no banco de dados; em caso de + # sucesso, destrói o registro de Token utilizado. + # Redireciona o navegador e define mensagem flash. + def processar_confirmacao_senha(tipo_operacao) + return redirecionar_erro_confirmacao_senha(tipo_operacao) unless senha_confirmacao_valida? + + token_registro = buscar_token_valido(params[:token], tipo_operacao) + return redirecionar_token_invalido(tipo_operacao) if token_registro.nil? + + usuario = token_registro.usuario + preparar_usuario_para_confirmacao(usuario, tipo_operacao) + return concluir_confirmacao(usuario, token_registro, tipo_operacao) if usuario.save + + redirecionar_erro_salvamento_confirmacao(usuario, tipo_operacao) + end + + # Processa a solicitação de redefinição de senha, validando o e-mail + # informado e disparando o e-mail com o token de redefinição. + # + # Argumentos:: Nenhum diretamente; utiliza +params[:email]+. + # Retorno:: O resultado de um redirecionamento. Possui múltiplos + # caminhos possíveis: e-mail inválido, e-mail não cadastrado, + # envio com sucesso ou falha técnica no envio. + # Efeitos colaterais:: Cria um novo registro de Token associado ao + # usuário (gravação no banco de dados) e envia um + # e-mail de redefinição via + # +enviar_email_redefinicao+. Redireciona o + # navegador e define mensagem flash. + def processar_solicitacao_redefinicao_senha + return erro_solicitacao_redefinicao_senha unless email_valido?(params[:email]) + + usuario = Usuario.find_by(email: params[:email]) + return erro_email_nao_cadastrado if usuario.nil? + + token_gerado = criar_token_redefinicao(usuario) + return redirecionar_sucesso_redefinicao_senha if enviar_email_redefinicao(params[:email], token_gerado) + + redirecionar_erro_redefinicao_senha + end + + # Atribui ao usuário a nova senha informada e, quando o fluxo for de + # cadastro, ativa sua conta. + # + # Argumentos:: + # - +usuario+ - instância de Usuario a ser preparada. + # - +tipo_operacao+ - String, "cadastro" ou "redefinicao". + # Retorno:: Nenhum valor relevante (sempre retorna o resultado da + # última atribuição/condicional). + # Efeitos colaterais:: Altera os atributos +senha+, +senha_confirmation+ + # e, se aplicável, +status+ do objeto +usuario+ em + # memória (a gravação no banco ocorre apenas + # quando +usuario.save+ for chamado pelo método + # que invoca este). + def preparar_usuario_para_confirmacao(usuario, tipo_operacao) + usuario.senha = params[:senha] + usuario.senha_confirmation = params[:senha_confirmacao] + return unless tipo_operacao == "cadastro" - if params[:senha] != params[:senha_confirmacao] + usuario.status = :ativo + end + + # Finaliza com sucesso o fluxo de cadastro ou redefinição de senha, + # descartando o token utilizado e redirecionando o usuário para a + # página de login. + # + # Argumentos:: + # - +usuario+ - instância de Usuario que teve a senha confirmada (não + # utilizado diretamente no corpo do método). + # - +token_registro+ - instância de Token a ser destruída. + # - +tipo_operacao+ - String, "cadastro" ou "redefinicao", usada para + # decidir a mensagem de sucesso exibida. + # Retorno:: O resultado de +redirect_to+. Possui duas mensagens de + # sucesso possíveis, de acordo com +tipo_operacao+. + # Efeitos colaterais:: Remove o registro de Token do banco de dados + # (+token_registro.destroy+) e redireciona o + # navegador para a página inicial com flash de + # sucesso. + def concluir_confirmacao(usuario, token_registro, tipo_operacao) + token_registro.destroy + return redirect_to root_path, flash: { success: "Cadastro concluído com sucesso! Faça seu login." } if tipo_operacao == "cadastro" + + redirect_to root_path, + flash: { success: "Sua senha foi alterada com sucesso! Insira suas novas credenciais para acessar." } + end + + # Redireciona de volta ao formulário de confirmação de senha (cadastro + # ou redefinição) quando o usuário não pôde ser salvo, exibindo as + # mensagens de erro de validação do model. + # + # Argumentos:: + # - +usuario+ - instância de Usuario que falhou ao ser salva, contendo + # os erros de validação em +usuario.errors+. + # - +tipo_operacao+ - String, "cadastro" ou "redefinicao". + # Retorno:: O resultado de +redirect_to+. Possui dois destinos + # possíveis (confirmação de cadastro ou de redefinição), de + # acordo com +tipo_operacao+. + # Efeitos colaterais:: Redireciona o navegador de volta ao formulário + # correspondente, com flash de erro contendo as + # mensagens de validação do usuário. Não realiza + # nenhuma alteração no banco de dados. + def redirecionar_erro_salvamento_confirmacao(usuario, tipo_operacao) + return redirect_to confirmar_cadastro_path(token: params[:token]), + flash: { error: usuario.errors.full_messages.to_sentence } if tipo_operacao == "cadastro" + + redirect_to redefinir_senha_path(token: params[:token]), + flash: { error: usuario.errors.full_messages.to_sentence } + end + + # Redireciona de volta ao formulário de confirmação de senha quando os + # campos de senha/confirmação são inválidos, exibindo a mensagem de + # erro mais específica possível (campos em branco, senha curta ou + # senhas que não coincidem). + # + # Argumentos:: +tipo_operacao+ - String, "cadastro" ou "redefinicao". + # Retorno:: O resultado de +redirect_to+ (via +redirecionar_com_erro+). + # Possui três mensagens de erro possíveis. + # Efeitos colaterais:: Redireciona o navegador de volta ao formulário + # correspondente com flash de erro. + def redirecionar_erro_confirmacao_senha(tipo_operacao) + if params[:senha].blank? || params[:senha_confirmacao].blank? return redirecionar_com_erro( - confirmar_cadastro_path(token: params[:token]), - "As senhas não coincidem. Digite novamente." + caminho_confirmacao_senha(tipo_operacao), + "Os campos de senha são obrigatórios." ) end - token_registro = buscar_token_valido(params[:token], "cadastro") - if token_registro.nil? + if params[:senha].length < TAMANHO_MINIMO_SENHA return redirecionar_com_erro( - root_path, - "O link de confirmação é inválido, expirou ou não corresponde a esta operação." + caminho_confirmacao_senha(tipo_operacao), + mensagem_senha_curta(tipo_operacao) ) end - usuario = token_registro.usuario - usuario.senha = params[:senha] - usuario.senha_confirmation = params[:senha_confirmacao] - usuario.status = :ativo + redirecionar_com_erro( + caminho_confirmacao_senha(tipo_operacao), + "As senhas não coincidem. Digite novamente." + ) + end - if usuario.save - token_registro.destroy - redirect_to root_path, flash: { success: "Cadastro concluído com sucesso! Faça seu login." } + # Determina o caminho (rota) do formulário de confirmação de senha de + # acordo com o tipo de operação em andamento. + # + # Argumentos:: +tipo_operacao+ - String, "cadastro" ou "redefinicao". + # Retorno:: String com a URL gerada por +redefinir_senha_path+ ou + # +confirmar_cadastro_path+, incluindo +params[:token]+. + # Efeitos colaterais:: Nenhum. + def caminho_confirmacao_senha(tipo_operacao) + return redefinir_senha_path(token: params[:token]) if tipo_operacao == "redefinicao" + + confirmar_cadastro_path(token: params[:token]) + end + + # Monta a mensagem de erro exibida quando a senha informada não atende + # ao tamanho mínimo exigido, com texto adaptado ao tipo de operação. + # + # Argumentos:: +tipo_operacao+ - String, "cadastro" ou "redefinicao". + # Retorno:: String com a mensagem de erro correspondente. + # Efeitos colaterais:: Nenhum. + def mensagem_senha_curta(tipo_operacao) + return "A nova senha deve conter pelo menos #{TAMANHO_MINIMO_SENHA} caracteres." if tipo_operacao == "redefinicao" + + "A senha deve conter pelo menos #{TAMANHO_MINIMO_SENHA} caracteres." + end + + # Redireciona para a página inicial informando que o token de + # cadastro/redefinição é inválido, expirado ou não corresponde à + # operação solicitada. + # + # Argumentos:: +tipo_operacao+ - String, "cadastro" ou "redefinicao", + # usada para adaptar o texto da mensagem. + # Retorno:: O resultado de +redirect_to+ (via +redirecionar_com_erro+). + # Efeitos colaterais:: Redireciona o navegador para a página inicial + # com flash de erro. + def redirecionar_token_invalido(tipo_operacao) + mensagem = if tipo_operacao == "redefinicao" + "O link de redefinição é inválido, expirou ou não corresponde a esta operação." else - redirect_to confirmar_cadastro_path(token: params[:token]), - flash: { error: usuario.errors.full_messages.to_sentence } + "O link de confirmação é inválido, expirou ou não corresponde a esta operação." end + + redirecionar_com_erro(root_path, mensagem) end - def processar_redefinicao_senha - unless email_valido?(params[:email]) - return redirecionar_com_erro(solicitar_redef_senha_path, "Por favor, insira um formato de e-mail válido.") - end + # Finaliza o fluxo de cadastro descartando o token utilizado e + # redirecionando o usuário para a página de login. + # + # NOTA: método atualmente não referenciado por nenhum fluxo ativo do + # controlador (mantido sem alteração de comportamento). + # + # Argumentos:: + # - +usuario+ - instância de Usuario (não utilizado diretamente no + # corpo do método). + # - +token_registro+ - instância de Token a ser destruída. + # Retorno:: O resultado de +redirect_to+. + # Efeitos colaterais:: Remove o registro de Token do banco de dados e + # redireciona o navegador para a página inicial + # com flash de sucesso. + def concluir_cadastro(usuario, token_registro) + token_registro.destroy + redirect_to root_path, flash: { success: "Cadastro concluído com sucesso! Faça seu login." } + end - usuario = Usuario.find_by(email: params[:email]) - return redirecionar_com_erro(solicitar_redef_senha_path, "Este e-mail não está cadastrado no sistema.") if usuario.nil? + # Redireciona de volta ao formulário de confirmação de cadastro quando + # o usuário não pôde ser salvo, exibindo as mensagens de erro de + # validação do model. + # + # NOTA: método atualmente não referenciado por nenhum fluxo ativo do + # controlador (mantido sem alteração de comportamento). + # + # Argumentos:: +usuario+ - instância de Usuario que falhou ao ser + # salva, contendo os erros de validação em + # +usuario.errors+. + # Retorno:: O resultado de +redirect_to+. + # Efeitos colaterais:: Redireciona o navegador de volta ao formulário + # de confirmação de cadastro, com flash de erro + # contendo as mensagens de validação do usuário. + def redirecionar_erro_salvamento_cadastro(usuario) + redirect_to confirmar_cadastro_path(token: params[:token]), + flash: { error: usuario.errors.full_messages.to_sentence } + end + # Gera e persiste um token de redefinição de senha para o usuário + # informado, com validade de 10 minutos. + # + # Argumentos:: +usuario+ - instância de Usuario para a qual o token + # será criado. + # Retorno:: String contendo o valor (hexadecimal) do token gerado. + # Efeitos colaterais:: Cria um novo registro de Token associado ao + # usuário no banco de dados (+usuario.tokens.create!+). + def criar_token_redefinicao(usuario) token_gerado = SecureRandom.hex(16) usuario.tokens.create!( value: token_gerado, tipo: "redefinicao", expires_at: 10.minutes.from_now ) + token_gerado + end - if enviar_email_redefinicao(params[:email], token_gerado) - redirect_to root_path, flash: { success: mensagem_email_enviado("10 minutos") } - else - redirect_to solicitar_redef_senha_path, - flash: { error: "Houve um erro técnico ao tentar enviar o e-mail de recuperação. Tente novamente mais tarde." } - end + # Redireciona de volta ao formulário de solicitação de redefinição de + # senha informando que o e-mail possui formato inválido. + # + # Argumentos:: Nenhum. + # Retorno:: O resultado de +redirect_to+ (via +redirecionar_com_erro+). + # Efeitos colaterais:: Redireciona o navegador com flash de erro. + def erro_solicitacao_redefinicao_senha + redirecionar_com_erro(solicitar_redef_senha_path, "Por favor, insira um formato de e-mail válido.") end - def confirmar_redefinicao_senha - if params[:senha].length < 6 - return redirecionar_com_erro( - redefinir_senha_path(token: params[:token]), - "A nova senha deve conter pelo menos 6 caracteres." - ) - end + # Redireciona de volta ao formulário de solicitação de redefinição de + # senha informando que o e-mail não está cadastrado no sistema. + # + # Argumentos:: Nenhum. + # Retorno:: O resultado de +redirect_to+ (via +redirecionar_com_erro+). + # Efeitos colaterais:: Redireciona o navegador com flash de erro. + def erro_email_nao_cadastrado + redirecionar_com_erro(solicitar_redef_senha_path, "Este e-mail não está cadastrado no sistema.") + end - if params[:senha] != params[:senha_confirmacao] - return redirecionar_com_erro( - redefinir_senha_path(token: params[:token]), - "As senhas não coincidem. Digite novamente." - ) - end + # Redireciona para a página inicial informando que o e-mail de + # redefinição de senha foi enviado com sucesso. + # + # Argumentos:: Nenhum. + # Retorno:: O resultado de +redirect_to+. + # Efeitos colaterais:: Redireciona o navegador para a página inicial + # com flash de sucesso contendo HTML com o tempo + # de validade do link. + def redirecionar_sucesso_redefinicao_senha + redirect_to root_path, flash: { success: mensagem_email_enviado("10 minutos") } + end - token_registro = buscar_token_valido(params[:token], "redefinicao") - if token_registro.nil? - return redirecionar_com_erro( - root_path, - "O link de redefinição é inválido, expirou ou não corresponde a esta operação." - ) - end + # Redireciona de volta ao formulário de solicitação de redefinição de + # senha informando que houve um erro técnico ao enviar o e-mail. + # + # Argumentos:: Nenhum. + # Retorno:: O resultado de +redirect_to+. + # Efeitos colaterais:: Redireciona o navegador com flash de erro. + def redirecionar_erro_redefinicao_senha + redirect_to solicitar_redef_senha_path, + flash: { error: "Houve um erro técnico ao tentar enviar o e-mail de recuperação. Tente novamente mais tarde." } + end - usuario = token_registro.usuario - usuario.senha = params[:senha] - usuario.senha_confirmation = params[:senha_confirmacao] + # Finaliza o fluxo de redefinição de senha descartando o token + # utilizado e redirecionando o usuário para a página de login. + # + # NOTA: método atualmente não referenciado por nenhum fluxo ativo do + # controlador (mantido sem alteração de comportamento). + # + # Argumentos:: + # - +usuario+ - instância de Usuario (não utilizado diretamente no + # corpo do método). + # - +token_registro+ - instância de Token a ser destruída. + # Retorno:: O resultado de +redirect_to+. + # Efeitos colaterais:: Remove o registro de Token do banco de dados e + # redireciona o navegador para a página inicial + # com flash de sucesso. + def concluir_redefinicao_senha(usuario, token_registro) + token_registro.destroy + redirect_to root_path, + flash: { success: "Sua senha foi alterada com sucesso! Insira suas novas credenciais para acessar." } + end - if usuario.save - token_registro.destroy - redirect_to root_path, - flash: { success: "Sua senha foi alterada com sucesso! Insira suas novas credenciais para acessar." } - else - redirect_to redefinir_senha_path(token: params[:token]), - flash: { error: usuario.errors.full_messages.to_sentence } - end + # Redireciona de volta ao formulário de redefinição de senha quando o + # usuário não pôde ser salvo, exibindo as mensagens de erro de + # validação do model. + # + # NOTA: método atualmente não referenciado por nenhum fluxo ativo do + # controlador (mantido sem alteração de comportamento). + # + # Argumentos:: +usuario+ - instância de Usuario que falhou ao ser + # salva, contendo os erros de validação em + # +usuario.errors+. + # Retorno:: O resultado de +redirect_to+. + # Efeitos colaterais:: Redireciona o navegador de volta ao formulário + # de redefinição de senha, com flash de erro + # contendo as mensagens de validação do usuário. + def redirecionar_erro_salvamento_redefinicao(usuario) + redirect_to redefinir_senha_path(token: params[:token]), + flash: { error: usuario.errors.full_messages.to_sentence } end - private + # Verifica se o e-mail informado em +params[:email]+ corresponde ao + # e-mail institucional já cadastrado para o usuário, ignorando caixa e + # espaços em branco. + # + # Argumentos:: +usuario+ - instância de Usuario cujo e-mail será + # comparado. + # Retorno:: +true+ se os e-mails coincidirem (normalizados), +false+ + # caso contrário. + # Efeitos colaterais:: Nenhum. + def email_corresponde_ao_institucional?(usuario) + usuario.email.to_s.downcase.strip == params[:email].to_s.downcase.strip + end + + # Gera e persiste um token de cadastro para o usuário informado, com + # validade de 10 minutos. + # + # Argumentos:: +usuario+ - instância de Usuario para a qual o token + # será criado. + # Retorno:: String contendo o valor (hexadecimal) do token gerado. + # Efeitos colaterais:: Cria um novo registro de Token associado ao + # usuário no banco de dados (+usuario.tokens.create!+). + def criar_token_cadastro(usuario) + token_gerado = SecureRandom.hex(16) + usuario.tokens.create!( + value: token_gerado, + tipo: "cadastro", + expires_at: 10.minutes.from_now + ) + token_gerado + end + + # Redireciona para a página inicial informando que o e-mail de + # confirmação de cadastro foi enviado com sucesso. + # + # Argumentos:: Nenhum. + # Retorno:: O resultado de +redirect_to+. + # Efeitos colaterais:: Redireciona o navegador para a página inicial + # com flash de sucesso contendo HTML com o tempo + # de validade do link. + def redirecionar_sucesso_email_cadastro + redirect_to root_path, flash: { success: mensagem_email_enviado("10 minutos") } + end + + # Redireciona de volta ao formulário de cadastro informando que houve + # um erro técnico ao enviar o e-mail de confirmação. + # + # Argumentos:: Nenhum. + # Retorno:: O resultado de +redirect_to+. + # Efeitos colaterais:: Redireciona o navegador com flash de erro. + def redirecionar_erro_email_cadastro + redirect_to cadastro_path, + flash: { error: "Houve um erro técnico ao tentar enviar o e-mail. Tente novamente mais tarde." } + end + # before_action que impede o acesso às actions de cadastro/login a + # usuários que já estão autenticados em uma sessão. + # + # Argumentos:: Nenhum diretamente; utiliza +current_user+. + # Retorno:: Não possui retorno relevante; quando há usuário logado a + # callback interrompe a execução com +return+, evitando que a + # action original seja executada. + # Efeitos colaterais:: Caso exista um usuário autenticado, redireciona + # para a página de avaliações com flash de erro, + # impedindo o processamento da action solicitada. def impedir_se_logado return unless current_user.present? @@ -202,6 +795,17 @@ def impedir_se_logado flash: { error: "Você já está logado em outra sessão. Faça o logout antes de poder terminar essa ação." } end + # before_action que valida o token recebido via URL (+params[:token]+) + # antes de permitir o acesso aos formulários de confirmação de cadastro + # ou de redefinição de senha. + # + # Argumentos:: Nenhum diretamente; utiliza +params[:token]+ e + # +action_name+ para determinar o tipo de token esperado. + # Retorno:: Não possui retorno relevante (callback de before_action). + # Efeitos colaterais:: Caso o token seja inválido, inexistente ou + # expirado, redireciona para a página inicial com + # flash de erro, impedindo o processamento da + # action solicitada. def validar_token_via_url tipo_esperado = action_name == "cadastrar" ? "cadastro" : "redefinicao" token = buscar_token_valido(params[:token], tipo_esperado) @@ -209,6 +813,14 @@ def validar_token_via_url redirect_to root_path, flash: { error: "Token inválido ou expirado." } if token.nil? end + # Localiza um usuário a partir de um identificador que pode ser tanto + # uma matrícula quanto um e-mail. + # + # Argumentos:: +identificador+ - String com a matrícula ou o e-mail + # informado pelo usuário. + # Retorno:: Uma instância de Usuario, caso encontrada, ou +nil+ caso + # contrário. + # Efeitos colaterais:: Nenhum (apenas consulta ao banco de dados). def buscar_usuario_por_identificador(identificador) identificador = identificador.to_s.strip @@ -219,11 +831,28 @@ def buscar_usuario_por_identificador(identificador) end end + # Verifica se uma string corresponde a um e-mail em formato válido. + # + # Argumentos:: +email+ - String a ser validada (pode ser +nil+ ou em + # branco). + # Retorno:: +true+ se o valor estiver presente e corresponder ao + # formato esperado de e-mail, +false+ caso contrário. + # Efeitos colaterais:: Nenhum. def email_valido?(email) email_regex = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i email.present? && email.match?(email_regex) end + # Busca um token pelo seu valor e tipo, garantindo que ele ainda esteja + # dentro do prazo de validade. + # + # Argumentos:: + # - +valor+ - String com o valor do token a ser buscado. + # - +tipo_esperado+ - String, "cadastro" ou "redefinicao", indicando o + # tipo de token esperado. + # Retorno:: A instância de Token encontrada e ainda válida, ou +nil+ + # caso o token não exista ou já tenha expirado. + # Efeitos colaterais:: Nenhum (apenas consulta ao banco de dados). def buscar_token_valido(valor, tipo_esperado) token = Token.find_by(value: valor, tipo: tipo_esperado) return nil if token.nil? || token.expires_at < Time.current @@ -231,10 +860,27 @@ def buscar_token_valido(valor, tipo_esperado) token end + # Método utilitário que redireciona o navegador para um caminho + # informado, definindo uma mensagem flash de erro. + # + # Argumentos:: + # - +caminho+ - String/rota de destino do redirecionamento. + # - +mensagem+ - String com o texto da mensagem flash de erro. + # Retorno:: O resultado de +redirect_to+. + # Efeitos colaterais:: Redireciona o navegador para o caminho informado + # e define +flash[:error]+. def redirecionar_com_erro(caminho, mensagem) redirect_to caminho, flash: { error: mensagem } end + # Monta o trecho de HTML exibido na mensagem flash de sucesso quando um + # e-mail (de cadastro ou de redefinição de senha) é enviado, incluindo + # um aviso sobre o prazo de validade do link. + # + # Argumentos:: +validade+ - String descrevendo o prazo de validade do + # link (ex.: "10 minutos"). + # Retorno:: String contendo o HTML da mensagem. + # Efeitos colaterais:: Nenhum. def mensagem_email_enviado(validade) <<~HTML E-mail enviado com sucesso! Caso não o veja na caixa de entrada, cheque sua caixa de spam. diff --git a/app/controllers/concerns/brevo_emailable.rb b/app/controllers/concerns/brevo_emailable.rb index 9ebec74946..dfefef056e 100644 --- a/app/controllers/concerns/brevo_emailable.rb +++ b/app/controllers/concerns/brevo_emailable.rb @@ -1,31 +1,152 @@ # frozen_string_literal: true +# Concern responsável por encapsular toda a integração com a API +# transacional de e-mails da Brevo (antigo Sendinblue). +# +# Fornece tanto os métodos de baixo nível para montar e disparar uma +# requisição HTTP para a API da Brevo (+chamar_api_brevo+ e auxiliares) +# quanto os métodos de alto nível usados pelo restante da aplicação para +# enviar e-mails específicos do fluxo de autenticação (cadastro, +# redefinição de senha e convite de administrador). module BrevoEmailable extend ActiveSupport::Concern + # URI base do endpoint de envio de e-mails transacionais da API da + # Brevo. BREVO_API_URL = URI("https://api.brevo.com/v3/smtp/email") + + # Dados do remetente (nome e e-mail) utilizados em todos os e-mails + # enviados por este concern. REMETENTE = { "name" => "CAMAAR Support", "email" => "rafaelsapienzapinheiro@gmail.com" }.freeze + # Monta e dispara uma requisição HTTP POST para a API da Brevo com o + # payload informado, tratando ausência de chave de API e erros + # inesperados. + # + # Argumentos:: + # - +payload+ - Hash com o corpo da requisição a ser enviado em JSON + # para a API da Brevo (remetente, destinatário, assunto e conteúdo + # HTML do e-mail). + # - +contexto:+ - String opcional usada apenas para identificar a + # origem da chamada nos logs (ex.: "Cadastro", "Redefinição de + # senha"). Valor padrão: string vazia. + # Retorno:: +true+ se o e-mail foi enviado com sucesso (resposta HTTP + # 2xx); +false+ se a chave de API não estiver configurada, + # se a API retornar uma resposta de erro, ou se ocorrer + # qualquer exceção durante a chamada. + # Efeitos colaterais:: Realiza uma chamada HTTP externa para a API da + # Brevo (efeito de rede) e grava mensagens de + # log (+Rails.logger.info+/+error+) descrevendo o + # resultado da operação. Não realiza alterações + # no banco de dados. def chamar_api_brevo(payload, contexto: "") api_key = brevo_api_key - unless api_key.present? - Rails.logger.error "[BREVO] #{contexto} — Token de API não configurado." - return false - end + return false unless brevo_api_key_configurada?(api_key, contexto) + + response = brevo_http.request(brevo_request(payload, api_key)) + brevo_response_sucesso?(response, contexto) + rescue StandardError => e + Rails.logger.error "[BREVO] #{contexto} — Erro inesperado: #{e.message}" + false + end + + # Obtém a chave de API da Brevo a partir das credenciais criptografadas + # do Rails. + # + # NOTA: este método público é redefinido pela versão privada presente + # no final do arquivo (que também considera a variável de ambiente + # +BREVO_API_KEY+); por ser definido depois, é a versão privada que + # efetivamente é executada em tempo de execução. Mantido aqui sem + # alteração de comportamento. + # + # Argumentos:: Nenhum. + # Retorno:: String com a chave de API configurada, ou +nil+ caso não + # exista nenhuma credencial cadastrada para +brevo.api_key+. + # Efeitos colaterais:: Nenhum. + def brevo_api_key + Rails.application.credentials.dig(:brevo, :api_key) + end + + # Verifica se a chave de API da Brevo está configurada, registrando um + # log de erro caso não esteja. + # + # Argumentos:: + # - +api_key+ - String (ou +nil+) com a chave de API a ser verificada. + # - +contexto+ - String usada para identificar a origem da chamada na + # mensagem de log em caso de erro. + # Retorno:: +true+ se +api_key+ estiver presente, +false+ caso + # contrário. + # Efeitos colaterais:: Caso a chave não esteja configurada, grava uma + # mensagem de log de erro (+Rails.logger.error+). + def brevo_api_key_configurada?(api_key, contexto) + return true if api_key.present? - headers = { + Rails.logger.error "[BREVO] #{contexto} — Token de API não configurado." + false + end + + # Monta o conjunto de cabeçalhos HTTP exigidos pela API da Brevo. + # + # Argumentos:: +api_key+ - String com a chave de API a ser enviada no + # cabeçalho de autenticação. + # Retorno:: Hash contendo os cabeçalhos +Accept+, +api-key+ e + # +Content-Type+. + # Efeitos colaterais:: Nenhum. + def brevo_headers(api_key) + { "Accept" => "application/json", "api-key" => api_key, "Content-Type" => "application/json" } + end - http = Net::HTTP.new(BREVO_API_URL.host, BREVO_API_URL.port) - http.use_ssl = true - request = Net::HTTP::Post.new(BREVO_API_URL.path, headers) - request.body = payload.to_json + # Constrói o cliente HTTP configurado para se comunicar com a API da + # Brevo via HTTPS. + # + # Argumentos:: Nenhum. + # Retorno:: Instância de +Net::HTTP+ configurada com o host e a porta + # de +BREVO_API_URL+ e com +use_ssl+ habilitado. + # Efeitos colaterais:: Nenhum (apenas instancia o objeto; a conexão de + # rede só ocorre quando a requisição é de fato + # executada em +chamar_api_brevo+). + def brevo_http + Net::HTTP.new(BREVO_API_URL.host, BREVO_API_URL.port).tap do |http| + http.use_ssl = true + end + end - response = http.request(request) + # Monta o objeto de requisição HTTP POST a ser enviado para a API da + # Brevo, já com cabeçalhos e corpo (payload serializado em JSON) + # definidos. + # + # Argumentos:: + # - +payload+ - Hash a ser serializado em JSON e enviado como corpo da + # requisição. + # - +api_key+ - String com a chave de API usada para montar os + # cabeçalhos de autenticação. + # Retorno:: Instância de +Net::HTTP::Post+ pronta para ser executada. + # Efeitos colaterais:: Nenhum (apenas monta o objeto da requisição; o + # envio efetivo ocorre em +chamar_api_brevo+). + def brevo_request(payload, api_key) + Net::HTTP::Post.new(BREVO_API_URL.path, brevo_headers(api_key)).tap do |request| + request.body = payload.to_json + end + end + # Avalia a resposta HTTP retornada pela API da Brevo, registrando o + # resultado (sucesso ou falha) no log da aplicação. + # + # Argumentos:: + # - +response+ - Objeto de resposta HTTP (+Net::HTTPResponse+) + # retornado pela chamada à API. + # - +contexto+ - String usada para identificar a origem da chamada na + # mensagem de log. + # Retorno:: +true+ se a resposta for uma instância de + # +Net::HTTPSuccess+ (status 2xx), +false+ caso contrário. + # Efeitos colaterais:: Grava uma mensagem de log (+info+ em caso de + # sucesso, +error+ em caso de falha, incluindo o + # código de status e o corpo da resposta). + def brevo_response_sucesso?(response, contexto) if response.is_a?(Net::HTTPSuccess) Rails.logger.info "[BREVO] #{contexto} — E-mail enviado com sucesso." true @@ -33,11 +154,22 @@ def chamar_api_brevo(payload, contexto: "") Rails.logger.error "[BREVO] #{contexto} — Falha. Código: #{response.code} | Resposta: #{response.body}" false end - rescue StandardError => e - Rails.logger.error "[BREVO] #{contexto} — Erro inesperado: #{e.message}" - false end + # Monta e envia o e-mail de confirmação de cadastro (primeiro acesso), + # contendo o link com o token para o usuário definir sua senha. + # + # Argumentos:: + # - +destinatario+ - String com o e-mail do usuário que receberá a + # mensagem. + # - +token+ - String com o token de cadastro a ser incluído na URL de + # confirmação. + # Retorno:: +true+ se o e-mail foi enviado com sucesso, +false+ caso + # contrário (delegado a +chamar_api_brevo+). + # Efeitos colaterais:: Realiza uma chamada HTTP externa para a API da + # Brevo (envio de e-mail) e grava mensagens de log + # sobre o resultado. Não realiza alterações no + # banco de dados. def enviar_email_cadastro(destinatario, token) url = "http://127.0.0.1:3000/cadastro/confirmar/?token=#{token}" @@ -72,6 +204,20 @@ def enviar_email_cadastro(destinatario, token) chamar_api_brevo(payload, contexto: "Cadastro") end + # Monta e envia o e-mail de redefinição de senha, contendo o link com o + # token para o usuário escolher uma nova senha. + # + # Argumentos:: + # - +destinatario+ - String com o e-mail do usuário que receberá a + # mensagem. + # - +token+ - String com o token de redefinição a ser incluído na URL + # de confirmação. + # Retorno:: +true+ se o e-mail foi enviado com sucesso, +false+ caso + # contrário (delegado a +chamar_api_brevo+). + # Efeitos colaterais:: Realiza uma chamada HTTP externa para a API da + # Brevo (envio de e-mail) e grava mensagens de log + # sobre o resultado. Não realiza alterações no + # banco de dados. def enviar_email_redefinicao(destinatario, token) url = "http://127.0.0.1:3000/redefinir-senha/confirmar/?token=#{token}" @@ -106,13 +252,29 @@ def enviar_email_redefinicao(destinatario, token) chamar_api_brevo(payload, contexto: "Redefinição de senha") end + # Monta e envia o e-mail de convite de cadastro disparado por um + # administrador para um novo usuário, contendo o link com o token para + # definição de senha. + # + # Argumentos:: + # - +destinatario+ - String com o e-mail do usuário convidado. + # - +token+ - String com o token de cadastro a ser incluído na URL de + # confirmação. + # - +nome_admin+ - String com o nome do administrador que está + # realizando o convite, exibido no assunto e no corpo do e-mail. + # Retorno:: +true+ se o e-mail foi enviado com sucesso, +false+ caso + # contrário (delegado a +chamar_api_brevo+). + # Efeitos colaterais:: Realiza uma chamada HTTP externa para a API da + # Brevo (envio de e-mail) e grava mensagens de log + # sobre o resultado. Não realiza alterações no + # banco de dados. def enviar_email_convite_admin(destinatario, token, nome_admin) url = "http://127.0.0.1:3000/cadastro/confirmar/?token=#{token}" payload = { "sender" => REMETENTE, "to" => [ { "email" => destinatario } ], - "subject" => "Convite de Cadastro no CAMAAR — Professor(a) #{nome_admin}", + "subject" => "Convite de Cadastro no CAMAAR — Administrador(a) #{nome_admin}", "htmlContent" => <<~HTML @@ -141,6 +303,19 @@ def enviar_email_convite_admin(destinatario, token, nome_admin) private + # Obtém a chave de API da Brevo, priorizando a variável de ambiente + # +BREVO_API_KEY+ e, na ausência dela, recorrendo às credenciais + # criptografadas do Rails. Esta é a versão efetivamente utilizada pela + # aplicação, pois reabre/sobrescreve a definição pública de + # +brevo_api_key+ declarada no início do módulo. + # + # Argumentos:: Nenhum. + # Retorno:: String com a chave de API encontrada (da variável de + # ambiente ou das credenciais), ou +nil+ caso nenhuma das + # duas fontes possua um valor configurado, ou caso as + # credenciais não possam ser decifradas. + # Efeitos colaterais:: Nenhum (apenas leitura de variável de ambiente e + # de credenciais; nenhuma gravação é realizada). def brevo_api_key ENV["BREVO_API_KEY"].presence || Rails.application.credentials.dig(:brevo, :api_key) rescue ActiveSupport::MessageEncryptor::InvalidMessage, ActiveSupport::MessageVerifier::InvalidSignature From 8d3efeb1f663cb744a9cfa635349e2b236b67aef Mon Sep 17 00:00:00 2001 From: Henrique Date: Tue, 30 Jun 2026 21:02:19 -0300 Subject: [PATCH 122/125] =?UTF-8?q?docs(gerenciamento-projeto):=20adiciona?= =?UTF-8?q?=20coment=C3=A1rios=20explicativos=20ao=20c=C3=B3digo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/controllers/dashboard_controller.rb | 1051 +++++++++++++++++++---- spec/rails_helper.rb | 5 + 2 files changed, 901 insertions(+), 155 deletions(-) diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index ceca3cbca8..a6c7c7e3e4 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -2,90 +2,201 @@ require "json" -# Controla as telas de dashboard e as ações administrativas ligadas ao SIGAA. +# Controla telas de dashboard, pesquisa e ações administrativas ligadas ao SIGAA. # -# As ações administrativas importam dados institucionais, sincronizam a base -# local e disparam convites para usuários pendentes definirem senha. +# Este controller reúne o painel do usuário, a busca global, a importação e +# sincronização de dados do SIGAA e o envio de convites para definição de senha. class DashboardController < ApplicationController include BrevoEmailable - before_action :verificar_admin, only: [ :gerenciamento, :importar_dados, :enviar_solicitacoes ] + before_action :verificar_usuario, only: %i[index pesquisar sugestoes] + before_action :verificar_admin, only: %i[gerenciamento importar_dados enviar_solicitacoes] - # Exibe o dashboard inicial do usuário autenticado. + # Exibe o dashboard do usuário autenticado. + # + # Não recebe argumentos. # # @return [void] - # @side_effect Redireciona usuários não autenticados para a página inicial e, - # para usuários autenticados, popula a lista simulada de turmas exibida na - # view. + # @side_effect Consulta avaliações pendentes do usuário atual e atribui + # +@avaliacoes_pendentes+ para a view. def index - if current_user.nil? - redirect_to root_path, flash: { error: "Acesso restrito. Por favor, faça login para continuar." } and return - end + @avaliacoes_pendentes = avaliacoes_do_usuario.limit(6) + end - @turmas_simuladas = [ - { materia: "Estruturas de Dados", semestre: "2026.1", professor: "Alessandro Silva" }, - { materia: "Bancos de Dados", semestre: "2026.1", professor: "Alessandro Silva" }, - { materia: "Cálculo 1", semestre: "2026.1", professor: "Maria Carmo" }, - { materia: "Compiladores", semestre: "2026.1", professor: "A definir" }, - { materia: "Sistemas Operacionais", semestre: "2026.1", professor: "A definir" } - ] + # Executa a pesquisa global do dashboard. + # + # Não recebe argumentos diretamente; usa +params[:q]+, +params[:turma_id]+ e + # filtros enviados pela requisição. + # + # @return [void] + # @side_effect Inicializa variáveis de instância usadas pela view e preenche + # resultados de avaliações, templates ou formulários conforme o filtro. + def pesquisar + inicializar_pesquisa + + return if @termo.blank? + return pesquisar_por_turma if params[:turma_id].present? + + pesquisar_por_termo end # Exibe a página administrativa de gerenciamento. # + # Não recebe argumentos. + # # @return [void] - # @side_effect Renderiza a tela com ações de importação do SIGAA e envio de - # solicitações de cadastro. + # @side_effect Renderiza a tela com ações de importação do SIGAA, envio de + # solicitações de cadastro e links administrativos. def gerenciamento end - # Envia solicitações de definição de senha para usuários importados pendentes. + # Envia solicitações de definição de senha para usuários pendentes. # - # Filtra docentes e discentes pendentes pelo departamento do administrador - # logado, cria tokens de cadastro e envia convites por e-mail. + # Não recebe argumentos diretamente; usa o departamento do administrador + # autenticado para filtrar docentes e discentes pendentes. # # @return [void] - # @side_effect Consulta usuários, cria registros de Token, chama a API de - # e-mail via Brevo e redireciona para a página de gerenciamento com flash de - # sucesso, aviso ou erro parcial. + # @side_effect Cria tokens de cadastro, envia e-mails pela Brevo e redireciona + # para +gerenciamento_path+ com flash de sucesso, aviso ou erro parcial. def enviar_solicitacoes depto_id = current_user.perfil_adm&.departamento_id if depto_id.blank? redirect_to gerenciamento_path, flash: { error: "Seu usuário não possui um departamento associado." } and return end + + usuarios_pendentes = usuarios_pendentes_do_departamento(depto_id) + + if usuarios_pendentes.empty? + redirect_to gerenciamento_path, flash: { notice: "Não há usuários pendentes de cadastro (docentes ou discentes) neste departamento." } and return + end + + resultado = enviar_convites_pendentes(usuarios_pendentes) + redirecionar_envio_solicitacoes(resultado[:sucessos], resultado[:erros_envio]) + end + + # Importa e sincroniza dados do SIGAA na base local. + # + # Não recebe argumentos diretamente; lê o arquivo retornado por + # #caminho_arquivo_sigaa. + # + # @return [void] + # @side_effect Lê JSON, cria ou atualiza matérias, turmas, usuários, perfis e + # participações, remove dados obsoletos e redireciona com flash de sucesso + # ou erro parcial. + # @raise [JSON::ParserError] Quando o arquivo de entrada não contém JSON + # válido. + def importar_dados + caminho_arquivo = caminho_arquivo_sigaa + unless File.exist?(caminho_arquivo) + redirect_to gerenciamento_path, flash: { error: "Arquivo JSON não encontrado em db/" } and return + end + + dados = JSON.parse(File.read(caminho_arquivo)) + contexto = contexto_importacao_sigaa + + importar_materias_sigaa(dados, contexto) + importar_turmas_sigaa(dados, contexto) + importar_docentes_sigaa(dados, contexto) + importar_discentes_sigaa(dados, contexto) + sincronizar_remocoes_sigaa(contexto) + redirecionar_importacao_sigaa(contexto[:erros_importacao]) + end + + # Retorna sugestões de pesquisa em JSON. + # + # Não recebe argumentos diretamente; usa +params[:q]+ e filtros da requisição. + # + # @return [void] + # @side_effect Renderiza uma resposta JSON com sugestões ou uma lista vazia. + def sugestoes + termo = params[:q].to_s.strip + return render json: [] if termo.blank? + + render json: sugestoes_do_termo(termo) + end + + private + + # Busca usuários pendentes pertencentes ao departamento informado. + # + # @param depto_id [Integer] Identificador do departamento do administrador. + # @return [Array] Lista única de docentes e discentes pendentes. + def usuarios_pendentes_do_departamento(depto_id) turmas_do_departamento_ids = Turma.joins(:materia).where(materias: { departamento_id: depto_id }).ids discentes_pendentes = Usuario.joins(:participacoes_turma) .where(status: 0, participacoes_turma: { turma_id: turmas_do_departamento_ids }) docentes_pendentes = Usuario.joins(:perfil_docente) .where(status: 0, perfis_docentes: { departamento_id: depto_id }) - usuarios_pendentes = (discentes_pendentes + docentes_pendentes).uniq - if usuarios_pendentes.empty? - redirect_to gerenciamento_path, flash: { notice: "Não há usuários pendentes de cadastro (docentes ou discentes) neste departamento." } and return - end + (discentes_pendentes + docentes_pendentes).uniq + end + + # Envia convites para uma coleção de usuários pendentes. + # + # @param usuarios_pendentes [Enumerable] Usuários que devem receber + # link de definição de senha. + # @return [Hash] Resultado com chaves +:sucessos+ e +:erros_envio+. + # @side_effect Pode criar tokens e enviar e-mails para cada usuário. + def enviar_convites_pendentes(usuarios_pendentes) + resultado = { sucessos: 0, erros_envio: [] } - sucessos = 0 - erros_envio = [] usuarios_pendentes.each do |usuario| - ActiveRecord::Base.transaction do - token_gerado = SecureRandom.hex(16) - - usuario.tokens.create!( - value: token_gerado, - tipo: "cadastro", - expires_at: 10.minutes.from_now - ) - - if enviar_email_convite_admin(usuario.email, token_gerado, current_user.nome) - sucessos += 1 - else - raise "Falha de comunicação com a Brevo." - end - rescue StandardError => e - erros_envio << "#{usuario.nome} (Matrícula: #{usuario.matricula}): #{e.message}" - raise ActiveRecord::Rollback + resultado[:sucessos] += 1 if enviar_convite_pendente(usuario, resultado[:erros_envio]) + end + + resultado + end + + # Envia um convite de cadastro para um usuário pendente. + # + # @param usuario [Usuario] Usuário que receberá o convite. + # @param erros_envio [Array] Lista mutável onde erros serão anexados. + # @return [Boolean] +true+ quando o e-mail foi enviado; +false+ quando houve + # erro e a transação foi revertida. + # @side_effect Cria Token em transação, chama a API de e-mail e pode adicionar + # mensagens em +erros_envio+. + def enviar_convite_pendente(usuario, erros_envio) + enviado = false + + ActiveRecord::Base.transaction do + token_gerado = criar_token_cadastro!(usuario) + + if enviar_email_convite_admin(usuario.email, token_gerado, current_user.nome) + enviado = true + else + raise "Falha de comunicação com a Brevo." end + rescue StandardError => e + erros_envio << "#{usuario.nome} (Matrícula: #{usuario.matricula}): #{e.message}" + raise ActiveRecord::Rollback end + + enviado + end + + # Cria um token temporário de cadastro para o usuário. + # + # @param usuario [Usuario] Usuário dono do token. + # @return [String] Valor do token gerado. + # @side_effect Persiste um registro de Token com validade de dez minutos. + def criar_token_cadastro!(usuario) + token_gerado = SecureRandom.hex(16) + usuario.tokens.create!( + value: token_gerado, + tipo: "cadastro", + expires_at: 10.minutes.from_now + ) + token_gerado + end + + # Redireciona após o envio de solicitações de cadastro. + # + # @param sucessos [Integer] Quantidade de e-mails enviados com sucesso. + # @param erros_envio [Array] Erros coletados durante os envios. + # @return [void] + # @side_effect Redireciona para +gerenciamento_path+ com flash de sucesso ou + # erro parcial. + def redirecionar_envio_solicitacoes(sucessos, erros_envio) if erros_envio.empty? redirect_to gerenciamento_path, flash: { success: "Convites enviados com sucesso para os #{sucessos} usuários do departamento!" } else @@ -96,124 +207,346 @@ def enviar_solicitacoes end end - # Importa e sincroniza matérias, turmas, docentes, discentes e matrículas. + # Informa o caminho do arquivo JSON usado como fonte SIGAA. + # + # Não recebe argumentos. + # + # @return [Pathname] Caminho para +db/usuarios_sigaa.json+. + def caminho_arquivo_sigaa + Rails.root.join("db", "usuarios_sigaa.json") + end + + # Cria a estrutura de controle usada durante a importação SIGAA. + # + # Não recebe argumentos. # - # Lê o arquivo +db/usuarios_sigaa.json+, cria ou atualiza registros locais - # conforme as matrículas e códigos vindos da fonte SIGAA simulada e remove - # dados que deixaram de aparecer na carga atual, preservando administradores. + # @return [Hash] Hash com listas de matérias, turmas, matrículas ativas e + # erros de importação. + def contexto_importacao_sigaa + { + codigos_materias_ativos: [], + turmas_ativas_ids: [], + matriculas_ativas_json: [], + erros_importacao: [] + } + end + + # Importa todas as matérias presentes no JSON do SIGAA. # + # @param dados [Hash] Dados parseados do arquivo SIGAA. + # @param contexto [Hash] Estrutura de controle da importação. # @return [void] - # @side_effect Lê arquivo JSON, altera Materia, Turma, Usuario, - # PerfilDocente, PerfilDiscente e ParticipacaoTurma no banco, remove dados - # obsoletos e redireciona para a página de gerenciamento com flash de - # sucesso ou erro parcial. - # @raise [JSON::ParserError] Quando o arquivo JSON existe, mas contém dados - # inválidos para parse. - def importar_dados - caminho_arquivo = Rails.root.join("db", "usuarios_sigaa.json") - unless File.exist?(caminho_arquivo) - redirect_to gerenciamento_path, flash: { error: "Arquivo JSON não encontrado em db/" } and return - end - dados = JSON.parse(File.read(caminho_arquivo)) - codigos_materias_ativos = [] - turmas_ativas_ids = [] - matriculas_ativas_json = [] - erros_importacao = [] + # @side_effect Cria ou atualiza registros de Materia e acumula erros no + # contexto. + def importar_materias_sigaa(dados, contexto) dados["materias"]&.each do |materia_json| - codigo = materia_json["codigo"] + importar_materia_sigaa(materia_json, contexto) + end + end - ActiveRecord::Base.transaction do - codigos_materias_ativos << codigo + # Importa uma matéria individual do SIGAA. + # + # @param materia_json [Hash] Dados da matéria, incluindo código, nome e + # departamento. + # @param contexto [Hash] Estrutura de controle da importação. + # @return [void] + # @side_effect Persiste Materia, registra o código como ativo e adiciona erro + # ao contexto quando a importação falha. + def importar_materia_sigaa(materia_json, contexto) + codigo = materia_json["codigo"] - materia = Materia.find_or_initialize_by(codigo: codigo) - materia.nome = materia_json["nome"] - materia.departamento_id = materia_json["departamento_id_temp"] - materia.save! - end - rescue StandardError => e - erros_importacao << "Matéria #{materia_json['nome']} (Código: #{codigo}): #{e.message}" + ActiveRecord::Base.transaction do + contexto[:codigos_materias_ativos] << codigo + materia = Materia.find_or_initialize_by(codigo: codigo) + materia.nome = materia_json["nome"] + materia.departamento_id = materia_json["departamento_id_temp"] + materia.save! + end + rescue StandardError => e + contexto[:erros_importacao] << "Matéria #{materia_json['nome']} (Código: #{codigo}): #{e.message}" + end + + # Importa todas as turmas presentes no JSON do SIGAA. + # + # @param dados [Hash] Dados parseados do arquivo SIGAA. + # @param contexto [Hash] Estrutura de controle da importação. + # @return [void] + # @side_effect Cria ou atualiza registros de Turma e acumula erros no + # contexto. + def importar_turmas_sigaa(dados, contexto) + dados["turmas"]&.each do |turma_json| + importar_turma_sigaa(turma_json, contexto) end + end + + # Importa uma turma individual do SIGAA. + # + # @param turma_json [Hash] Dados da turma, incluindo número, ano, semestre e + # código da matéria. + # @param contexto [Hash] Estrutura de controle da importação. + # @return [void] + # @side_effect Persiste Turma, registra seu ID como ativo e adiciona erro ao + # contexto quando a importação falha. + def importar_turma_sigaa(turma_json, contexto) ActiveRecord::Base.transaction do - dados["turmas"]&.each do |turma_json| - materia = Materia.find_by(codigo: turma_json["materia_codigo"]) - next unless materia - - turma = Turma.find_or_initialize_by( - numero: turma_json["numero"], - ano: turma_json["ano"], - semestre: turma_json["semestre"], - materia_id: materia.id - ) - turma.save! - turmas_ativas_ids << turma.id - end + materia = encontrar_materia_sigaa!(turma_json["materia_codigo"]) + turma = salvar_turma_sigaa!(turma_json, materia) + contexto[:turmas_ativas_ids] << turma.id end + rescue StandardError => e + contexto[:erros_importacao] << "Turma nº #{turma_json['numero']} (#{turma_json['ano']}/#{turma_json['semestre']}) da matéria '#{turma_json['materia_codigo']}': #{e.message}" + end + + # Cria ou atualiza uma turma a partir dos dados do SIGAA. + # + # @param turma_json [Hash] Dados da turma importada. + # @param materia [Materia] Matéria associada à turma. + # @return [Turma] Turma persistida. + # @side_effect Altera ou cria registro de Turma no banco. + # @raise [ActiveRecord::RecordInvalid] Quando a turma não passa nas + # validações. + def salvar_turma_sigaa!(turma_json, materia) + turma = Turma.find_or_initialize_by( + numero: turma_json["numero"], + ano: turma_json["ano"], + semestre: turma_json["semestre"], + materia_id: materia.id + ) + turma.save! + turma + end + + # Importa todos os docentes presentes no JSON do SIGAA. + # + # @param dados [Hash] Dados parseados do arquivo SIGAA. + # @param contexto [Hash] Estrutura de controle da importação. + # @return [void] + # @side_effect Cria ou atualiza usuários docentes, perfis docentes e + # participações em turmas. + def importar_docentes_sigaa(dados, contexto) dados["usuarios_docentes"]&.each do |docente_json| - matricula = docente_json["matricula"] - ActiveRecord::Base.transaction do - matriculas_ativas_json << matricula - usuario = Usuario.find_or_initialize_by(matricula: matricula) - usuario.nome = docente_json["nome"] - usuario.email = docente_json["email"] - if usuario.new_record? - usuario.status = 0 - usuario.senha = "" - end - usuario.save! - perfil = PerfilDocente.find_or_initialize_by(id: usuario.id) - perfil.departamento_id = docente_json["departamento_id_temp"] - perfil.save! - end - rescue StandardError => e - erros_importacao << "Docente #{docente_json['nome']} (Matrícula: #{matricula}): #{e.message}" + importar_docente_sigaa(docente_json, contexto) end + end + + # Importa um docente individual do SIGAA. + # + # @param docente_json [Hash] Dados do docente, incluindo matrícula, nome, + # e-mail, departamento e turmas lecionadas. + # @param contexto [Hash] Estrutura de controle da importação. + # @return [void] + # @side_effect Persiste Usuario, PerfilDocente e ParticipacaoTurma, remove + # participações docentes antigas e registra erros no contexto. + def importar_docente_sigaa(docente_json, contexto) + matricula = docente_json["matricula"] + + ActiveRecord::Base.transaction do + contexto[:matriculas_ativas_json] << matricula + usuario = salvar_usuario_importado_sigaa!(docente_json, matricula) + salvar_perfil_docente_sigaa!(usuario, docente_json) + turmas_docente_ids = importar_participacoes_docente_sigaa!(usuario, docente_json) + usuario.participacoes_turma.docentes.where.not(turma_id: turmas_docente_ids).destroy_all + end + rescue StandardError => e + contexto[:erros_importacao] << "Docente #{docente_json['nome']} (Matrícula: #{matricula}): #{e.message}" + end + + # Cria ou atualiza o perfil docente do usuário importado. + # + # @param usuario [Usuario] Usuário docente importado. + # @param docente_json [Hash] Dados do docente vindos do SIGAA. + # @return [void] + # @side_effect Persiste PerfilDocente associado ao usuário. + # @raise [ActiveRecord::RecordInvalid] Quando o perfil não passa nas + # validações. + def salvar_perfil_docente_sigaa!(usuario, docente_json) + perfil = PerfilDocente.find_or_initialize_by(id: usuario.id) + perfil.departamento_id = docente_json["departamento_id_temp"] + perfil.save! + end + + # Importa as participações docentes nas turmas lecionadas. + # + # @param usuario [Usuario] Usuário docente importado. + # @param docente_json [Hash] Dados do docente com a lista de turmas. + # @return [Array] IDs das turmas em que o docente permanece ativo. + # @side_effect Cria participações docentes no banco. + def importar_participacoes_docente_sigaa!(usuario, docente_json) + turmas_docente_ids = [] + (docente_json["turmas_lecionadas"] || []).each do |mat_json| + importar_participacao_sigaa!(usuario, mat_json, :docente, turmas_docente_ids) + end + turmas_docente_ids + end + + # Importa todos os discentes presentes no JSON do SIGAA. + # + # @param dados [Hash] Dados parseados do arquivo SIGAA. + # @param contexto [Hash] Estrutura de controle da importação. + # @return [void] + # @side_effect Cria ou atualiza usuários discentes, perfis discentes e + # matrículas em turmas. + def importar_discentes_sigaa(dados, contexto) dados["usuarios_discentes"]&.each do |discente_json| - matricula = discente_json["matricula"] - ActiveRecord::Base.transaction do - matriculas_ativas_json << matricula - - usuario = Usuario.find_or_initialize_by(matricula: matricula) - usuario.nome = discente_json["nome"] - usuario.email = discente_json["email"] - if usuario.new_record? - usuario.status = 0 - usuario.senha = "" - end - usuario.save! - PerfilDiscente.find_or_create_by!(id: usuario.id) - turmas_aluno_ids = [] - discente_json["turmas_matriculadas"].each do |mat_json| - materia = Materia.find_by(codigo: mat_json["materia_codigo"]) - if materia.nil? - raise "Matéria com código '#{mat_json['materia_codigo']}' não existe no sistema." - end - turma = Turma.find_by(materia_id: materia.id, numero: mat_json["numero_turma"], ano: 2026, semestre: 1) - if turma.nil? - raise "Turma nº #{mat_json['numero_turma']} da matéria '#{materia.nome}' não foi localizada no sistema." - end - - turmas_aluno_ids << turma.id - ParticipacaoTurma.find_or_create_by!( - usuario_id: usuario.id, - turma_id: turma.id, - tipo_participacao: :discente - ) - end - usuario.participacoes_turma.where.not(turma_id: turmas_aluno_ids).destroy_all - end - rescue StandardError => e - erros_importacao << "Discente #{discente_json['nome']} (Matrícula: #{matricula}): #{e.message}" + importar_discente_sigaa(discente_json, contexto) end + end + + # Importa um discente individual do SIGAA. + # + # @param discente_json [Hash] Dados do discente, incluindo matrícula, nome, + # e-mail e turmas matriculadas. + # @param contexto [Hash] Estrutura de controle da importação. + # @return [void] + # @side_effect Persiste Usuario, PerfilDiscente e ParticipacaoTurma, remove + # matrículas antigas e registra erros no contexto. + def importar_discente_sigaa(discente_json, contexto) + matricula = discente_json["matricula"] + ActiveRecord::Base.transaction do - Turma.where.not(id: turmas_ativas_ids).destroy_all - Materia.where.not(codigo: codigos_materias_ativos).destroy_all - usuarios_para_remover = Usuario.where.not(matricula: matriculas_ativas_json) - usuarios_para_remover.each do |usuario| - next if usuario.administrador? + contexto[:matriculas_ativas_json] << matricula + usuario = salvar_usuario_importado_sigaa!(discente_json, matricula) + PerfilDiscente.find_or_create_by!(id: usuario.id) + turmas_aluno_ids = importar_participacoes_discente_sigaa!(usuario, discente_json) + usuario.participacoes_turma.where.not(turma_id: turmas_aluno_ids).destroy_all + end + rescue StandardError => e + contexto[:erros_importacao] << "Discente #{discente_json['nome']} (Matrícula: #{matricula}): #{e.message}" + end - usuario.destroy! - end + # Importa as participações discentes nas turmas matriculadas. + # + # @param usuario [Usuario] Usuário discente importado. + # @param discente_json [Hash] Dados do discente com turmas matriculadas. + # @return [Array] IDs das turmas em que o discente permanece ativo. + # @side_effect Cria participações discentes no banco. + def importar_participacoes_discente_sigaa!(usuario, discente_json) + turmas_aluno_ids = [] + discente_json["turmas_matriculadas"].each do |mat_json| + importar_participacao_sigaa!(usuario, mat_json, :discente, turmas_aluno_ids) + end + turmas_aluno_ids + end + + # Cria ou atualiza um usuário importado do SIGAA. + # + # @param usuario_json [Hash] Dados do usuário vindos do SIGAA. + # @param matricula [String] Matrícula institucional usada como chave. + # @return [Usuario] Usuário persistido. + # @side_effect Altera ou cria registro de Usuario no banco. + # @raise [ActiveRecord::RecordInvalid] Quando o usuário não passa nas + # validações. + def salvar_usuario_importado_sigaa!(usuario_json, matricula) + usuario = Usuario.find_or_initialize_by(matricula: matricula) + usuario.nome = usuario_json["nome"] + usuario.email = usuario_json["email"] + inicializar_usuario_importado_sigaa(usuario) + usuario.save! + usuario + end + + # Define estado inicial para usuário recém-importado. + # + # @param usuario [Usuario] Usuário novo ou existente. + # @return [void] + # @side_effect Para usuários novos, define status pendente e senha vazia. + def inicializar_usuario_importado_sigaa(usuario) + return unless usuario.new_record? + + usuario.status = 0 + usuario.senha = "" + end + + # Importa uma participação de usuário em turma do SIGAA. + # + # @param usuario [Usuario] Usuário que será vinculado à turma. + # @param mat_json [Hash] Dados da matrícula ou turma lecionada. + # @param tipo_participacao [Symbol] Tipo da participação, como +:docente+ ou + # +:discente+. + # @param turmas_ids [Array] Lista mutável que recebe IDs ativos. + # @return [ParticipacaoTurma] Participação encontrada ou criada. + # @side_effect Cria ParticipacaoTurma e adiciona o ID da turma em + # +turmas_ids+. + # @raise [RuntimeError] Quando a matéria ou turma não é encontrada. + def importar_participacao_sigaa!(usuario, mat_json, tipo_participacao, turmas_ids) + turma = encontrar_turma_sigaa!(mat_json) + turmas_ids << turma.id + ParticipacaoTurma.find_or_create_by!( + usuario_id: usuario.id, + turma_id: turma.id, + tipo_participacao: tipo_participacao + ) + end + + # Encontra uma matéria pelo código importado do SIGAA. + # + # @param codigo [String] Código institucional da matéria. + # @return [Materia] Matéria encontrada. + # @raise [RuntimeError] Quando não existe matéria com o código informado. + def encontrar_materia_sigaa!(codigo) + materia = Materia.find_by(codigo: codigo) + raise "Matéria com código '#{codigo}' não existe no sistema." if materia.nil? + + materia + end + + # Encontra uma turma a partir dos dados de matrícula do SIGAA. + # + # @param mat_json [Hash] Dados com código da matéria, número da turma, ano e + # semestre. + # @return [Turma] Turma encontrada. + # @raise [RuntimeError] Quando a matéria ou turma não é encontrada. + def encontrar_turma_sigaa!(mat_json) + materia = encontrar_materia_sigaa!(mat_json["materia_codigo"]) + turma = Turma.find_by( + materia_id: materia.id, + numero: mat_json["numero_turma"], + ano: mat_json["ano"], + semestre: mat_json["semestre"] + ) + if turma.nil? + raise "Turma nº #{mat_json['numero_turma']} (#{mat_json['ano']}/#{mat_json['semestre']}) da matéria '#{materia.nome}' não foi localizada no sistema." + end + + turma + end + + # Remove dados locais que não aparecem mais na carga SIGAA atual. + # + # @param contexto [Hash] Estrutura com códigos, IDs e matrículas ativos. + # @return [void] + # @side_effect Remove turmas, matérias e usuários obsoletos em transação. + def sincronizar_remocoes_sigaa(contexto) + ActiveRecord::Base.transaction do + Turma.where.not(id: contexto[:turmas_ativas_ids]).destroy_all + Materia.where.not(codigo: contexto[:codigos_materias_ativos]).destroy_all + remover_usuarios_importacao_sigaa(contexto[:matriculas_ativas_json]) + end + end + + # Remove usuários não administrativos ausentes na importação atual. + # + # @param matriculas_ativas_json [Array] Matrículas presentes no JSON + # importado. + # @return [void] + # @side_effect Destrói usuários que não estão na lista e não são + # administradores. + def remover_usuarios_importacao_sigaa(matriculas_ativas_json) + Usuario.where.not(matricula: matriculas_ativas_json).each do |usuario| + next if usuario.administrador? + + usuario.destroy! end + end + + # Redireciona ao final da importação SIGAA. + # + # @param erros_importacao [Array] Erros coletados durante a + # importação. + # @return [void] + # @side_effect Redireciona para +gerenciamento_path+ com flash de sucesso ou + # erro parcial. + def redirecionar_importacao_sigaa(erros_importacao) if erros_importacao.empty? redirect_to gerenciamento_path, flash: { success: "Dados do SIGAA importados e sincronizados com sucesso!" } else @@ -221,13 +554,421 @@ def importar_dados end end - private + # Inicializa variáveis usadas pela busca global. + # + # Não recebe argumentos diretamente; usa parâmetros da requisição. + # + # @return [void] + # @side_effect Define +@termo+, coleções vazias e tipos selecionados para a + # view de pesquisa. + def inicializar_pesquisa + @termo = params[:q].to_s.strip + @avaliacoes = Avaliacao.none + @templates = Template.none + @formularios = Formulario.none + @tipos_selecionados = tipos_selecionados + end + + # Pesquisa resultados vinculados a uma turma específica. + # + # Não recebe argumentos diretamente; usa +params[:turma_id]+. + # + # @return [void] + # @side_effect Atribui +@avaliacoes+ e, para administradores, +@formularios+. + def pesquisar_por_turma + turma_id = params[:turma_id] + @avaliacoes = avaliacoes_da_turma(turma_id) if tipo_selecionado?("avaliacoes") + @formularios = formularios_da_turma(turma_id) if administrador_com_tipo?("formularios") + end + + # Pesquisa resultados textuais pelo termo informado. + # + # Não recebe argumentos diretamente; usa +@termo+ e os filtros selecionados. + # + # @return [void] + # @side_effect Atribui coleções de avaliações, templates e formulários para a + # view conforme permissões e filtros. + def pesquisar_por_termo + padrao = padrao_pesquisa + @avaliacoes = pesquisar_avaliacoes(padrao) if tipo_selecionado?("avaliacoes") - # Garante que apenas administradores acessem ações restritas. + return unless current_user.administrador? + + @templates = pesquisar_templates(padrao) if tipo_selecionado?("templates") + @formularios = pesquisar_formularios(padrao) if tipo_selecionado?("formularios") + end + + # Monta o padrão SQL seguro para busca textual. + # + # Não recebe argumentos; usa +@termo+. + # + # @return [String] Termo sanitizado envolvido por porcentagens para uso com + # +LIKE+. + def padrao_pesquisa + "%#{ActiveRecord::Base.sanitize_sql_like(@termo.downcase)}%" + end + + # Verifica se um tipo de resultado está selecionado. + # + # @param tipo [String] Tipo de resultado, como "avaliacoes", "templates" ou + # "formularios". + # @return [Boolean] +true+ quando o tipo está presente em + # +@tipos_selecionados+. + def tipo_selecionado?(tipo) + @tipos_selecionados.include?(tipo) + end + + # Verifica se o usuário é administrador e selecionou um tipo. + # + # @param tipo [String] Tipo de resultado administrativo. + # @return [Boolean] +true+ quando o usuário atual é administrador e o tipo + # está selecionado. + def administrador_com_tipo?(tipo) + current_user.administrador? && tipo_selecionado?(tipo) + end + + # Monta todas as sugestões disponíveis para um termo. + # + # @param termo [String] Texto digitado pelo usuário. + # @return [Array] Lista de sugestões básicas, avaliações e resultados + # administrativos permitidos. + def sugestoes_do_termo(termo) + tipos = tipos_selecionados + padrao = "%#{ActiveRecord::Base.sanitize_sql_like(termo.downcase)}%" + + sugestoes_basicas(termo, padrao, tipos) + + sugestoes_avaliacoes(padrao, tipos) + + sugestoes_administrador(padrao, tipos) + end + + # Monta sugestões básicas de turmas e matérias. + # + # @param termo [String] Texto original digitado pelo usuário. + # @param padrao [String] Padrão sanitizado para consultas com +LIKE+. + # @param tipos [Array] Tipos de resultado selecionados. + # @return [Array] Sugestões de turmas e matérias. + def sugestoes_basicas(termo, padrao, tipos) + sugestoes_turmas(termo, tipos) + sugestoes_materias(padrao, tipos) + end + + # Monta sugestões de turmas compatíveis com o termo. + # + # @param termo [String] Texto original digitado pelo usuário. + # @param tipos [Array] Tipos de resultado selecionados. + # @return [Array] Até três sugestões de turmas. + def sugestoes_turmas(termo, tipos) + pesquisar_turmas(termo).limit(3).map do |turma| + { + tipo: "Turma", + titulo: turma.materia.nome, + subtitulo: nil, + materia_codigo: turma.materia.codigo, + turma_codigo: turma.codigo_exibicao, + url: turma_suggestion_url(turma, tipos) + } + end + end + + # Monta sugestões de matérias compatíveis com o padrão de busca. + # + # @param padrao [String] Padrão sanitizado para consultas com +LIKE+. + # @param tipos [Array] Tipos de resultado selecionados. + # @return [Array] Até três sugestões de matérias. + def sugestoes_materias(padrao, tipos) + pesquisar_materias(padrao).limit(3).map do |materia| + { + tipo: "Matéria", + titulo: materia.nome, + subtitulo: nil, + materia_codigo: materia.codigo, + url: materia_suggestion_url(materia, tipos) + } + end + end + + # Monta sugestões de avaliações pendentes. + # + # @param padrao [String] Padrão sanitizado para consultas com +LIKE+. + # @param tipos [Array] Tipos de resultado selecionados. + # @return [Array] Sugestões de avaliações ou lista vazia quando o tipo + # não está selecionado. + def sugestoes_avaliacoes(padrao, tipos) + return [] unless tipos.include?("avaliacoes") + + pesquisar_avaliacoes(padrao).limit(5).map do |avaliacao| + sugestao_avaliacao(avaliacao) + end + end + + # Monta o hash de sugestão para uma avaliação. + # + # @param avaliacao [Avaliacao] Avaliação pendente que será sugerida. + # @return [Hash] Dados exibidos no autocomplete da pesquisa. + def sugestao_avaliacao(avaliacao) + turma = avaliacao.formulario.turma + + { + tipo: "Avaliação", + titulo: avaliacao.formulario.template&.titulo || "Avaliação", + subtitulo: turma.nome_exibicao, + materia_codigo: turma.materia.codigo, + turma_codigo: turma.codigo_exibicao, + url: responder_avaliacao_path(avaliacao) + } + end + + # Monta sugestões visíveis apenas para administradores. + # + # @param padrao [String] Padrão sanitizado para consultas com +LIKE+. + # @param tipos [Array] Tipos de resultado selecionados. + # @return [Array] Sugestões de templates e formulários ou lista vazia + # para usuários não administradores. + def sugestoes_administrador(padrao, tipos) + return [] unless current_user.administrador? + + sugestoes_templates(padrao, tipos) + sugestoes_formularios(padrao, tipos) + end + + # Monta sugestões de templates. + # + # @param padrao [String] Padrão sanitizado para consultas com +LIKE+. + # @param tipos [Array] Tipos de resultado selecionados. + # @return [Array] Sugestões de templates ou lista vazia quando o tipo + # não está selecionado. + def sugestoes_templates(padrao, tipos) + return [] unless tipos.include?("templates") + + pesquisar_templates(padrao).limit(5).map do |template| + { + tipo: "Template", + titulo: template.titulo, + subtitulo: template.descricao.presence || "Sem descrição", + url: template_path(template) + } + end + end + + # Monta sugestões de formulários. + # + # @param padrao [String] Padrão sanitizado para consultas com +LIKE+. + # @param tipos [Array] Tipos de resultado selecionados. + # @return [Array] Sugestões de formulários ou lista vazia quando o tipo + # não está selecionado. + def sugestoes_formularios(padrao, tipos) + return [] unless tipos.include?("formularios") + + pesquisar_formularios(padrao).limit(5).map do |formulario| + { + tipo: "Formulário", + titulo: formulario.template&.titulo || "Template removido", + subtitulo: formulario.turma.nome_exibicao, + materia_codigo: formulario.turma.materia.codigo, + turma_codigo: formulario.turma.codigo_exibicao, + url: formulario_path(formulario) + } + end + end + + # Define os tipos de resultado selecionados na busca. + # + # Não recebe argumentos diretamente; usa +params[:filtro_ativo]+, + # +params[:tipos]+ e +params[:sem_templates]+. + # + # Sem o filtro aberto, assume as três categorias. Com o filtro aberto, usa + # exatamente o que está marcado. +sem_templates+ força a exclusão mesmo que + # "templates" venha marcado por algum motivo. + # + # @return [Array] Tipos selecionados para pesquisa e sugestões. + def tipos_selecionados + tipos = if params[:filtro_ativo].present? + Array(params[:tipos]) + else + %w[avaliacoes templates formularios] + end + + tipos -= [ "templates" ] if params[:sem_templates] == "1" + tipos + end + + # Garante que exista usuário autenticado. + # + # Não recebe argumentos. + # + # @return [void] + # @side_effect Redireciona para a página inicial com flash de erro quando não + # há usuário logado. + def verificar_usuario + return if current_user.present? + + redirect_to root_path, + flash: { error: "Acesso restrito. Por favor, faça login para continuar." } + end + + # Busca avaliações pendentes do usuário atual. + # + # Não recebe argumentos. + # + # @return [ActiveRecord::Relation] Relação de avaliações pendentes + # ordenadas da mais recente para a mais antiga. + def avaliacoes_do_usuario + Avaliacao + .pendentes + .joins(:participacao_turma) + .where(participacoes_turmas: { usuario_id: current_user.id }) + .includes(formulario: [ :template, { turma: :materia } ]) + .order(created_at: :desc) + end + + # Busca avaliações pendentes do usuário para uma turma. + # + # @param turma_id [Integer, String] Identificador da turma filtrada. + # @return [ActiveRecord::Relation] Relação de avaliações da turma. + def avaliacoes_da_turma(turma_id) + avaliacoes_do_usuario + .joins(:formulario) + .where(formularios: { turma_id: turma_id }) + end + + # Busca formulários de uma turma no departamento do administrador. + # + # @param turma_id [Integer, String] Identificador da turma filtrada. + # @return [ActiveRecord::Relation] Relação de formulários + # recentes da turma. + def formularios_da_turma(turma_id) + Formulario + .do_departamento(current_administrador.departamento) + .where(turma_id: turma_id) + .includes(:template, turma: :materia) + .recentes + end + + # Pesquisa avaliações pendentes por título, matéria ou código. + # + # @param padrao [String] Padrão sanitizado para consultas com +LIKE+. + # @return [ActiveRecord::Relation] Avaliações compatíveis com o + # padrão. + def pesquisar_avaliacoes(padrao) + avaliacoes_do_usuario + .joins(formulario: [ :template, { turma: :materia } ]) + .where( + "LOWER(templates.titulo) LIKE :padrao OR LOWER(materias.nome) LIKE :padrao OR LOWER(materias.codigo) LIKE :padrao", + padrao: padrao + ) + end + + # Pesquisa templates pelo título ou descrição. + # + # @param padrao [String] Padrão sanitizado para consultas com +LIKE+. + # @return [ActiveRecord::Relation