diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000..a06eaf135e Binary files /dev/null and b/.DS_Store differ diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..325bfc036d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,51 @@ +# 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* 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..1a34dc2f17 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,130 @@ +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 test + + system-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 System Tests + env: + RAILS_ENV: test + # RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }} + # REDIS_URL: redis://localhost:6379/0 + run: bin/rails db:test:prepare test:system + + - name: Keep screenshots from failed system tests + uses: actions/upload-artifact@v4 + if: failure() + with: + name: screenshots + path: ${{ github.workspace }}/tmp/screenshots + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..5fc74bfc64 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# 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 + +# Ignore vendor/bundle +/vendor/bundle + +# Ignore key files for decrypting credentials and more. +/config/*.key + +# Ignore simplecov coverage report +/coverage diff --git a/.kamal/hooks/docker-setup.sample b/.kamal/hooks/docker-setup.sample new file mode 100755 index 0000000000..2fb07d7d7a --- /dev/null +++ b/.kamal/hooks/docker-setup.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Docker set up on $KAMAL_HOSTS..." diff --git a/.kamal/hooks/post-app-boot.sample b/.kamal/hooks/post-app-boot.sample new file mode 100755 index 0000000000..70f9c4bc95 --- /dev/null +++ b/.kamal/hooks/post-app-boot.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Booted app version $KAMAL_VERSION on $KAMAL_HOSTS..." diff --git a/.kamal/hooks/post-deploy.sample b/.kamal/hooks/post-deploy.sample new file mode 100755 index 0000000000..fd364c2a77 --- /dev/null +++ b/.kamal/hooks/post-deploy.sample @@ -0,0 +1,14 @@ +#!/bin/sh + +# A sample post-deploy hook +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_ROLES (if set) +# KAMAL_DESTINATION (if set) +# KAMAL_RUNTIME + +echo "$KAMAL_PERFORMER deployed $KAMAL_VERSION to $KAMAL_DESTINATION in $KAMAL_RUNTIME seconds" diff --git a/.kamal/hooks/post-proxy-reboot.sample b/.kamal/hooks/post-proxy-reboot.sample new file mode 100755 index 0000000000..1435a677f2 --- /dev/null +++ b/.kamal/hooks/post-proxy-reboot.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Rebooted kamal-proxy on $KAMAL_HOSTS" diff --git a/.kamal/hooks/pre-app-boot.sample b/.kamal/hooks/pre-app-boot.sample new file mode 100755 index 0000000000..45f7355045 --- /dev/null +++ b/.kamal/hooks/pre-app-boot.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Booting app version $KAMAL_VERSION on $KAMAL_HOSTS..." diff --git a/.kamal/hooks/pre-build.sample b/.kamal/hooks/pre-build.sample new file mode 100755 index 0000000000..c5a55678b2 --- /dev/null +++ b/.kamal/hooks/pre-build.sample @@ -0,0 +1,51 @@ +#!/bin/sh + +# A sample pre-build hook +# +# Checks: +# 1. We have a clean checkout +# 2. A remote is configured +# 3. The branch has been pushed to the remote +# 4. The version we are deploying matches the remote +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_ROLES (if set) +# KAMAL_DESTINATION (if set) + +if [ -n "$(git status --porcelain)" ]; then + echo "Git checkout is not clean, aborting..." >&2 + git status --porcelain >&2 + exit 1 +fi + +first_remote=$(git remote) + +if [ -z "$first_remote" ]; then + echo "No git remote set, aborting..." >&2 + exit 1 +fi + +current_branch=$(git branch --show-current) + +if [ -z "$current_branch" ]; then + echo "Not on a git branch, aborting..." >&2 + exit 1 +fi + +remote_head=$(git ls-remote $first_remote --tags $current_branch | cut -f1) + +if [ -z "$remote_head" ]; then + echo "Branch not pushed to remote, aborting..." >&2 + exit 1 +fi + +if [ "$KAMAL_VERSION" != "$remote_head" ]; then + echo "Version ($KAMAL_VERSION) does not match remote HEAD ($remote_head), aborting..." >&2 + exit 1 +fi + +exit 0 diff --git a/.kamal/hooks/pre-connect.sample b/.kamal/hooks/pre-connect.sample new file mode 100755 index 0000000000..77744bdca8 --- /dev/null +++ b/.kamal/hooks/pre-connect.sample @@ -0,0 +1,47 @@ +#!/usr/bin/env ruby + +# A sample pre-connect check +# +# Warms DNS before connecting to hosts in parallel +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_ROLES (if set) +# KAMAL_DESTINATION (if set) +# KAMAL_RUNTIME + +hosts = ENV["KAMAL_HOSTS"].split(",") +results = nil +max = 3 + +elapsed = Benchmark.realtime do + results = hosts.map do |host| + Thread.new do + tries = 1 + + begin + Socket.getaddrinfo(host, 0, Socket::AF_UNSPEC, Socket::SOCK_STREAM, nil, Socket::AI_CANONNAME) + rescue SocketError + if tries < max + puts "Retrying DNS warmup: #{host}" + tries += 1 + sleep rand + retry + else + puts "DNS warmup failed: #{host}" + host + end + end + + tries + end + end.map(&:value) +end + +retries = results.sum - hosts.size +nopes = results.count { |r| r == max } + +puts "Prewarmed %d DNS lookups in %.2f sec: %d retries, %d failures" % [ hosts.size, elapsed, retries, nopes ] diff --git a/.kamal/hooks/pre-deploy.sample b/.kamal/hooks/pre-deploy.sample new file mode 100755 index 0000000000..05b3055b72 --- /dev/null +++ b/.kamal/hooks/pre-deploy.sample @@ -0,0 +1,122 @@ +#!/usr/bin/env ruby + +# A sample pre-deploy hook +# +# Checks the Github status of the build, waiting for a pending build to complete for up to 720 seconds. +# +# Fails unless the combined status is "success" +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_COMMAND +# KAMAL_SUBCOMMAND +# KAMAL_ROLES (if set) +# KAMAL_DESTINATION (if set) + +# Only check the build status for production deployments +if ENV["KAMAL_COMMAND"] == "rollback" || ENV["KAMAL_DESTINATION"] != "production" + exit 0 +end + +require "bundler/inline" + +# true = install gems so this is fast on repeat invocations +gemfile(true, quiet: true) do + source "https://rubygems.org" + + gem "octokit" + gem "faraday-retry" +end + +MAX_ATTEMPTS = 72 +ATTEMPTS_GAP = 10 + +def exit_with_error(message) + $stderr.puts message + exit 1 +end + +class GithubStatusChecks + attr_reader :remote_url, :git_sha, :github_client, :combined_status + + def initialize + @remote_url = github_repo_from_remote_url + @git_sha = `git rev-parse HEAD`.strip + @github_client = Octokit::Client.new(access_token: ENV["GITHUB_TOKEN"]) + refresh! + end + + def refresh! + @combined_status = github_client.combined_status(remote_url, git_sha) + end + + def state + combined_status[:state] + end + + def first_status_url + first_status = combined_status[:statuses].find { |status| status[:state] == state } + first_status && first_status[:target_url] + end + + def complete_count + combined_status[:statuses].count { |status| status[:state] != "pending"} + end + + def total_count + combined_status[:statuses].count + end + + def current_status + if total_count > 0 + "Completed #{complete_count}/#{total_count} checks, see #{first_status_url} ..." + else + "Build not started..." + end + end + + private + def github_repo_from_remote_url + url = `git config --get remote.origin.url`.strip.delete_suffix(".git") + if url.start_with?("https://github.com/") + url.delete_prefix("https://github.com/") + elsif url.start_with?("git@github.com:") + url.delete_prefix("git@github.com:") + else + url + end + end +end + + +$stdout.sync = true + +begin + puts "Checking build status..." + + attempts = 0 + checks = GithubStatusChecks.new + + loop do + case checks.state + when "success" + puts "Checks passed, see #{checks.first_status_url}" + exit 0 + when "failure" + exit_with_error "Checks failed, see #{checks.first_status_url}" + when "pending" + attempts += 1 + end + + exit_with_error "Checks are still pending, gave up after #{MAX_ATTEMPTS * ATTEMPTS_GAP} seconds" if attempts == MAX_ATTEMPTS + + puts checks.current_status + sleep(ATTEMPTS_GAP) + checks.refresh! + end +rescue Octokit::NotFound + exit_with_error "Build status could not be found" +end diff --git a/.kamal/hooks/pre-proxy-reboot.sample b/.kamal/hooks/pre-proxy-reboot.sample new file mode 100755 index 0000000000..061f8059e6 --- /dev/null +++ b/.kamal/hooks/pre-proxy-reboot.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Rebooting kamal-proxy on $KAMAL_HOSTS..." diff --git a/.kamal/secrets b/.kamal/secrets new file mode 100644 index 0000000000..b3089d6f5a --- /dev/null +++ b/.kamal/secrets @@ -0,0 +1,20 @@ +# Secrets defined here are available for reference under registry/password, env/secret, builder/secrets, +# and accessories/*/env/secret in config/deploy.yml. All secrets should be pulled from either +# password manager, ENV, or a file. DO NOT ENTER RAW CREDENTIALS HERE! This file needs to be safe for git. + +# Example of extracting secrets from 1password (or another compatible pw manager) +# SECRETS=$(kamal secrets fetch --adapter 1password --account your-account --from Vault/Item KAMAL_REGISTRY_PASSWORD RAILS_MASTER_KEY) +# KAMAL_REGISTRY_PASSWORD=$(kamal secrets extract KAMAL_REGISTRY_PASSWORD ${SECRETS}) +# RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY ${SECRETS}) + +# Example of extracting secrets from Rails credentials +# KAMAL_REGISTRY_PASSWORD=$(rails credentials:fetch kamal.registry_password) + +# Use a GITHUB_TOKEN if private repositories are needed for the image +# GITHUB_TOKEN=$(gh config get -h github.com oauth_token) + +# Grab the registry password from ENV +# KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD + +# Improve security by using a password manager. Never check config/master.key into git! +RAILS_MASTER_KEY=$(cat config/master.key) 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..265e7fc660 --- /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 trabalho_final_eng_soft . +# docker run -d -p 80:80 -e RAILS_MASTER_KEY= --name trabalho_final_eng_soft trabalho_final_eng_soft + +# 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..0a79586935 --- /dev/null +++ b/Gemfile @@ -0,0 +1,81 @@ +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" + +gem "dotenv-rails", groups: [:development, :test] + +# csv deixou de ser uma gem padrão (default gem bundled) a partir do Ruby 3.4, +# por isso precisa ser declarada explicitamente para uso em Admin::ResultsController +gem "csv" + +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 + + # Gems para a Entrega 3 (Sprint 3) + gem "saikuro", require: false + gem "rubycritic", require: false + gem "simplecov", require: false + gem "rdoc" +end + +group :development do + # Use console on exceptions pages [https://github.com/rails/web-console] + gem "web-console" +end + +group :test do + gem 'rspec-rails' + gem 'cucumber-rails', require: false + gem 'database_cleaner-active_record' + gem 'factory_bot_rails' + gem "capybara" + gem "selenium-webdriver" +end \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000000..6220560733 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,600 @@ +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 (3.1.22) + bcrypt_pbkdf (1.1.2) + bcrypt_pbkdf (1.1.2-arm64-darwin) + bcrypt_pbkdf (1.1.2-x64-mingw-ucrt) + bigdecimal (4.1.2) + bindex (0.8.1) + bootsnap (1.24.5) + 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) + csv (3.3.4) + 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) + dotenv-rails (3.2.0) + dotenv (= 3.2.0) + railties (>= 6.1) + 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 + factory_bot (6.6.0) + activesupport (>= 6.1.0) + factory_bot_rails (6.5.1) + factory_bot (~> 6.5) + railties (>= 6.1.0) + 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-arm64-darwin) + 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) + 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.15.0) + 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.2.1) + 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-arm64-darwin) + 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) + racc (~> 1.4) + ostruct (0.6.3) + parallel (1.28.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.2) + 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.2) + 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) + saikuro (1.2.1) + securerandom (0.4.1) + selenium-webdriver (4.44.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-arm64-darwin) + 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) + 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) + 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.21) + thruster (0.1.21-aarch64-linux) + thruster (0.1.21-arm64-darwin) + thruster (0.1.21-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) + tzinfo-data (1.2026.2) + tzinfo (>= 1.0.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) + win32ole (1.9.3) + xpath (3.2.0) + nokogiri (~> 1.8) + zeitwerk (2.8.1) + +PLATFORMS + aarch64-linux + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + arm64-darwin-24 + x64-mingw-ucrt + x86_64-linux + x86_64-linux-gnu + x86_64-linux-musl + +DEPENDENCIES + bcrypt (~> 3.1.7) + bootsnap + brakeman + bundler-audit + capybara + csv + cucumber-rails + database_cleaner-active_record + debug + dotenv-rails + factory_bot_rails + image_processing (~> 1.2) + importmap-rails + jbuilder + kamal + propshaft + puma (>= 5.0) + rails (~> 8.1.3) + rdoc + rspec-rails + rubocop-rails-omakase + rubycritic + saikuro + selenium-webdriver + simplecov + solid_cable + solid_cache + solid_queue + sqlite3 (>= 2.1) + stimulus-rails + thruster + turbo-rails + tzinfo-data + web-console + +BUNDLED WITH + 2.6.9 diff --git a/README.md b/README.md index 9d7fe1bf53..7db80e4ca1 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,24 @@ -# CAMAAR -Sistema para avaliação de atividades acadêmicas remotas do CIC +# README + +This README would normally document whatever steps are necessary to get the +application up and running. + +Things you may want to cover: + +* Ruby version + +* System dependencies + +* Configuration + +* Database creation + +* Database initialization + +* How to run the test suite + +* Services (job queues, cache servers, search engines, etc.) + +* Deployment instructions + +* ... 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/.DS_Store b/app/.DS_Store new file mode 100644 index 0000000000..07fdfbff2b Binary files /dev/null and b/app/.DS_Store differ 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..ce3b4bf11b --- /dev/null +++ b/app/assets/stylesheets/application.css @@ -0,0 +1,46 @@ +/* + * 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. + */ + +.modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 9999; +} + +.modal { + background: white; + padding: 30px 40px; + border-radius: 12px; + text-align: center; + min-width: 300px; + max-width: 400px; + box-shadow: 0 5px 20px rgba(0,0,0,0.2); +} + +.modal p { + margin-bottom: 0; + font-size: 1rem; +} + +.modal p.notice { + color: #155724; +} + +.modal p.flash-alert { + color: #721c24; +} diff --git a/app/assets/stylesheets/home.css b/app/assets/stylesheets/home.css new file mode 100644 index 0000000000..68880ad29b --- /dev/null +++ b/app/assets/stylesheets/home.css @@ -0,0 +1,164 @@ +body { + margin: 0; + font-family: 'Poppins', sans-serif; +} + +.header { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 50; + height: 60px; + background: white; + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 20px; + box-shadow: 0px 4px 10px rgba(0,0,0,0.05); +} + +.header-left { + display: flex; + align-items: center; + gap: 15px; +} + +.header-right { + display: flex; + align-items: center; + gap: 15px; +} + +.user-avatar-wrapper { + position: relative; +} + +.user-avatar { + cursor: pointer; +} + +.user-menu { + display: none; + position: absolute; + right: 0; + top: 120%; + background: white; + border: 1px solid #ddd; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + min-width: 160px; + z-index: 100; + padding: 8px 0; + flex-direction: column; + align-items: center; + gap: 8px; +} + +.user-menu.open { + display: flex; +} + +.user-menu-name { + font-size: 0.9rem; + color: #555; + padding: 4px 16px; +} + +.logout-btn { + background: #e53e3e; + color: white; + border: none; + border-radius: 6px; + padding: 8px 24px; + cursor: pointer; + font-size: 0.9rem; + width: 80%; +} + +.logout-btn:hover { + background: #c53030; +} + +.search { + border-radius: 20px; + border: 1px solid #ccc; + padding: 8px 15px; +} + +.sidebar { + position: fixed; + top: 60px; + left: 0; + width: 257px; + height: calc(100% - 60px); + background: white; +} + +.menu-item { + display: block; + text-decoration: none; + color: inherit; + padding: 12px; + text-align: center; + border-bottom: 1px solid #ddd; +} + +.menu-item:hover { + background: #f5f5f5; +} + +.menu-item.active { + background: #6C2365; + color: white; +} + +.main { + margin-left: 257px; + margin-top: 60px; + height: calc(100vh - 60px); + background: #DBDBDB; + + display: flex; + justify-content: center; + align-items: center; +} + +.card { + background: white; + width: 460px; + height: 650px; + border-radius: 8px; + + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 15px; +} + +.btn { + width: 300px; + height: 43px; + border: none; + border-radius: 6px; + font-size: 16px; + cursor: pointer; +} + +.primary { + background: #22C55E; + color: white; +} + +.secondary { + background: #86EFAC; + color: white; +} +.alert { + padding: 12px; + margin-bottom: 16px; + border-radius: 4px; +} +.alert-success { background: #d4edda; color: #155724; } +.alert-danger { background: #f8d7da; color: #721c24; } diff --git a/app/assets/stylesheets/password_sets.css b/app/assets/stylesheets/password_sets.css new file mode 100644 index 0000000000..b424252f2e --- /dev/null +++ b/app/assets/stylesheets/password_sets.css @@ -0,0 +1,144 @@ +@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@400;600;700&display=swap'); + +body { + font-family: 'Poppins', sans-serif; + background-color: #e0e0e0; + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + margin: 0; +} + +.flash-text { + position: absolute; + left: -9999px; +} + +.modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 999; +} + +.modal { + background: white; + padding: 30px 40px; + border-radius: 12px; + text-align: center; + min-width: 300px; + max-width: 400px; + box-shadow: 0 5px 20px rgba(0,0,0,0.2); +} + +.modal p { + margin-bottom: 20px; + font-size: 1rem; +} + +.modal button { + background-color: #6b2d7e; + color: white; + border: none; + padding: 10px 20px; + border-radius: 8px; + cursor: pointer; +} + +.password-set-wrapper { + display: flex; + width: 800px; + border-radius: 12px; + overflow: hidden; + background: white; + box-shadow: 0 2px 12px rgba(0,0,0,0.1); +} + +.password-set-left { + flex: 1; + padding: 60px 40px; + display: flex; + flex-direction: column; + gap: 28px; +} + +.password-set-left h1 { + font-size: 1.8rem; + font-weight: 600; + text-align: center; + letter-spacing: 3px; +} + +.field { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 20px; +} + +.field label { + font-size: 1rem; + font-weight: 500; +} + +.field input { + border: 1px solid #ccc; + border-radius: 8px; + padding: 16px 18px; + font-size: 1rem; + font-family: 'Poppins', sans-serif; + outline: none; +} + +.field input:focus { + border-color: #999; +} + +input[type="submit"] { + background-color: #5cb85c; + color: white; + border: none; + border-radius: 8px; + padding: 18px; + font-size: 1rem; + font-family: 'Poppins', sans-serif; + font-weight: 600; + cursor: pointer; + display: block; + width: 60%; + margin: 20px auto 0; +} + +input[type="submit"]:hover { + background-color: #4cae4c; +} + +.error { + color: red; + font-size: 0.9rem; + text-align: center; +} + +.password-set-right { + flex: 1; + background-color: #6b2d7e; + display: flex; + justify-content: center; + align-items: center; + padding: 0px; +} + +.password-set-right h2 { + color: white; + font-size: 2.5rem; + font-weight: 600; + text-align: center; + line-height: 1.3; +} \ No newline at end of file diff --git a/app/assets/stylesheets/sessions.css b/app/assets/stylesheets/sessions.css new file mode 100644 index 0000000000..09a70021c9 --- /dev/null +++ b/app/assets/stylesheets/sessions.css @@ -0,0 +1,144 @@ +@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@400;600;700&display=swap'); + +body { + font-family: 'Poppins', sans-serif; + background-color: #e0e0e0; + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + margin: 0; +} + +.flash-text { + position: absolute; + left: -9999px; +} + +.modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); /* fundo escuro */ + display: flex; + justify-content: center; + align-items: center; + z-index: 999; +} + +.modal { + background: white; + padding: 30px 40px; + border-radius: 12px; + text-align: center; + min-width: 300px; + max-width: 400px; + box-shadow: 0 5px 20px rgba(0,0,0,0.2); +} + +.modal p { + margin-bottom: 20px; + font-size: 1rem; +} + +.modal button { + background-color: #6b2d7e; + color: white; + border: none; + padding: 10px 20px; + border-radius: 8px; + cursor: pointer; +} + +.login-wrapper { + display: flex; + width: 800px; + border-radius: 12px; + overflow: hidden; + background: white; + box-shadow: 0 2px 12px rgba(0,0,0,0.1); +} + +.login-left { + flex: 1; + padding: 60px 40px; + display: flex; + flex-direction: column; + gap: 28px; +} + +.login-left h1 { + font-size: 1.8rem; + font-weight: 600; + text-align: center; + letter-spacing: 3px; +} + +.field { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 20px; +} + +.field label { + font-size: 1rem; + font-weight: 500; +} + +.field input { + border: 1px solid #ccc; + border-radius: 8px; + padding: 16px 18px; + font-size: 1rem; + font-family: 'Poppins', sans-serif; + outline: none; +} + +.field input:focus { + border-color: #999; +} + +input[type="submit"] { + background-color: #5cb85c; + color: white; + border: none; + border-radius: 8px; + padding: 18px; + font-size: 1rem; + font-family: 'Poppins', sans-serif; + font-weight: 600; + cursor: pointer; + display: block; + width: 60%; + margin: 20px auto 0; +} + +input[type="submit"]:hover { + background-color: #4cae4c; +} + +.error { + color: red; + font-size: 0.9rem; + text-align: center; +} + +.login-right { + flex: 1; + background-color: #6b2d7e; + display: flex; + justify-content: center; + align-items: center; + padding: 0px; +} + +.login-right h2 { + color: white; + font-size: 2.5rem; + font-weight: 600; + text-align: center; + line-height: 1.3; +} \ No newline at end of file diff --git a/app/controllers/admin/forms_controller.rb b/app/controllers/admin/forms_controller.rb new file mode 100644 index 0000000000..fa41022bec --- /dev/null +++ b/app/controllers/admin/forms_controller.rb @@ -0,0 +1,123 @@ +# Namespace para os controllers do painel administrativo do sistema +module Admin +end + +# Controller para criação de formulários de avaliação pelo administrador +class Admin::FormsController < ApplicationController + before_action :require_admin + layout "dashboard" + + # Lista os formulários ativos (ainda dentro do período de vigência) + # + # Argumentos: Nenhum + # Retorno: Nenhum (renderiza view) + # Efeitos colaterais: Nenhum + def index + @forms = Form.includes(:template, :turmas).where("end_date > ?", Time.current).order(created_at: :desc) + end + + # Cria um formulário baseado em template e o envia para as turmas selecionadas + # + # Argumentos: params[:template_id], params[:turma_ids] (array) + # Retorno: Nenhum (redireciona) + # Efeitos colaterais: Cria Form e associações com turmas no banco + def create + return redirect_missing_destinatario if params[:destinatario].blank? + return redirect_missing_template if params[:template_id].blank? + + turma_ids = Array(params[:turma_ids]).reject(&:blank?) + return redirect_missing_turmas if turma_ids.empty? + + deliver_form(params[:template_id], turma_ids) + end + + private + + # Redireciona com erro quando tipo de destinatário não foi selecionado + # + # Argumentos: Nenhum + # Retorno: ActionDispatch::Response (redirect) + # Efeitos colaterais: Nenhum + def redirect_missing_destinatario + redirect_to admin_management_path, alert: "Selecione o tipo de destinatário (Dicentes ou Docentes)" + end + + # Redireciona com erro quando template não foi selecionado + # + # Argumentos: Nenhum + # Retorno: ActionDispatch::Response (redirect) + # Efeitos colaterais: Nenhum + def redirect_missing_template + redirect_to admin_management_path, alert: "Selecione um template para o formulário" + end + + # Redireciona com erro quando nenhuma turma foi selecionada + # + # Argumentos: Nenhum + # Retorno: ActionDispatch::Response (redirect) + # Efeitos colaterais: Nenhum + def redirect_missing_turmas + redirect_to admin_management_path, alert: "Selecione ao menos uma turma" + end + + # Cria o formulário e associa às turmas selecionadas + # + # Argumentos: template_id (String), turma_ids (Array) + # Retorno: ActionDispatch::Response (redirect) + # Efeitos colaterais: Cria Form e registros em forms_turmas + def deliver_form(template_id, turma_ids) + template = Template.find_by(id: template_id) + form = Form.new( + template: template, + start_date: parse_date(params[:start_date])&.beginning_of_day, + end_date: parse_date(params[:end_date])&.end_of_day, + destinatario: resolve_destinatario + ) + + if form.save + form.turmas << Turma.where(id: turma_ids) + redirect_to admin_management_path, notice: success_message(form.destinatario, turma_ids.size) + else + redirect_to admin_management_path, alert: form.errors.full_messages.to_sentence + end + end + + # Monta a mensagem de sucesso de acordo com o destinatário e a + # quantidade de turmas selecionadas + # + # Argumentos: destinatario (String), quantidade_turmas (Integer) + # Retorno: String + def success_message(destinatario, quantidade_turmas) + return "Formulário criado com sucesso para #{quantidade_turmas} turmas" if quantidade_turmas > 1 + + destino = destinatario == "discente" ? "dicentes" : "docentes" + "Formulário criado com sucesso para #{destino}" + end + + # Resolve o público-alvo do formulário a partir do parâmetro enviado. + # + # Argumentos: Nenhum (lê params[:destinatario]) + # Retorno: String + # Efeitos colaterais: Nenhum + def resolve_destinatario + valor = params[:destinatario].to_s.strip + %w[discente docente].include?(valor) ? valor : "discente" + end + + # Converte uma string de data (dd/mm/yyyy ou yyyy-mm-dd) em Date + # + # Argumentos: valor (String) + # Retorno: Date ou nil + # Efeitos colaterais: Nenhum + def parse_date(valor) + return nil if valor.blank? + + Date.strptime(valor, "%d/%m/%Y") + rescue ArgumentError + begin + Date.parse(valor) + rescue ArgumentError, TypeError + nil + end + end +end diff --git a/app/controllers/admin/imports_controller.rb b/app/controllers/admin/imports_controller.rb new file mode 100644 index 0000000000..3f02023c34 --- /dev/null +++ b/app/controllers/admin/imports_controller.rb @@ -0,0 +1,72 @@ +# Controller responsável por gerenciar a importação de turmas e discentes do SIGAA. +class Admin::ImportsController < ApplicationController + layout 'dashboard' + before_action :require_admin + + # Exibe a página de importação de dados. + # + # Argumentos: Nenhum + # Retorno: Nenhum + # Efeitos Colaterais: Renderiza a view associada. + def index + end + + # Recebe e processa o arquivo JSON para importar turmas ou membros do SIGAA. + # + # Argumentos: Espera receber params[:file] e params[:import_type]. + # Retorno: Nenhum + # Efeitos Colaterais: Cria usuários/turmas no banco de dados e redireciona a página. + def create + return se_arquivo_invalido if arquivo_invalido? + + result = processar_importacao(params[:file].read, params[:import_type]) + + if result[:success] + flash[:notice] = result[:message] + else + flash[:alert] = result[:message] + end + + redirect_to admin_imports_path + end + + private + + # Verifica se o arquivo enviado não é um JSON válido ou se não existe. + # + # Argumentos: Nenhum + # Retorno: Booleano + # Efeitos Colaterais: Nenhum + def arquivo_invalido? + params[:file].nil? || (params[:file].content_type != 'application/json' && !params[:file].original_filename.end_with?('.json')) + end + + # Trata a resposta quando o arquivo é inválido. + # + # Argumentos: Nenhum + # Retorno: Nenhum + # Efeitos Colaterais: Configura flash alert e redireciona. + def se_arquivo_invalido + if params[:file].nil? + flash[:alert] = "Por favor, selecione um arquivo para importar" + else + flash[:alert] = "Formato de arquivo inválido. Por favor, envie um arquivo .json" + end + redirect_to admin_imports_path + end + + # Direciona para o importador correto com base no tipo. + # + # Argumentos: + # - file_content: string com o conteúdo do json + # - import_type: tipo de importação (classes ou members) + # Retorno: Hash com o resultado da importação. + # Efeitos Colaterais: Realiza operações de banco de dados via SigaaImporter. + def processar_importacao(file_content, import_type) + if import_type == 'classes' + SigaaImporter.import_classes(file_content) + else + SigaaImporter.import_members(file_content) + end + end +end diff --git a/app/controllers/admin/management_controller.rb b/app/controllers/admin/management_controller.rb new file mode 100644 index 0000000000..a91c6c5551 --- /dev/null +++ b/app/controllers/admin/management_controller.rb @@ -0,0 +1,15 @@ +# Controller da página principal de gerenciamento do administrador +class Admin::ManagementController < ApplicationController + before_action :require_admin + layout "dashboard" + + # Exibe o painel de gerenciamento com botões de ação + # + # Argumentos: Nenhum + # Retorno: Nenhum (renderiza view) + # Efeitos colaterais: Nenhum + def index + @turmas = Turma.includes(:disciplina).all + @templates = Template.order(:nome) + end +end diff --git a/app/controllers/admin/results_controller.rb b/app/controllers/admin/results_controller.rb new file mode 100644 index 0000000000..b7f7799ba1 --- /dev/null +++ b/app/controllers/admin/results_controller.rb @@ -0,0 +1,148 @@ +require "csv" + +# Controller para visualização e download de resultados de formulários +class Admin::ResultsController < ApplicationController + before_action :require_admin + layout "dashboard" + before_action :set_turma, only: [:show, :csv] + + # Lista turmas que possuem formulários com respostas + # + # Argumentos: Nenhum + # Retorno: Nenhum (renderiza view) + # Efeitos colaterais: Nenhum + def index + @turmas = Turma.includes(:disciplina, :docentes).joins(:forms).distinct + end + + # Exibe detalhes de resultados de uma turma específica + # + # Argumentos: params[:id] — id da Turma + # Retorno: Nenhum (renderiza view) + # Efeitos colaterais: Nenhum + def show + @responses = FormResponse.where(turma: @turma).includes(:user, form: { template: :questions }) + @form = @turma.forms.order(created_at: :desc).first + @template = @form&.template + @question_summaries = build_question_summaries(@responses, @template) + end + + # Gera e faz download de CSV com as respostas da turma + # + # Argumentos: params[:id] — id da Turma + # Retorno: Arquivo CSV + # Efeitos colaterais: Envia arquivo ao cliente + def csv + responses = FormResponse.where(turma: @turma).includes(:user, form: { template: :questions }) + + if responses.empty? + return redirect_to admin_result_path(@turma), alert: "Esta turma ainda não possui respostas registradas" + end + + send_data build_csv(responses), + type: "text/csv; charset=utf-8", + filename: "resultados_turma_#{@turma.id}.csv", + disposition: "attachment" + end + + private + + def set_turma + @turma = Turma.includes(:disciplina, :docentes).find(params[:id]) + end + + # Gera CSV com colunas Turma, Discente, Questão, Resposta + # + # Argumentos: responses (ActiveRecord::Relation) + # Retorno: String com conteúdo CSV + # Efeitos colaterais: Nenhum + def build_csv(responses) + CSV.generate(headers: true) do |csv| + csv << %w[Turma Discente Questão Resposta] + responses.each { |fr| append_response_rows(csv, fr) } + end + end + + # Adiciona linhas de um FormResponse ao CSV + # + # Argumentos: csv (CSV), fr (FormResponse) + # Retorno: Nenhum + # Efeitos colaterais: Modifica csv in-place + def append_response_rows(csv, fr) + answers = Array(fr.answers) + return csv << empty_row(fr) if answers.empty? + + questions = questions_for(fr) + answers.each { |ans| csv << answer_row(fr, questions, ans) } + end + + # Retorna linha vazia para respostas sem dados + # + # Argumentos: fr (FormResponse) + # Retorno: Array com campos da linha + # Efeitos colaterais: Nenhum + def empty_row(fr) + [@turma.classCode, fr.user&.nome.to_s, "", ""] + end + + # Retorna linha preenchida para uma resposta + # + # Argumentos: fr (FormResponse), questions (Array), ans (Hash) + # Retorno: Array com campos da linha + # Efeitos colaterais: Nenhum + def answer_row(fr, questions, ans) + question = questions.find { |q| q.id == ans["question_id"] } + [@turma.classCode, fr.user&.nome.to_s, question&.text.to_s, ans["value"].to_s] + end + + # Retorna as questões associadas ao FormResponse + # + # Argumentos: fr (FormResponse) + # Retorno: Array de Question + # Efeitos colaterais: Nenhum + def questions_for(fr) + fr.form&.template&.questions || [] + end + + # Monta um resumo por questão: contagem de opções para Radio, + # lista de respostas para Texto + # + # Argumentos: responses (ActiveRecord::Relation), template (Template ou nil) + # Retorno: Array de Hash + # Efeitos colaterais: Nenhum + def build_question_summaries(responses, template) + return [] unless template + + template.questions.map do |question| + valores = collect_values(responses, question) + { + question: question, + option_counts: question.kind == "radio" ? tally_options(question, valores) : nil, + respostas: question.kind == "radio" ? nil : valores + } + end + end + + # Coleta os valores de resposta de uma questão específica + # + # Argumentos: responses (ActiveRecord::Relation), question (Question) + # Retorno: Array de Hash com value/user/created_at + # Efeitos colaterais: Nenhum + def collect_values(responses, question) + responses.flat_map do |fr| + Array(fr.answers) + .select { |a| a["question_id"] == question.id } + .map { |a| { value: a["value"], user: fr.user&.nome, created_at: fr.created_at } } + end + end + + # Conta as respostas recebidas para cada opção de uma questão Radio + # + # Argumentos: question (Question), valores (Array de Hash) + # Retorno: Hash opção => contagem + # Efeitos colaterais: Nenhum + def tally_options(question, valores) + base = Array(question.options).index_with(0) + base.merge(valores.group_by { |v| v[:value] }.transform_values(&:size)) + end +end diff --git a/app/controllers/admin/templates_controller.rb b/app/controllers/admin/templates_controller.rb new file mode 100644 index 0000000000..ccb2e2c03b --- /dev/null +++ b/app/controllers/admin/templates_controller.rb @@ -0,0 +1,147 @@ +# Controller para CRUD de templates de formulário no painel admin +class Admin::TemplatesController < ApplicationController + before_action :require_admin + layout "dashboard" + before_action :set_template, only: [:update, :destroy] + + # Lista todos os templates + # + # Argumentos: Nenhum + # Retorno: Nenhum (renderiza view) + # Efeitos colaterais: Nenhum + def index + @templates = Template.order(:nome) + end + + # Cria um novo template com suas questões + # + # Argumentos: params[:template] — nome; params[:questions] — array de questões + # Retorno: Nenhum (redireciona) + # Efeitos colaterais: Cria registros de Template e Question no banco + def create + name = extract_template_name + return redirect_to(admin_templates_path, alert: "O nome do template é obrigatório") if name.blank? + + @template = Template.new(nome: name, semester: template_semester) + persist_template("Template criado com sucesso") + end + + # Atualiza um template existente + # + # Argumentos: params[:id], params[:template] + # Retorno: Nenhum (redireciona) + # Efeitos colaterais: Atualiza registro no banco + def update + ActiveRecord::Base.transaction do + if @template.update(nome: resolve_nome, semester: template_semester) + build_questions(@template, replace: true) + flash[:notice] = "Template atualizado com sucesso" + else + flash[:alert] = @template.errors.full_messages.join(", ") + end + end + rescue ActiveRecord::RecordInvalid => e + flash[:alert] = e.record.errors.full_messages.join(", ") + ensure + redirect_to admin_templates_path + end + + # Exclui um template + # + # Argumentos: params[:id] + # Retorno: Nenhum (redireciona) + # Efeitos colaterais: Remove o template do banco + def destroy + @template.destroy + redirect_to admin_templates_path, notice: "Template removido com sucesso" + end + + private + + def set_template + @template = Template.find(params[:id]) + rescue ActiveRecord::RecordNotFound + redirect_to admin_templates_path, alert: "Template não encontrado" + end + + # Extrai o nome do template dos params (suporta :name e :nome) + # + # Argumentos: Nenhum + # Retorno: String ou nil + # Efeitos colaterais: Nenhum + def extract_template_name + params.dig(:template, :name).presence || params.dig(:template, :nome) + end + + # Extrai o semestre do template dos params + # + # Argumentos: Nenhum + # Retorno: String ou nil + # Efeitos colaterais: Nenhum + def template_semester + params.dig(:template, :semester) + end + + # Resolve o nome a usar no update (prioridade: name > nome > nome atual) + # + # Argumentos: Nenhum + # Retorno: String + # Efeitos colaterais: Nenhum + def resolve_nome + extract_template_name.presence || @template.nome + end + + # Salva o template e redireciona com mensagem de sucesso ou erro + # + # Argumentos: success_msg (String) + # Retorno: ActionDispatch::Response (redirect) + # Efeitos colaterais: Persiste Template e Questions no banco + def persist_template(success_msg) + ActiveRecord::Base.transaction do + if @template.save + build_questions(@template) + flash[:notice] = success_msg + else + flash[:alert] = @template.errors.full_messages.join(", ") + end + end + rescue ActiveRecord::RecordInvalid => e + flash[:alert] = e.record.errors.full_messages.join(", ") + ensure + redirect_to admin_templates_path + end + + # Constrói questões a partir dos params + # + # Argumentos: template (Template), replace (Boolean) + # Retorno: Nenhum + # Efeitos colaterais: Cria/deleta registros de Question + def build_questions(template, replace: false) + return unless params[:questions].present? + template.questions.destroy_all if replace + params[:questions].each_value { |q| persist_question(template, q) } + end + + # Persiste uma questão individual se o texto não for vazio + # + # Argumentos: template (Template), q (Hash com :text e :kind) + # Retorno: Nenhum + # Efeitos colaterais: Cria registro de Question no banco + def persist_question(template, q) + return if q[:text].blank? + template.questions.create!( + kind: q[:kind].presence || "text", + text: q[:text], + options: extract_options(q) + ) + end + + # Filtra opções em branco enviadas para uma questão + # + # Argumentos: q (Hash com possível chave :options) + # Retorno: Array de String + # Efeitos colaterais: Nenhum + def extract_options(q) + Array(q[:options]).map(&:to_s).map(&:strip).reject(&:blank?) + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb new file mode 100644 index 0000000000..d9890c4848 --- /dev/null +++ b/app/controllers/application_controller.rb @@ -0,0 +1,32 @@ +class ApplicationController < ActionController::Base + allow_browser versions: :modern + stale_when_importmap_changes + + before_action :require_login + + private + + def current_user + @current_user ||= User.find_by(id: session[:user_id]) + end + helper_method :current_user + + def require_login + if current_user.nil? + redirect_to login_path, alert: "Faça login para continuar." + elsif current_user.password_digest.nil? && current_user.invitation_token.present? + redirect_to password_set_path(current_user.invitation_token), notice: "Você precisa definir uma senha antes de continuar" + end + end + + def admin? + current_user&.role == "admin" + end + helper_method :admin? + + def require_admin + unless admin? + redirect_to avaliacoes_path, alert: "Acesso não autorizado" + end + end +end diff --git a/app/controllers/avaliacoes_controller.rb b/app/controllers/avaliacoes_controller.rb new file mode 100644 index 0000000000..070ad9c147 --- /dev/null +++ b/app/controllers/avaliacoes_controller.rb @@ -0,0 +1,193 @@ +# Controller para o painel de avaliações do discente +class AvaliacoesController < ApplicationController + layout "dashboard" + before_action :set_turma_and_form, only: [:show, :detalhes, :responder] + + # Lista as turmas com formulários disponíveis para o usuário logado + # + # Argumentos: Nenhum + # Retorno: Nenhum (renderiza view) + # Efeitos colaterais: Nenhum + def index + turmas_ids = current_user.turmas.pluck(:id) + @turmas_com_form = Turma + .joins(:forms) + .where(id: turmas_ids, forms: { destinatario: current_user.role }) + .includes(:disciplina) + .distinct + end + + # Retorna o formulário mais recente destinado ao papel do usuário atual, + # associado à turma + # + # Argumentos: turma (Turma) + # Retorno: Form ou nil + def form_for(turma) + turma.forms.where(destinatario: current_user.role).order(created_at: :desc).first + end + helper_method :form_for + + # Verifica se o usuário atual já respondeu o formulário informado + # + # Argumentos: form (Form) + # Retorno: Booleano + def respondido?(form) + form && FormResponse.exists?(form: form, user: current_user) + end + helper_method :respondido? + + # Exibe os detalhes de um formulário (turma, template, datas, quantidade + # de questões) antes do discente decidir respondê-lo + # + # Argumentos: params[:id] — id da Turma + # Retorno: Nenhum (renderiza view) + # Efeitos colaterais: Nenhum + def detalhes + return if @form.nil? + + @questions = @form.template.questions + redirect_to avaliacoes_path, alert: "Você já respondeu esta avaliação" if ja_respondeu? + end + + # Exibe o formulário de avaliação de uma turma para o discente responder + # + # Argumentos: params[:id] — id da Turma + # Retorno: Nenhum (renderiza view) + # Efeitos colaterais: Nenhum + def show + return if @form.nil? + + @questions = @form.template.questions + redirect_to avaliacoes_path, alert: "Você já respondeu esta avaliação" if ja_respondeu? + end + + # Processa a submissão das respostas do discente + # + # Argumentos: params[:id] — id da Turma; params[:answers] — hash de respostas + # Retorno: Nenhum (redireciona) + # Efeitos colaterais: Cria FormResponse no banco + def responder + return if @form.nil? + return if validacao_falhou? + + FormResponse.create!( + form: @form, + user: current_user, + turma: @turma, + answers: build_answers + ) + + redirect_to avaliacoes_path, notice: "Avaliação submetida com sucesso!" + rescue => e + flash.now[:alert] = "Erro ao enviar avaliação: #{e.message}" + @questions = @form.template.questions + render "avaliacoes/show", status: :unprocessable_entity + end + + private + + def set_turma_and_form + @turma = Turma.find(params[:id]) + return redirect_to avaliacoes_path, alert: "Você não está matriculado nesta turma" unless matriculado_na_turma? + + @form = @turma.forms.where("end_date > ?", Time.current).where(destinatario: current_user.role).first + redirect_para_formulario_expirado if @form.nil? + end + + # Verifica se o usuário logado está matriculado na turma atual + # + # Argumentos: Nenhum (usa @turma e current_user) + # Retorno: Booleano + def matriculado_na_turma? + TurmaMembership.exists?(turma: @turma, user: current_user) + end + + # Redireciona informando a data em que o formulário mais recente destinado + # ao papel do usuário expirou, ou uma mensagem genérica caso nenhum + # formulário desse tipo tenha existido para a turma (inclusive quando o + # único formulário existente é destinado a outro papel, ex.: docente + # tentando acessar formulário de dicente e vice-versa) + # + # Argumentos: Nenhum (usa @turma e current_user) + # Retorno: Nenhum + # Efeitos colaterais: Redireciona com flash de alerta + def redirect_para_formulario_expirado + formulario_expirado = @turma.forms.where(destinatario: current_user.role).order(end_date: :desc).first + + if formulario_expirado + redirect_to avaliacoes_path, alert: "Este formulário expirou em #{formulario_expirado.end_date.strftime('%d/%m/%Y')}" + else + redirect_to avaliacoes_path, alert: "Formulário não disponível" + end + end + + # Realiza as validações necessárias antes de salvar a resposta. + # + # Argumentos: Nenhum + # Retorno: Booleano indicando se alguma validação falhou (true) ou não (false) + # Efeitos Colaterais: Configura alertas no flash e renderiza/redireciona a página em caso de falha. + def validacao_falhou? + return true if ja_respondeu_validacao? + return true if questoes_obrigatorias_faltando? + false + end + + # Verifica se o usuário já respondeu este formulário. + # + # Argumentos: Nenhum + # Retorno: Booleano + def ja_respondeu? + FormResponse.exists?(form: @form, user: current_user) + end + + # Verifica se o usuário já respondeu este formulário e redireciona se sim. + # + # Argumentos: Nenhum + # Retorno: Booleano + # Efeitos Colaterais: Redireciona e gera alerta se já respondeu. + def ja_respondeu_validacao? + if ja_respondeu? + redirect_to avaliacoes_path, alert: "Você já respondeu esta avaliação" + true + else + false + end + end + + # Verifica se falta alguma resposta a questão obrigatória. + # + # Argumentos: Nenhum + # Retorno: Booleano + # Efeitos Colaterais: Renderiza erro com status unprocessable_entity se faltar resposta. + def questoes_obrigatorias_faltando? + @questions = @form.template.questions + @missing_question_ids = @questions.select do |question| + question.obrigatoria? && (params[:answers].blank? || params[:answers][question.id.to_s].blank?) + end.map(&:id) + + if @missing_question_ids.any? + flash.now[:alert] = "Por favor, responda todas as questões obrigatórias" + render "avaliacoes/show", status: :unprocessable_entity + true + else + false + end + end + + # Monta o array de respostas a partir dos params, no formato + # { "question_id" => ..., "value" => ... } usado por FormResponse#answers + # (e consumido, por exemplo, na exportação de CSV dos resultados). + # + # Argumentos: Nenhum + # Retorno: Array de Hash + def build_answers + return [] unless params[:answers].is_a?(ActionController::Parameters) + + params[:answers].to_unsafe_h.map do |question_id, value| + { + "question_id" => question_id.to_i, + "value" => value.is_a?(String) ? value : value.to_h + } + end + end +end diff --git a/app/controllers/concerns/.keep b/app/controllers/concerns/.keep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb new file mode 100644 index 0000000000..86c5694001 --- /dev/null +++ b/app/controllers/home_controller.rb @@ -0,0 +1,18 @@ +class HomeController < ApplicationController + before_action :require_login + layout "dashboard" + + def index + if current_user.discente? || current_user.docente? + redirect_to avaliacoes_path + end + end + + private + + def require_login + unless session[:user_id] + redirect_to root_path, alert: "Você precisa estar logado" + end + end +end diff --git a/app/controllers/password_resets_controller.rb b/app/controllers/password_resets_controller.rb new file mode 100644 index 0000000000..55fe1625ec --- /dev/null +++ b/app/controllers/password_resets_controller.rb @@ -0,0 +1,114 @@ +# Controller responsável por gerenciar a redefinição de senhas esquecidas. +class PasswordResetsController < ApplicationController + skip_before_action :require_login + + # Exibe a tela de solicitação de redefinição de senha. + # + # Argumentos: Nenhum + # Retorno: Nenhum + # Efeitos Colaterais: Renderiza a view de nova solicitação. + def new + end + + # Processa o e-mail informado e envia o link de redefinição. + # + # Argumentos: Espera params[:login] + # Retorno: Nenhum + # Efeitos Colaterais: Altera o token no BD, enfileira o envio do e-mail. + def create + user = User.find_by_login(params[:login]) + + if user + user.generate_reset_token! + UserMailer.reset_password(user).deliver_later + end + + redirect_to login_path, + flash: { success: "Se esse cadastro existir, você receberá um e-mail com o link para redefinir sua senha." } + end + + # Exibe o formulário de mudança de senha com o token. + # + # Argumentos: params[:token] + # Retorno: Nenhum + # Efeitos Colaterais: Redireciona se token inválido. + def edit + @user = User.find_by(reset_token: params[:token]) + + if @user.nil? + redirect_to login_path, alert: "Link inválido." + elsif @user.reset_token_expired? + redirect_to login_path, alert: "Este link expirou. Solicite um novo link." + end + end + + # Efetiva a alteração da senha. + # + # Argumentos: params[:token], params[:password], params[:password_confirmation] + # Retorno: Nenhum + # Efeitos Colaterais: Altera banco de dados e limpa o token. + def update + @user = User.find_by(reset_token: params[:token]) + return if token_invalido_ou_expirado? + return if senhas_invalidas? + + salvar_nova_senha + end + + private + + # Verifica a validade do token de redefinição. + # + # Argumentos: Nenhum + # Retorno: Booleano + # Efeitos Colaterais: Redireciona em caso de erro. + def token_invalido_ou_expirado? + if @user.nil? || @user.reset_token_expired? + redirect_to login_path, alert: "Este link expirou. Solicite um novo link." + true + else + false + end + end + + # Valida os campos de senha da requisição. + # + # Argumentos: Nenhum + # Retorno: Booleano + # Efeitos Colaterais: Renderiza a tela edit em caso de falha. + def senhas_invalidas? + nova_senha = params[:password] + confirmacao = params[:password_confirmation] + + if nova_senha.blank? || confirmacao.blank? + flash.now[:alert] = "Preencha todos os campos obrigatórios" + render :edit, status: :unprocessable_entity + return true + elsif nova_senha != confirmacao + flash.now[:alert] = "As senhas não coincidem" + render :edit, status: :unprocessable_entity + return true + elsif nova_senha.length < 8 + flash.now[:alert] = "A senha deve ter no mínimo 8 caracteres" + render :edit, status: :unprocessable_entity + return true + end + false + end + + # Atualiza os dados no banco de dados e cria a sessão. + # + # Argumentos: Nenhum + # Retorno: Nenhum + # Efeitos Colaterais: Salva no BD e redireciona página. + def salvar_nova_senha + if @user.update(password: params[:password], password_confirmation: params[:password_confirmation]) + @user.consume_reset_token! + session[:user_id] = @user.id + redirect_to home_path + else + flash.now[:alert] = @user.errors.full_messages.first + render :edit, status: :unprocessable_entity + end + end +end \ No newline at end of file diff --git a/app/controllers/password_sets_controller.rb b/app/controllers/password_sets_controller.rb new file mode 100644 index 0000000000..85c4252842 --- /dev/null +++ b/app/controllers/password_sets_controller.rb @@ -0,0 +1,88 @@ +# Controller responsável pelo primeiro cadastro de senha via convite. +class PasswordSetsController < ApplicationController + skip_before_action :require_login + + # Inicia o processo de definição da senha para novos cadastros. + # + # Argumentos: params[:token] da URL. + # Retorno: Nenhum + # Efeitos Colaterais: Redireciona caso token seja inválido ou renderiza a tela. + def new + @user = User.find_by(invitation_token: params[:token]) + if @user.nil? + redirect_to login_path, alert: "Link inválido." + elsif @user.invitation_token_expired? + redirect_to login_path, alert: "Este link expirou. Solicite um novo cadastro." + end + end + + # Efetiva o cadastro processando as senhas enviadas. + # + # Argumentos: params[:token], params[:password], params[:password_confirmation] + # Retorno: Nenhum + # Efeitos Colaterais: Altera senha no banco de dados e cria sessão. + def update + @user = User.find_by(invitation_token: params[:token]) + return if token_invalido_ou_expirado? + return if senhas_invalidas? + + efetivar_cadastro + end + + private + + # Verifica a validade do token. + # + # Argumentos: Nenhum + # Retorno: Booleano + # Efeitos Colaterais: Redireciona e alerta se for inválido. + def token_invalido_ou_expirado? + if @user.nil? || @user.invitation_token_expired? + redirect_to login_path, alert: "Este link expirou. Solicite um novo cadastro." + true + else + false + end + end + + # Realiza as validações nas senhas fornecidas. + # + # Argumentos: Nenhum + # Retorno: Booleano + # Efeitos Colaterais: Renderiza nova tentativa em caso de erro. + def senhas_invalidas? + nova_senha = params[:password] + confirmacao = params[:password_confirmation] + + if nova_senha.blank? || confirmacao.blank? + flash.now[:alert] = "Preencha todos os campos obrigatórios" + render :new, status: :unprocessable_entity + return true + elsif nova_senha != confirmacao + flash.now[:alert] = "As senhas não coincidem" + render :new, status: :unprocessable_entity + return true + elsif nova_senha.length < 8 + flash.now[:alert] = "A senha deve ter no mínimo 8 caracteres" + render :new, status: :unprocessable_entity + return true + end + false + end + + # Salva a nova senha e consome o token de convite. + # + # Argumentos: Nenhum + # Retorno: Nenhum + # Efeitos Colaterais: Altera o banco de dados, altera sessão e redireciona. + def efetivar_cadastro + if @user.update(password: params[:password], password_confirmation: params[:password_confirmation]) + @user.consume_invitation_token! + session[:user_id] = @user.id + redirect_to home_path, notice: "Cadastro efetivado com sucesso!" + else + flash.now[:alert] = @user.errors.full_messages.first + render :new, status: :unprocessable_entity + end + end +end \ No newline at end of file diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb new file mode 100644 index 0000000000..9bbb164db1 --- /dev/null +++ b/app/controllers/sessions_controller.rb @@ -0,0 +1,42 @@ +# Controller responsável por gerenciar a sessão dos usuários (login e logout) +class SessionsController < ApplicationController + skip_before_action :require_login, only: [:new, :create] + + # Exibe a página de login + # + # Argumentos: Nenhum + # Retorno: Nenhum + # Efeitos Colaterais: Renderiza a view de login + def new + # renderizar a página de login + end + + # Autentica o usuário e cria a sessão + # + # Argumentos: params[:login], params[:password] + # Retorno: Nenhum + # Efeitos Colaterais: Cria sessão, redireciona ou renderiza erro de credenciais. + def create + user = User.find_by_login(params[:login]) + + if user&.authenticate(params[:password]) + session[:user_id] = user.id + redirect_to home_path # tirar o flash[:notice] + elsif user && user.password_digest.nil? && user.invitation_token.present? + redirect_to password_set_path(user.invitation_token), notice: "Este é seu primeiro acesso. Por favor, defina uma senha." + else + flash.now[:alert] = "Credenciais inválidas" + render :new, status: :unprocessable_entity + end + end + + # Destrói a sessão do usuário (Logout) + # + # Argumentos: Nenhum + # Retorno: Nenhum + # Efeitos Colaterais: Limpa sessão e redireciona para a tela de login. + def destroy + session[:user_id] = nil + redirect_to login_path, flash: { logout: "Logout realizado com sucesso!" } + end +end diff --git a/app/controllers/sigaa_controller.rb b/app/controllers/sigaa_controller.rb new file mode 100644 index 0000000000..2f6bc0b81b --- /dev/null +++ b/app/controllers/sigaa_controller.rb @@ -0,0 +1,22 @@ +# Controller responsável pela sincronização de dados via API do SIGAA +class SigaaController < ApplicationController + before_action :require_login + before_action :require_admin + + # Inicia o processo de atualização do banco de dados chamando o serviço de sincronização + # + # Argumentos: Nenhum + # Retorno: Nenhum + # Efeitos Colaterais: Dispara o job de importação e redireciona com flash message. + def update_database + resultado = SigaaSyncService.new(current_user).call + + if resultado[:success] + flash[:notice] = resultado[:message] + else + flash[:alert] = resultado[:message] + end + + redirect_to home_path + end +end \ No newline at end of file diff --git a/app/controllers/templates_controller.rb b/app/controllers/templates_controller.rb new file mode 100644 index 0000000000..5d8dc26b76 --- /dev/null +++ b/app/controllers/templates_controller.rb @@ -0,0 +1,155 @@ +# Controller para gerenciar o ciclo de vida dos templates de formulários +class TemplatesController < ApplicationController + before_action :require_login + before_action :require_admin + before_action :set_template, only: [:show, :edit, :update, :destroy] + + # Lista e pagina os templates, permitindo busca por nome + # + # Argumentos: Nenhum (usa params: :page, :q, :simulate_error) + # Retorno: Renderiza a view index + # Efeitos Colaterais: Altera variáveis de instância de paginação e filtros; pode lançar erro simulado. + def index + @templates = Template.order(:nome) + + if params[:simulate_error] == "true" + raise StandardError, "Falha simulada no servidor" + end + + if params[:q].present? + if ActiveRecord::Base.connection.adapter_name.downcase.include?("sqlite") + @templates = @templates.where( + "lower(nome) LIKE ?", + "%#{params[:q].downcase}%" + ) + else + @templates = @templates.where( + "nome ILIKE ?", + "%#{params[:q]}%" + ) + end + end + + per_page = 5 + page = params[:page].to_i + page = 1 if page < 1 + + @has_previous_page = page > 1 + @has_next_page = @templates.offset(page * per_page).limit(1).exists? + @current_page = page + @templates = @templates.offset((page - 1) * per_page).limit(per_page) + rescue StandardError + flash.now[:alert] = "Erro ao carregar a lista de templates" + @templates = Template.none + @has_previous_page = false + @has_next_page = false + @current_page = 1 + end + + # Exibe os detalhes de um template específico + # + # Argumentos: Nenhum (usa @template definido no set_template) + # Retorno: Renderiza a view show + # Efeitos Colaterais: Nenhum + def show + end + + # Exibe o formulário de edição do template + # + # Argumentos: Nenhum (usa @template definido no set_template) + # Retorno: Renderiza a view edit + # Efeitos Colaterais: Nenhum + def edit + end + + # Cria uma nova versão do template a partir das alterações enviadas + # + # Argumentos: Nenhum (usa params via template_params e @template) + # Retorno: Redirecionamento HTTP + # Efeitos Colaterais: Duplica o registro atual, incrementa a versão e salva no banco de dados. + def update + # Cria uma nova versão do template em vez de alterar o original + novo = @template.dup + + if novo.respond_to?(:parent_template=) + novo.parent_template = @template + end + + novo.user = current_user if novo.respond_to?(:user=) + novo.version = @template.version + 1 if novo.respond_to?(:version=) + + if novo.update(template_params) + redirect_to templates_path, + notice: "Nova versão do template criada com sucesso." + else + flash[:alert] = novo.errors.full_messages.to_sentence + redirect_to edit_template_path(@template) + end + end + + # Remove o template se ele não estiver associado a nenhum formulário + # + # Argumentos: Nenhum (usa @template definido no set_template) + # Retorno: Redirecionamento HTTP + # Efeitos Colaterais: Exclui o registro do template no banco de dados ou exibe alerta de erro. + def destroy + if @template.forms.exists? + redirect_to templates_path, + alert: "Template está em uso por formulários" + return + end + + if @template.destroy + redirect_to templates_path, + notice: "Template removido com sucesso." + else + redirect_to templates_path, + alert: @template.errors.full_messages.to_sentence + end + end + + private + + # Define o template alvo das ações baseado no ID + # + # Argumentos: Nenhum (usa params[:id]) + # Retorno: Objeto Template ou Redirecionamento se não encontrado + # Efeitos Colaterais: Define a variável de instância @template ou redireciona com flash de erro. + def set_template + @template = Template.find(params[:id]) + rescue ActiveRecord::RecordNotFound + redirect_to templates_path, + alert: "Template não encontrado." + end + + # Filtra os parâmetros permitidos para o template + # + # Argumentos: Nenhum (usa params) + # Retorno: ActionController::Parameters (strong params permitidos) + # Efeitos Colaterais: Nenhum + def template_params + params.require(:template) + .permit(:nome, :descricao) + end + + # Garante que o usuário esteja logado no sistema + # + # Argumentos: Nenhum (usa current_user) + # Retorno: Redirecionamento se não logado + # Efeitos Colaterais: Interrompe o fluxo da requisição se o usuário for nulo. + def require_login + redirect_to login_path unless current_user + end + + # Garante que o usuário logado possui privilégios de administrador + # + # Argumentos: Nenhum (usa current_user) + # Retorno: Redirecionamento se não for admin + # Efeitos Colaterais: Interrompe o fluxo da requisição se o usuário não for admin. + def require_admin + unless current_user&.admin? + redirect_to root_path, + alert: "Acesso negado." + end + end +end \ No newline at end of file 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/helpers/home_helper.rb b/app/helpers/home_helper.rb new file mode 100644 index 0000000000..23de56ac60 --- /dev/null +++ b/app/helpers/home_helper.rb @@ -0,0 +1,2 @@ +module HomeHelper +end diff --git a/app/helpers/password_reset_helper.rb b/app/helpers/password_reset_helper.rb new file mode 100644 index 0000000000..c3a05db8d9 --- /dev/null +++ b/app/helpers/password_reset_helper.rb @@ -0,0 +1,2 @@ +module PasswordResetHelper +end diff --git a/app/helpers/password_sets_helper.rb b/app/helpers/password_sets_helper.rb new file mode 100644 index 0000000000..d3493e1402 --- /dev/null +++ b/app/helpers/password_sets_helper.rb @@ -0,0 +1,2 @@ +module PasswordSetsHelper +end diff --git a/app/helpers/sessions_helper.rb b/app/helpers/sessions_helper.rb new file mode 100644 index 0000000000..309f8b2eb3 --- /dev/null +++ b/app/helpers/sessions_helper.rb @@ -0,0 +1,2 @@ +module SessionsHelper +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..30a30acb78 --- /dev/null +++ b/app/javascript/controllers/application.js @@ -0,0 +1,27 @@ +import { Application } from "@hotwired/stimulus" + +const application = Application.start() + +// Configure Stimulus development experience +application.debug = false +window.Stimulus = application + +export { application } + +document.addEventListener("DOMContentLoaded", function() { + const avatar = document.querySelector(".user-avatar"); + if (!avatar) return; // evita erro nas páginas sem o avatar (ex: login) + + function toggleUserMenu() { + document.getElementById("userMenu").classList.toggle("open"); + } + + avatar.addEventListener("click", toggleUserMenu); + + document.addEventListener("click", function(event) { + const wrapper = document.querySelector(".user-avatar-wrapper"); + if (wrapper && !wrapper.contains(event.target)) { + document.getElementById("userMenu").classList.remove("open"); + } + }); +}); 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/javascript/controllers/template_modal_controller.js b/app/javascript/controllers/template_modal_controller.js new file mode 100644 index 0000000000..767d9ec4f3 --- /dev/null +++ b/app/javascript/controllers/template_modal_controller.js @@ -0,0 +1,163 @@ +import { Controller } from "@hotwired/stimulus" + +// Gerencia os modais de criação, edição e exclusão de templates de formulário +export default class extends Controller { + + openNew() { + const modal = document.getElementById("templateModal") + const form = document.getElementById("templateForm") + const methodField = document.getElementById("templateFormMethod") + const submitBtn = document.getElementById("templateSubmitBtn") + + form.action = form.dataset.createUrl || "/admin/templates" + methodField.value = "post" + document.getElementById("templateName").value = "" + document.getElementById("questoesContainer").innerHTML = this._questaoHtml(0) + submitBtn.textContent = "Criar" + this._hideError() + + modal.style.display = "block" + } + + openEdit(event) { + const btn = event.currentTarget + const id = btn.dataset.templateId + const name = btn.dataset.templateName || "" + const modal = document.getElementById("templateModal") + const form = document.getElementById("templateForm") + const submitBtn = document.getElementById("templateSubmitBtn") + + form.action = `/admin/templates/${id}` + document.getElementById("templateFormMethod").value = "patch" + document.getElementById("templateName").value = name + // Limpa questões ao editar — servidor mantém as questões existentes + document.getElementById("questoesContainer").innerHTML = "" + submitBtn.textContent = "Criar" + this._hideError() + + modal.style.display = "block" + } + + openDelete(event) { + const btn = event.currentTarget + const id = btn.dataset.templateId + const name = btn.dataset.templateName || "" + const modal = document.getElementById("deleteModal") + + document.getElementById("deleteMessage").textContent = + `Deseja excluir o template ${name}?` + document.getElementById("deleteForm").action = `/admin/templates/${id}` + + modal.style.display = "block" + } + + close() { + document.getElementById("templateModal").style.display = "none" + } + + closeDelete() { + document.getElementById("deleteModal").style.display = "none" + } + + handleSubmit(event) { + const name = document.getElementById("templateName").value.trim() + + if (!name) { + event.preventDefault() + this._showError("O nome do template é obrigatório") + return + } + + const questoes = document.querySelectorAll("#questoesContainer .questao") + let hasTextError = false + let hasRadioError = false + + questoes.forEach(q => { + const texto = q.querySelector(".questao-texto").value.trim() + if (!texto) hasTextError = true + + const tipo = q.querySelector(".questao-tipo").value + if (tipo === "radio") { + const opcoes = Array.from(q.querySelectorAll(".campo-opcao")) + .map(input => input.value.trim()) + .filter(valor => valor.length > 0) + if (opcoes.length === 0) hasRadioError = true + } + }) + + if (hasTextError) { + event.preventDefault() + this._showError("O texto da questão é obrigatório") + return + } + + if (hasRadioError) { + event.preventDefault() + this._showError("Questões do tipo Radio devem ter ao menos uma opção") + return + } + } + + addQuestao() { + const container = document.getElementById("questoesContainer") + const idx = container.querySelectorAll(".questao").length + container.insertAdjacentHTML("beforeend", this._questaoHtml(idx)) + } + + addOpcao(event) { + const questao = event.currentTarget.closest(".questao") + const idx = parseInt(questao.dataset.idx, 10) || 0 + const opcoes = questao.querySelector(".opcoes-container") + const count = opcoes.querySelectorAll(".campo-opcao").length + const newId = `questao_${idx}_opcao_${count}` + + const input = document.createElement("input") + input.type = "text" + input.id = newId + input.name = `questions[${idx}][options][]` + input.className = "campo-opcao" + input.placeholder = `Opção ${count + 1}` + + opcoes.insertBefore(input, event.currentTarget) + } + + _showError(msg) { + const errDiv = document.getElementById("templateModalError") + if (errDiv) { + errDiv.textContent = msg + errDiv.style.display = "block" + } + } + + _hideError() { + const errDiv = document.getElementById("templateModalError") + if (errDiv) errDiv.style.display = "none" + } + + _questaoHtml(idx) { + return ` +
+
+ + +
+
+ + +
+
+ + + +
+
` + } +} 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..89ba27e264 --- /dev/null +++ b/app/mailers/application_mailer.rb @@ -0,0 +1,4 @@ +class ApplicationMailer < ActionMailer::Base + default from: ENV["GMAIL_USER"] + layout "mailer" +end \ No newline at end of file diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb new file mode 100644 index 0000000000..b6d4b229b4 --- /dev/null +++ b/app/mailers/user_mailer.rb @@ -0,0 +1,15 @@ +class UserMailer < ApplicationMailer + # Não é necessário informar senha no cadastro — o usuário a define pelo link enviado. + + def invitation(user) + @user = user + @url = password_set_url(token: @user.invitation_token) + mail(to: @user.email, subject: "Bem-vindo ao CAMAAR — Defina sua senha") + end + + def reset_password(user) + @user = user + @url = password_reset_url(token: @user.reset_token) + mail(to: @user.email, subject: "CAMAAR — Redefina sua senha") + end +end \ No newline at end of file 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/models/discente.rb b/app/models/discente.rb new file mode 100644 index 0000000000..3fa5993446 --- /dev/null +++ b/app/models/discente.rb @@ -0,0 +1,3 @@ +class Discente < ApplicationRecord + belongs_to :turma +end diff --git a/app/models/disciplina.rb b/app/models/disciplina.rb new file mode 100644 index 0000000000..afbb721ad9 --- /dev/null +++ b/app/models/disciplina.rb @@ -0,0 +1,3 @@ +class Disciplina < ApplicationRecord + has_many :turmas, dependent: :destroy +end diff --git a/app/models/docente.rb b/app/models/docente.rb new file mode 100644 index 0000000000..227fe9a625 --- /dev/null +++ b/app/models/docente.rb @@ -0,0 +1,3 @@ +class Docente < ApplicationRecord + belongs_to :turma +end diff --git a/app/models/form.rb b/app/models/form.rb new file mode 100644 index 0000000000..7d7933d0f0 --- /dev/null +++ b/app/models/form.rb @@ -0,0 +1,45 @@ +# Formulário de avaliação baseado em um Template, enviado para turmas +class Form < ApplicationRecord + belongs_to :template + has_and_belongs_to_many :turmas + + validates :template, presence: true + validates :destinatario, inclusion: { in: %w[discente docente] } + validates :start_date, presence: { message: "As datas de vigência são obrigatórias" } + validates :end_date, presence: { message: "As datas de vigência são obrigatórias" } + validate :end_date_apos_start_date + + # Retorna true se o formulário ainda está disponível para resposta + # + # Argumentos: Nenhum + # Retorno: Boolean + # Efeitos colaterais: Nenhum + def active? + end_date.nil? || end_date > Time.current + end + + # Verifica se o formulário é destinado ao papel (role) do usuário informado + # + # Argumentos: user (User) + # Retorno: Boolean + def destinado_a?(user) + return false if user.nil? + + destinatario == user.role + end + + private + + # Garante que a data de término não seja anterior à data de início + # + # Argumentos: Nenhum (usa start_date e end_date) + # Retorno: Nenhum + # Efeitos colaterais: Adiciona erro em :end_date se inválido + def end_date_apos_start_date + return if start_date.blank? || end_date.blank? + + return unless end_date < start_date + + errors.add(:end_date, "não pode ser anterior à data de início") + end +end diff --git a/app/models/form_response.rb b/app/models/form_response.rb new file mode 100644 index 0000000000..75c479f412 --- /dev/null +++ b/app/models/form_response.rb @@ -0,0 +1,10 @@ +# Resposta de um discente a um formulário enviado para sua turma +class FormResponse < ApplicationRecord + belongs_to :form + belongs_to :user + belongs_to :turma + + serialize :answers, coder: JSON + + validates :form, :user, :turma, presence: true +end diff --git a/app/models/question.rb b/app/models/question.rb new file mode 100644 index 0000000000..a33c7dd014 --- /dev/null +++ b/app/models/question.rb @@ -0,0 +1,24 @@ +# Questão pertencente a um Template de formulário +class Question < ApplicationRecord + belongs_to :template + + serialize :options, coder: JSON + + validates :kind, presence: true, inclusion: { in: %w[radio text escala] } + validates :text, presence: true + validate :radio_must_have_options + + private + + # Garante que questões do tipo "radio" possuam ao menos uma opção preenchida + # + # Argumentos: Nenhum + # Retorno: Nenhum + # Efeitos colaterais: Adiciona erro em :options quando inválido + def radio_must_have_options + return unless kind == "radio" + return if Array(options).any? { |opt| opt.to_s.strip.present? } + + errors.add(:options, "Questões do tipo Radio devem ter ao menos uma opção") + end +end diff --git a/app/models/sigaa_log.rb b/app/models/sigaa_log.rb new file mode 100644 index 0000000000..3badbaad82 --- /dev/null +++ b/app/models/sigaa_log.rb @@ -0,0 +1,3 @@ +class SigaaLog < ApplicationRecord + belongs_to :user +end \ No newline at end of file diff --git a/app/models/template.rb b/app/models/template.rb new file mode 100644 index 0000000000..74a3927496 --- /dev/null +++ b/app/models/template.rb @@ -0,0 +1,23 @@ +# Template de formulário, contendo questões reutilizáveis +class Template < ApplicationRecord + belongs_to :user, optional: true + belongs_to :parent_template, class_name: "Template", optional: true + + has_many :versions, class_name: "Template", foreign_key: "parent_template_id" + has_many :questions, dependent: :destroy + has_many :forms, dependent: :destroy + + before_destroy :ensure_not_in_use_by_forms + + validates :nome, presence: true + + def ensure_not_in_use_by_forms + return true unless Form.exists?(template_id: id) + + errors.add(:base, "Template está em uso por formulários") + throw(:abort) + end + + # Permite usar :name como alias de :nome para compatibilidade com specs + alias_attribute :name, :nome +end \ No newline at end of file diff --git a/app/models/turma.rb b/app/models/turma.rb new file mode 100644 index 0000000000..0f122f0aca --- /dev/null +++ b/app/models/turma.rb @@ -0,0 +1,22 @@ +# Turma associada a uma disciplina +class Turma < ApplicationRecord + belongs_to :disciplina + has_many :docente_records, class_name: "Docente", dependent: :destroy + has_many :discente_records, class_name: "Discente", dependent: :destroy + has_and_belongs_to_many :forms + + has_many :turma_memberships, dependent: :destroy + has_many :member_users, through: :turma_memberships, source: :user + + # Users com role 'docente' vinculados via TurmaMembership + has_many :docentes, + -> { where(role: "docente") }, + through: :turma_memberships, + source: :user + + # Users com role 'discente' vinculados via TurmaMembership + has_many :discente_users, + -> { where(role: "discente") }, + through: :turma_memberships, + source: :user +end diff --git a/app/models/turma_membership.rb b/app/models/turma_membership.rb new file mode 100644 index 0000000000..f1c9e3ce75 --- /dev/null +++ b/app/models/turma_membership.rb @@ -0,0 +1,5 @@ +# Associação entre Turma e User (discentes/docentes cadastrados via User) +class TurmaMembership < ApplicationRecord + belongs_to :turma + belongs_to :user +end diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 0000000000..0c6c1d321d --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,147 @@ +# Modelo de Usuário do sistema +class User < ApplicationRecord + has_secure_password validations: false + + has_many :turma_memberships, dependent: :destroy + has_many :turmas, through: :turma_memberships + has_many :form_responses, dependent: :destroy + + validates :email, uniqueness: true, allow_blank: false + validates :matricula, uniqueness: true, allow_blank: false + validates :role, inclusion: { in: %w[admin discente docente] } + validates :password, length: { minimum: 8 }, if: :password_being_set? + validates :password, confirmation: true, if: :password_being_set? + + # Busca um usuário pelo email ou matricula + # + # Argumentos: login (String) + # Retorno: User ou nil + # Efeitos Colaterais: Busca no BD + def self.find_by_login(login) + find_by(email: login) || find_by(matricula: login) + end + + # Verifica se é admin + # + # Argumentos: Nenhum + # Retorno: Booleano + # Efeitos Colaterais: Nenhum + def admin? + role == "admin" + end + + # Verifica se é discente + # + # Argumentos: Nenhum + # Retorno: Booleano + # Efeitos Colaterais: Nenhum + def discente? + role == "discente" + end + + # Verifica se é docente + # + # Argumentos: Nenhum + # Retorno: Booleano + # Efeitos Colaterais: Nenhum + def docente? + role == "docente" + end + + # Retorna o perfil vinculado (Discente ou Docente) + # + # Argumentos: Nenhum + # Retorno: Collection + # Efeitos Colaterais: Consulta no BD + def profile + @profile ||= if discente? + Discente.where(matricula: matricula) + elsif docente? + Docente.where(usuario: matricula) + end + end + + # Retorna turmas vinculadas ao usuário via importação SIGAA (legado) + # + # Argumentos: Nenhum + # Retorno: ActiveRecord::Relation ou Array vazio + # Efeitos Colaterais: Consulta no BD + def sigaa_turmas + if discente? + Turma.where(id: Discente.where(matricula: matricula).select(:turma_id)) + elsif docente? + Turma.where(id: Docente.where(usuario: matricula).select(:turma_id)) + else + Turma.none + end + end + + # Gera token de convite e salva + # + # Argumentos: Nenhum + # Retorno: Booleano + # Efeitos Colaterais: Modifica BD + def generate_invitation_token! + self.invitation_token = SecureRandom.urlsafe_base64(32) + self.invitation_sent_at = Time.current + save!(validate: false) + end + + # Checa se o convite expirou (24h) + # + # Argumentos: Nenhum + # Retorno: Booleano + # Efeitos Colaterais: Nenhum + def invitation_token_expired? + invitation_sent_at.nil? || invitation_sent_at < 24.hours.ago + end + + # Consome token de convite + # + # Argumentos: Nenhum + # Retorno: Booleano + # Efeitos Colaterais: Atualiza colunas no BD + def consume_invitation_token! + update_columns(invitation_token: nil, invitation_sent_at: nil) + end + + # Gera token de redefinição de senha + # + # Argumentos: Nenhum + # Retorno: Booleano + # Efeitos Colaterais: Modifica BD + def generate_reset_token! + self.reset_token = SecureRandom.urlsafe_base64(32) + self.reset_sent_at = Time.current + save!(validate: false) + end + + # Checa se o token de senha expirou (24h) + # + # Argumentos: Nenhum + # Retorno: Booleano + # Efeitos Colaterais: Nenhum + def reset_token_expired? + reset_sent_at.nil? || reset_sent_at < 24.hours.ago + end + + # Consome token de redefinição + # + # Argumentos: Nenhum + # Retorno: Booleano + # Efeitos Colaterais: Atualiza colunas no BD + def consume_reset_token! + update_columns(reset_token: nil, reset_sent_at: nil) + end + + private + + # Verifica se a senha está sendo cadastrada + # + # Argumentos: Nenhum + # Retorno: Booleano + # Efeitos Colaterais: Nenhum + def password_being_set? + password.present? || password_confirmation.present? + end +end \ No newline at end of file diff --git a/app/services/sigaa_api.rb b/app/services/sigaa_api.rb new file mode 100644 index 0000000000..18cae44a15 --- /dev/null +++ b/app/services/sigaa_api.rb @@ -0,0 +1,35 @@ +# API Simulada (Mock) para comunicação com o SIGAA. +class SigaaApi + # Busca os dados baseados no cenário de teste configurado. + # + # Argumentos: Nenhum + # Retorno: JSON String + # Efeitos Colaterais: Lê arquivo local ou lança exceção. + def self.fetch_data + case scenario + when :success + File.read(Rails.root.join("spec/fixtures/sigaa.json")) + + when :empty + "[]" + + when :invalid + raise "Dados inválidos retornados pelo SIGAA" + + when :connection_error + raise "Falha de conexão com o SIGAA" + + else + File.read(Rails.root.join("spec/fixtures/sigaa.json")) + end + end + + # Recupera o cenário atual do ambiente. + # + # Argumentos: Nenhum + # Retorno: Symbol + # Efeitos Colaterais: Acessa ENV + def self.scenario + ENV["SIGAA_SCENARIO"]&.to_sym || :success + end +end \ No newline at end of file diff --git a/app/services/sigaa_importer.rb b/app/services/sigaa_importer.rb new file mode 100644 index 0000000000..6a3af6bb57 --- /dev/null +++ b/app/services/sigaa_importer.rb @@ -0,0 +1,178 @@ +require 'json' + +# Serviço para importar dados do SIGAA. +class SigaaImporter + class << self + # Importa turmas do JSON para o banco de dados. + # + # Argumentos: json_content (String) + # Retorno: Hash com status e mensagem + # Efeitos Colaterais: Cria disciplinas e turmas no banco. + def import_classes(json_content) + begin + data = parse_json_data(json_content) + process_classes_data(data) + { success: true, message: "Dados de turmas importados com sucesso: #{data.size} novas turmas processadas" } + rescue JSON::ParserError + { success: false, message: "Erro na estrutura do arquivo: campos obrigatórios ausentes ou JSON inválido" } + rescue => e + { success: false, message: "Erro na estrutura do arquivo: #{e.message}" } + end + end + + # Importa participantes (docentes e discentes) do JSON. + # + # Argumentos: json_content (String) + # Retorno: Hash com status e mensagem + # Efeitos Colaterais: Cria usuários, docentes e discentes no banco. + def import_members(json_content) + begin + data = parse_json_data(json_content) + process_members_data(data) + { success: true, message: "Participantes importados com sucesso" } + rescue JSON::ParserError + { success: false, message: "Erro na estrutura do arquivo: campos obrigatórios ausentes ou JSON inválido" } + rescue => e + { success: false, message: "Erro na estrutura do arquivo: #{e.message}" } + end + end + + private + + # Transforma string em array de hash. + # + # Argumentos: json_content (String) + # Retorno: Array de Hashes + # Efeitos Colaterais: Nenhum + def parse_json_data(json_content) + data = JSON.parse(json_content) + data.is_a?(Array) ? data : [data] + end + + # Itera sobre os dados para importar as classes. + # + # Argumentos: data (Array) + # Retorno: Nenhum + # Efeitos Colaterais: Chama persistencia no BD. + def process_classes_data(data) + data.each do |item| + next unless item.is_a?(Hash) + validate_class_structure!(item) + import_single_class(item) + end + end + + # Valida estrutura da classe. + # + # Argumentos: item (Hash) + # Retorno: Nenhum + # Efeitos Colaterais: Lança exceção se faltarem campos. + def validate_class_structure!(item) + raise "campos obrigatórios ausentes" unless item['code'] && item['class'] && item['class']['classCode'] + end + + # Importa uma disciplina e turma individualmente. + # + # Argumentos: item (Hash) + # Retorno: Nenhum + # Efeitos Colaterais: Cria no BD. + def import_single_class(item) + disciplina = Disciplina.find_or_create_by!(code: item['code']) do |d| + d.name = item['name'] + end + + Turma.find_or_create_by!( + classCode: item['class']['classCode'], + semester: item['class']['semester'], + disciplina: disciplina + ) do |t| + t.time = item['class']['time'] + end + end + + # Processa dados dos membros. + # + # Argumentos: data (Array) + # Retorno: Nenhum + # Efeitos Colaterais: Busca e persiste. + def process_members_data(data) + data.each do |item| + next unless item.is_a?(Hash) + turma = find_turma(item) + next unless turma + + import_discentes(item['dicente'] || [], turma) + import_docente(item['docente'], turma) if item['docente'] + end + end + + # Localiza turma pelo hash. + # + # Argumentos: item (Hash) + # Retorno: Turma ou nil + # Efeitos Colaterais: Busca no BD. + def find_turma(item) + disciplina = Disciplina.find_by(code: item['code']) + return nil unless disciplina + Turma.find_by(classCode: item['classCode'], semester: item['semester'], disciplina: disciplina) + end + + # Importa discentes. + # + # Argumentos: dicentes (Array), turma (Turma) + # Retorno: Nenhum + # Efeitos Colaterais: Persiste discentes. + def import_discentes(dicentes, turma) + dicentes.each do |d_data| + create_discente_record(d_data, turma) + create_or_invite_user(d_data['matricula'], d_data['email'], 'discente') + end + end + + # Cria ou busca o discente + # + # Argumentos: d_data (Hash), turma (Turma) + # Retorno: Nenhum + # Efeitos Colaterais: Cria discente no banco + def create_discente_record(d_data, turma) + Discente.find_or_create_by!(matricula: d_data['matricula'], turma: turma) do |d| + d.nome = d_data['nome'] + d.curso = d_data['curso'] + d.usuario = d_data['usuario'] + d.formacao = d_data['formacao'] + d.ocupacao = d_data['ocupacao'] + d.email = d_data['email'] + end + end + + # Importa docente. + # + # Argumentos: doc_data (Hash), turma (Turma) + # Retorno: Nenhum + # Efeitos Colaterais: Persiste docentes. + def import_docente(doc_data, turma) + Docente.find_or_create_by!(usuario: doc_data['usuario'], turma: turma) do |doc| + doc.nome = doc_data['nome'] + doc.departamento = doc_data['departamento'] + doc.formacao = doc_data['formacao'] + doc.email = doc_data['email'] + doc.ocupacao = doc_data['ocupacao'] + end + + create_or_invite_user(doc_data['usuario'], doc_data['email'], 'docente') + end + + # Cria usuário e gera convite caso não exista. + # + # Argumentos: matricula, email, role + # Retorno: Nenhum + # Efeitos Colaterais: Cria usuário + def create_or_invite_user(matricula, email, role) + User.find_or_create_by!(matricula: matricula) do |u| + u.email = email + u.role = role + u.generate_invitation_token! + end + end + end +end diff --git a/app/services/sigaa_sync_service.rb b/app/services/sigaa_sync_service.rb new file mode 100644 index 0000000000..505a1cbbec --- /dev/null +++ b/app/services/sigaa_sync_service.rb @@ -0,0 +1,93 @@ +# Serviço para sincronizar dados do SIGAA +class SigaaSyncService + # Inicializa o servico + # + # Argumentos: user (User) + # Retorno: Nenhum + # Efeitos Colaterais: Salva user no estado + def initialize(user) + @user = user + end + + # Inicia o processo de importacao + # + # Argumentos: Nenhum + # Retorno: Hash com status e message + # Efeitos Colaterais: Cria locks, importa turmas e membros, registra logs. + def call + return failure("Acesso negado") unless @user.admin? + return failure("Já existe uma atualização em andamento") if SyncLock.active? + + SyncLock.start! + execute_sync + ensure + SyncLock.release! + end + + private + + # Executa o sincronismo real + # + # Argumentos: Nenhum + # Retorno: Hash com sucesso ou falha + # Efeitos Colaterais: Importa turmas ou captura exceções + def execute_sync + begin + json = SigaaApi.fetch_data + return failure("SIGAA não retornou dados") if json.nil? || json.strip == "[]" + + import_data_from_json(json) + rescue => e + log_sync_event("error", e.message) + failure(e.message) + end + end + + # Coordena as chamadas para o importador + # + # Argumentos: json (String) + # Retorno: Hash + # Efeitos Colaterais: Persistencia no DB + def import_data_from_json(json) + classes = SigaaImporter.import_classes(json) + members = SigaaImporter.import_members(json) + + if classes[:success] && members[:success] + log_sync_event("success", "Atualização SIGAA concluída") + success("Atualização concluída com sucesso") + else + failure("SIGAA retornou dados inconsistentes") + end + end + + # Salva log da operacao + # + # Argumentos: status, message + # Retorno: Nenhum + # Efeitos Colaterais: Salva SigaaLog no DB + def log_sync_event(status, message) + SigaaLog.create!( + user: @user, + status: status, + message: message + ) + end + + # Auxiliar de sucesso + # + # Argumentos: msg + # Retorno: Hash + # Efeitos Colaterais: Nenhum + def success(msg) + { success: true, message: msg } + end + + # Auxiliar de falha + # + # Argumentos: msg + # Retorno: Hash + # Efeitos Colaterais: Nenhum + def failure(msg) + { success: false, message: msg } + end +end \ No newline at end of file diff --git a/app/services/sync_lock.rb b/app/services/sync_lock.rb new file mode 100644 index 0000000000..8b3b13edad --- /dev/null +++ b/app/services/sync_lock.rb @@ -0,0 +1,13 @@ +class SyncLock + def self.active? + Rails.cache.read("sigaa_lock") == true + end + + def self.start! + Rails.cache.write("sigaa_lock", true) + end + + def self.release! + Rails.cache.delete("sigaa_lock") + end +end \ No newline at end of file diff --git a/app/views/admin/forms/index.html.erb b/app/views/admin/forms/index.html.erb new file mode 100644 index 0000000000..10c6195f4c --- /dev/null +++ b/app/views/admin/forms/index.html.erb @@ -0,0 +1,35 @@ +<% if flash[:notice] %> +
<%= flash[:notice] %>
+<% end %> +<% if flash[:alert] %> +
<%= flash[:alert] %>
+<% end %> + +

Formulários Ativos

+ +<% if @forms.empty? %> +

Nenhum formulário ativo no momento

+<% else %> + + + + + + + + + + + + <% @forms.each do |form| %> + + + + + + + + <% end %> + +
DestinatárioTurma(s)TemplateInícioTérmino
<%= form.destinatario == "discente" ? "Dicentes" : "Docentes" %><%= form.turmas.map(&:classCode).join(", ") %><%= form.template.nome %><%= form.start_date&.strftime("%d/%m/%Y") %><%= form.end_date&.strftime("%d/%m/%Y") %>
+<% end %> diff --git a/app/views/admin/imports/index.html.erb b/app/views/admin/imports/index.html.erb new file mode 100644 index 0000000000..78c96bc08d --- /dev/null +++ b/app/views/admin/imports/index.html.erb @@ -0,0 +1,17 @@ +

Gerenciamento - Importar Dados SIGAA

+ +<%= form_with(url: admin_imports_path, method: :post, multipart: true) do |f| %> +
+ <%= f.label :import_type, "Tipo de Importação" %> + <%= f.select :import_type, [["Turmas", "classes"], ["Participantes", "members"]] %> +
+ +
+ <%= f.label :file, "Selecionar Arquivo (.json)" %> + <%= f.file_field :file %> +
+ +
+ <%= f.submit "Confirmar Importação" %> +
+<% end %> diff --git a/app/views/admin/management/index.html.erb b/app/views/admin/management/index.html.erb new file mode 100644 index 0000000000..a5a98bb739 --- /dev/null +++ b/app/views/admin/management/index.html.erb @@ -0,0 +1,133 @@ +

Gerenciamento

+ +
+ <%= button_to "Atualizar Base SIGAA", "/sigaa/update", method: :post, class: "btn btn-warning", data: { turbo: false } %> + <%= button_to "Editar Templates", admin_templates_path, method: :get, class: "btn btn-secondary", data: { turbo: false } %> + + + + <%= link_to "Resultados", admin_results_path, class: "btn btn-info" %> +
+ + + + + diff --git a/app/views/admin/results/index.html.erb b/app/views/admin/results/index.html.erb new file mode 100644 index 0000000000..6511f285ac --- /dev/null +++ b/app/views/admin/results/index.html.erb @@ -0,0 +1,27 @@ +<% if flash[:notice] %> +
<%= flash[:notice] %>
+<% end %> +<% if flash[:alert] %> +
<%= flash[:alert] %>
+<% end %> + +

Resultados

+ +<% if @turmas.empty? %> +

Nenhum resultado disponível

+<% else %> +
+ <% @turmas.each do |turma| %> +
+ + <%= link_to turma.disciplina&.name || turma.classCode, + admin_result_path(turma) %> + + <%= turma.semester %> + + <%= turma.docentes.map(&:nome).reject(&:blank?).join(", ") %> + +
+ <% end %> +
+<% end %> diff --git a/app/views/admin/results/show.html.erb b/app/views/admin/results/show.html.erb new file mode 100644 index 0000000000..964ee74ca4 --- /dev/null +++ b/app/views/admin/results/show.html.erb @@ -0,0 +1,54 @@ +

Resultados — <%= @turma.disciplina&.name || @turma.classCode %>

+ +<%= form_with url: csv_admin_result_path(@turma), method: :get, local: true do %> + <%= submit_tag "Baixar CSV", class: "btn btn-primary" %> +<% end %> + +<% if @responses.empty? %> +

Esta turma ainda não possui respostas registradas

+<% else %> +

<%= @template&.nome %>

+

Total de respostas recebidas: <%= @responses.size %>

+ + <% @question_summaries.each do |summary| %> +
+

<%= summary[:question].text %>

+ + <% if summary[:question].kind == "radio" %> + + <% else %> + + <% end %> +
+ <% end %> + +
+

Respostas detalhadas

+ + + + + + <% @responses.each do |fr| %> + <% answers = Array(fr.answers) %> + <% questions = fr.form&.template&.questions || [] %> + <% answers.each do |a| %> + <% q = questions.find { |q| q.id == a["question_id"] } %> + + + + + + <% end %> + <% end %> + +
DiscenteQuestãoResposta
<%= fr.user&.nome %><%= q&.text %><%= a["value"] %>
+<% end %> diff --git a/app/views/admin/templates/index.html.erb b/app/views/admin/templates/index.html.erb new file mode 100644 index 0000000000..aa38f6e704 --- /dev/null +++ b/app/views/admin/templates/index.html.erb @@ -0,0 +1,103 @@ +
+ +

Templates

+ +
+
+
+ + <% @templates.each do |template| %> +
+ <%= template.nome %> + <%= template.semester %> +
+ + +
+
+ <% end %> +
+ + + + + + + +
diff --git a/app/views/avaliacoes/detalhes.html.erb b/app/views/avaliacoes/detalhes.html.erb new file mode 100644 index 0000000000..f380bc421e --- /dev/null +++ b/app/views/avaliacoes/detalhes.html.erb @@ -0,0 +1,20 @@ +<% if flash[:notice] %> +
<%= flash[:notice] %>
+<% end %> +<% if flash[:alert] %> +
<%= flash[:alert] %>
+<% end %> + +
+

Detalhes da Avaliação

+ +

Turma: <%= @turma.classCode %>

+

Template: <%= @form.template&.name %>

+

Data de Início: <%= @form.start_date.strftime("%d/%m/%Y") %>

+

Data de Término: <%= @form.end_date.strftime("%d/%m/%Y") %>

+

<%= @questions.size %> questões

+ + <%= form_with url: avaliacao_path(@turma), method: :get, local: true do %> + <%= submit_tag "Responder Agora", name: nil, class: "btn btn-primary btn-responder-agora" %> + <% end %> +
diff --git a/app/views/avaliacoes/index.html.erb b/app/views/avaliacoes/index.html.erb new file mode 100644 index 0000000000..508b77cd48 --- /dev/null +++ b/app/views/avaliacoes/index.html.erb @@ -0,0 +1,35 @@ +<% if flash[:notice] %> +
<%= flash[:notice] %>
+<% end %> +<% if flash[:alert] %> +
<%= flash[:alert] %>
+<% end %> + +

Minhas Avaliações

+ +<% if @turmas_com_form.empty? %> +

Nenhuma avaliação disponível no momento.

+<% else %> +
+
+ <% @turmas_com_form.each do |turma| %> + <% form = form_for(turma) %> +
+ <%= link_to turma.classCode, detalhes_avaliacao_path(turma) %> +

<%= form.template&.name %>

+

Término: <%= form.end_date.strftime("%d/%m/%Y") %>

+ + <% if respondido?(form) %> + Respondido +

Você já respondeu esta avaliação

+ <%= button_tag "Responder", disabled: true, class: "btn-responder" %> + <% elsif form.active? %> + <%= link_to "Responder", avaliacao_path(turma), class: "btn-responder" %> + <% else %> +

Avaliação encerrada em <%= form.end_date.strftime("%d/%m/%Y") %>

+ <% end %> +
+ <% end %> +
+
+<% end %> diff --git a/app/views/avaliacoes/show.html.erb b/app/views/avaliacoes/show.html.erb new file mode 100644 index 0000000000..a91498ea1e --- /dev/null +++ b/app/views/avaliacoes/show.html.erb @@ -0,0 +1,33 @@ +<% if flash[:notice] %> +
<%= flash[:notice] %>
+<% end %> +<% if flash[:alert] %> +
<%= flash[:alert] %>
+<% end %> + +

Avaliação — <%= @turma.classCode %>

+ +<%= form_with url: responder_avaliacao_path(@turma), method: :post, local: true do |f| %> + <% @questions.each do |question| %> +
+

+ <%= question.text %> + <% if question.obrigatoria? %>*<% end %> +

+ + <% valor_anterior = params.dig(:answers, question.id.to_s) %> + <% if question.kind == "radio" %> + <% ["Sim", "Não", "Parcialmente"].each_with_index do |opcao, index| %> + + <% end %> + <% else %> + + <% end %> +
+ <% end %> + + +<% end %> diff --git a/app/views/home/index.html.erb b/app/views/home/index.html.erb new file mode 100644 index 0000000000..fdb1fb67f7 --- /dev/null +++ b/app/views/home/index.html.erb @@ -0,0 +1,8 @@ +<% if flash[:notice] %> +
<%= flash[:notice] %>
+<% end %> +<% if flash[:alert] %> +
<%= flash[:alert] %>
+<% end %> +

Bem-vindo ao CAMAAR

+

Utilize o menu lateral para navegar entre as seções disponíveis.

diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb new file mode 100644 index 0000000000..b92e4cbe22 --- /dev/null +++ b/app/views/layouts/application.html.erb @@ -0,0 +1,43 @@ + + + + <%= content_for(:title) || "Trabalho Final Eng Soft" %> + + + + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + <%= yield :head %> + + + + + + <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> + <%= javascript_importmap_tags %> + + + + <% if flash[:notice] || flash[:alert] || flash[:logout] || flash[:success] %> + + <% unless Rails.env.test? %> + + <% end %> + <% end %> + + <%= yield %> + + diff --git a/app/views/layouts/dashboard.html.erb b/app/views/layouts/dashboard.html.erb new file mode 100644 index 0000000000..036967da81 --- /dev/null +++ b/app/views/layouts/dashboard.html.erb @@ -0,0 +1,85 @@ + + + + CAMAAR + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + <%= stylesheet_link_tag "home", "data-turbo-track": "reload" %> + <%= javascript_importmap_tags %> + + + + +
+
+ + CAMAAR +
+ +
+
+ <%= current_user&.email %> + <%= button_to "Logout", logout_path, method: :delete, class: "logout-btn" %> +
+
+
+ + + +
+ <% if flash[:notice] %> +
<%= flash[:notice] %>
+ <% end %> + <% if flash[:alert] %> +
<%= flash[:alert] %>
+ <% end %> + <%= 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/password_resets/edit.html.erb b/app/views/password_resets/edit.html.erb new file mode 100644 index 0000000000..2e5bf0a579 --- /dev/null +++ b/app/views/password_resets/edit.html.erb @@ -0,0 +1,35 @@ + + <% if flash[:alert] %> + + <% end %> + +
+
+

REDEFINIR SENHA

+ <%= form_with url: url_for(action: :update, token: params[:token]), method: :patch do |form| %> +
+ <%= form.label :password, "Nova Senha" %> + <%= form.password_field :password, required: true, placeholder: "Nova Senha" %> +
+
+ <%= form.label :password_confirmation, "Confirmar Senha" %> + <%= form.password_field :password_confirmation, required: true, placeholder: "Confirmar Senha" %> +
+ <%= form.submit "Redefinir Senha" %> + <% end %> +
+
+

Crie sua nova senha

+
+
+ diff --git a/app/views/password_resets/new.html.erb b/app/views/password_resets/new.html.erb new file mode 100644 index 0000000000..41fd20005a --- /dev/null +++ b/app/views/password_resets/new.html.erb @@ -0,0 +1,34 @@ + + <% if flash[:alert] %> + + <% end %> + +
+
+

ESQUECI MINHA SENHA

+ <%= form_with url: password_resets_path do |form| %> +
+ <%= form.label :login, "Email ou Matrícula" %> + <%= form.text_field :login, required: true, placeholder: "Email ou Matrícula" %> +
+ <%= form.submit "Enviar link" %> + <% end %> +

+ <%= link_to "Voltar para o login", login_path %> +

+
+
+

Recupere seu acesso

+
+
+ diff --git a/app/views/password_sets/new.html.erb b/app/views/password_sets/new.html.erb new file mode 100644 index 0000000000..34c227cb28 --- /dev/null +++ b/app/views/password_sets/new.html.erb @@ -0,0 +1,37 @@ + + <% if flash[:notice] || flash[:alert] %> + + <% end %> + +
+
+

DEFINIR SENHA

+ <%= form_with url: url_for(action: :update, token: params[:token]), method: :patch do |form| %> +
+ <%= form.label :password, "Nova Senha" %> + <%= form.password_field :password, required: true, placeholder: "Nova Senha" %> +
+
+ <%= form.label :password_confirmation, "Confirmar Senha" %> + <%= form.password_field :password_confirmation, required: true, placeholder: "Confirmar Senha" %> +
+ <%= form.submit "Definir Senha" %> + <% end %> +
+
+

Defina sua nova senha

+
+
+ \ No newline at end of file diff --git a/app/views/password_sets/update.html.erb b/app/views/password_sets/update.html.erb new file mode 100644 index 0000000000..5d406b244b --- /dev/null +++ b/app/views/password_sets/update.html.erb @@ -0,0 +1,2 @@ +

PasswordSets#update

+

Find me in app/views/password_sets/update.html.erb

diff --git a/app/views/pwa/manifest.json.erb b/app/views/pwa/manifest.json.erb new file mode 100644 index 0000000000..6c50be8c6b --- /dev/null +++ b/app/views/pwa/manifest.json.erb @@ -0,0 +1,22 @@ +{ + "name": "TrabalhoFinalEngSoft", + "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": "TrabalhoFinalEngSoft.", + "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/app/views/sessions/create.html.erb b/app/views/sessions/create.html.erb new file mode 100644 index 0000000000..c251174fea --- /dev/null +++ b/app/views/sessions/create.html.erb @@ -0,0 +1,2 @@ +

Sessions#create

+

Find me in app/views/sessions/create.html.erb

diff --git a/app/views/sessions/destroy.html.erb b/app/views/sessions/destroy.html.erb new file mode 100644 index 0000000000..d75237d982 --- /dev/null +++ b/app/views/sessions/destroy.html.erb @@ -0,0 +1,2 @@ +

Sessions#destroy

+

Find me in app/views/sessions/destroy.html.erb

diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb new file mode 100644 index 0000000000..3819c586be --- /dev/null +++ b/app/views/sessions/new.html.erb @@ -0,0 +1,25 @@ +
+ + + +
diff --git a/app/views/templates/edit.html.erb b/app/views/templates/edit.html.erb new file mode 100644 index 0000000000..e4999949b3 --- /dev/null +++ b/app/views/templates/edit.html.erb @@ -0,0 +1,17 @@ +

Editar Template

+ +<% if flash[:alert].present? %> +
+

<%= flash[:alert] %>

+
+<% end %> + +<%= form_with model: @template, local: true do |f| %> + <%= f.label :nome, "Nome" %> + <%= f.text_field :nome %> + + <%= f.label :descricao, "Descricao" %> + <%= f.text_area :descricao %> + + <%= f.submit "Salvar" %> +<% end %> \ No newline at end of file diff --git a/app/views/templates/index.html.erb b/app/views/templates/index.html.erb new file mode 100644 index 0000000000..9ff165bba9 --- /dev/null +++ b/app/views/templates/index.html.erb @@ -0,0 +1,38 @@ +

Templates

+ +<%= form_with url: templates_path, method: :get, local: true do |f| %> + <%= f.label :q, "Buscar" %> + <%= f.text_field :q %> + <%= f.submit "Pesquisar" %> +<% end %> + +<% if @templates.any? %> + + + +<% else %> +

Não há templates disponíveis.

+<% end %> \ No newline at end of file diff --git a/app/views/templates/show.html.erb b/app/views/templates/show.html.erb new file mode 100644 index 0000000000..37348baeb1 --- /dev/null +++ b/app/views/templates/show.html.erb @@ -0,0 +1,7 @@ +

<%= @template.nome %>

+ +

+ <%= @template.descricao %> +

+ +<%= link_to "Voltar", templates_path %> \ No newline at end of file diff --git a/app/views/user_mailer/invitation.html.erb b/app/views/user_mailer/invitation.html.erb new file mode 100644 index 0000000000..fed0db99d5 --- /dev/null +++ b/app/views/user_mailer/invitation.html.erb @@ -0,0 +1,5 @@ +

Olá!

+

Você foi cadastrado no sistema CAMAAR.

+

Clique no link abaixo para definir sua senha. O link expira em 24 horas.

+

<%= link_to "Definir minha senha", @url %>

+

Se você não solicitou esse cadastro, ignore este e-mail.

\ No newline at end of file diff --git a/app/views/user_mailer/invitation.text.erb b/app/views/user_mailer/invitation.text.erb new file mode 100644 index 0000000000..1ad1a10286 --- /dev/null +++ b/app/views/user_mailer/invitation.text.erb @@ -0,0 +1,9 @@ +Olá! + +Você foi cadastrado no sistema CAMAAR. + +Acesse o link abaixo para definir sua senha. O link expira em 24 horas. + +<%= @url %> + +Se você não solicitou esse cadastro, ignore este e-mail. \ No newline at end of file diff --git a/app/views/user_mailer/reset_password.html.erb b/app/views/user_mailer/reset_password.html.erb new file mode 100644 index 0000000000..43a820e782 --- /dev/null +++ b/app/views/user_mailer/reset_password.html.erb @@ -0,0 +1,5 @@ +

Redefina sua senha

+

Recebemos uma solicitação para redefinir a senha da sua conta no CAMAAR.

+

Clique no link abaixo para criar uma nova senha. O link expira em 24 horas.

+

<%= link_to "Redefinir minha senha", @url %>

+

Se você não solicitou a redefinição, ignore este e-mail. Sua senha permanecerá a mesma.

diff --git a/app/views/user_mailer/reset_password.text.erb b/app/views/user_mailer/reset_password.text.erb new file mode 100644 index 0000000000..7eaa1a92c1 --- /dev/null +++ b/app/views/user_mailer/reset_password.text.erb @@ -0,0 +1,9 @@ +Redefina sua senha + +Recebemos uma solicitação para redefinir a senha da sua conta no CAMAAR. + +Acesse o link abaixo para criar uma nova senha. O link expira em 24 horas. + +<%= @url %> + +Se você não solicitou a redefinição, ignore este e-mail. Sua senha permanecerá a mesma. 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..cbe59b95ed --- /dev/null +++ b/bin/kamal @@ -0,0 +1,27 @@ +#!/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__) + +bundle_binstub = File.expand_path("bundle", __dir__) + +if File.file?(bundle_binstub) + if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") + load(bundle_binstub) + else + abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. +Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") + end +end + +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..924ee2d890 --- /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 TrabalhoFinalEngSoft + 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..1712cc1127 --- /dev/null +++ b/config/ci.rb @@ -0,0 +1,24 @@ +# 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: Rails", "bin/rails test" + 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..7fb22fc0cf --- /dev/null +++ b/config/credentials.yml.enc @@ -0,0 +1 @@ +mjRyVXhSn6f910REf8LItBjEXxR40VKHHdlTAA6yJ4+GmBQCzKbOSA7YfXjLyMLdi4WxB/wOwfE8EytiCyaq8wiTCsamkXTRaHXZer4XG/z9LNK4llEA5nDcrn/7pq/W+IsZnnMJnPaKnysQm+7IzV8q0V1A0HuKU5Q3MffCSbv6Qpu79zIUJd2iJ9JPiyKejI1ng3HoM/wK8vofZGEYo0JbyviVW3SKXDdPwsRE8hgDioYlQsddbWZKRej6tHaSZRIZv4dEwaTXMm2TewLJquTGE2I964iJD4XNz+wOhOIJsNzbhKUr+b2/8CdnGLoERt3tPcBW2xF86+RX8SecXL5hZiBYRb82b94gkP9SnC5pJp7Tc9GEK972OMNKDebtTqq4+ZYk9G6WXls479QkUWaIyiDs9w0NiozRq4KTSVCSQ9WZuj8V4pfyhx7hT/iatYFv19shjNas+lg//JEuUmbZhT2wU4GpHkWmORY4S3zG5JoE05m9GiUt--zP8eXi+0+r0z/0Wa--cv+BnPP1pzPpcLmCyxQ74g== \ 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..0583990c5e --- /dev/null +++ b/config/deploy.yml @@ -0,0 +1,119 @@ +# Name of your application. Used to uniquely configure containers. +service: trabalho_final_eng_soft + +# Name of the container image (use your-user/app-name on external registries). +image: trabalho_final_eng_soft + +# 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 trabalho_final_eng_soft-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: + - "trabalho_final_eng_soft_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..ea4d903964 --- /dev/null +++ b/config/environments/development.rb @@ -0,0 +1,96 @@ +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! + config.action_mailer.default_url_options = { host: 'localhost', port: 3000 } + + config.action_mailer.delivery_method = :smtp + config.action_mailer.perform_deliveries = true + config.action_mailer.raise_delivery_errors = true + config.action_mailer.smtp_settings = { + address: "smtp.gmail.com", + port: 587, + domain: "gmail.com", + user_name: ENV["GMAIL_USER"], + password: ENV["GMAIL_PASSWORD"], + authentication: "plain", + enable_starttls_auto: true + } +end \ No newline at end of file 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..d0f0e3bd39 --- /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 = :memory_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..3c8b48f8a9 --- /dev/null +++ b/config/initializers/inflections.rb @@ -0,0 +1,5 @@ +ActiveSupport::Inflector.inflections(:en) do |inflect| + inflect.irregular "pergunta", "perguntas" + inflect.irregular "resposta", "respostas" + inflect.irregular "formulario", "formularios" +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..2e4f2f50aa --- /dev/null +++ b/config/routes.rb @@ -0,0 +1,42 @@ +Rails.application.routes.draw do + root "sessions#new" + + get "home/index" + + get "login", to: "sessions#new", as: "/" + post "login", to: "sessions#create" + delete "logout", to: "sessions#destroy", as: "logout" + + get "/home", to: "home#index", as: "home" + + get 'definir-senha/:token', to: 'password_sets#new', as: 'password_set' + patch 'definir-senha/:token', to: 'password_sets#update' + + resources :templates + + post "/sigaa/update", + to: "sigaa#update_database" + + namespace :admin do + resources :imports, only: [:index, :create] + get "management", to: "management#index", as: "management" + get "results", to: "results#index", as: "results" + resources :results, only: [:show] do + member { get :csv } + end + resources :templates, only: [:index, :create, :update, :destroy] + resources :forms, only: [:index, :create] + end + + get "avaliacoes", to: "avaliacoes#index", as: "avaliacoes" + get "avaliacoes/:id/detalhes", to: "avaliacoes#detalhes", as: "detalhes_avaliacao" + get "avaliacoes/:id", to: "avaliacoes#show", as: "avaliacao" + post "avaliacoes/:id/responder", to: "avaliacoes#responder", as: "responder_avaliacao" + + get "esqueci-senha", to: "password_resets#new", as: "new_password_reset" + post "esqueci-senha", to: "password_resets#create", as: "password_resets" + get "redefinir-senha/:token", to: "password_resets#edit", as: "password_reset" + patch "redefinir-senha/:token", to: "password_resets#update" + + get "/favicon.ico", to: proc { [204, {}, []] } +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/migrate/20260527135330_create_disciplinas.rb b/db/migrate/20260527135330_create_disciplinas.rb new file mode 100644 index 0000000000..47ea504471 --- /dev/null +++ b/db/migrate/20260527135330_create_disciplinas.rb @@ -0,0 +1,10 @@ +class CreateDisciplinas < ActiveRecord::Migration[8.1] + def change + create_table :disciplinas do |t| + t.string :code + t.string :name + + t.timestamps + end + end +end diff --git a/db/migrate/20260527135638_create_turmas.rb b/db/migrate/20260527135638_create_turmas.rb new file mode 100644 index 0000000000..535298f173 --- /dev/null +++ b/db/migrate/20260527135638_create_turmas.rb @@ -0,0 +1,12 @@ +class CreateTurmas < ActiveRecord::Migration[8.1] + def change + create_table :turmas do |t| + t.string :classCode + t.string :semester + t.string :time + t.references :disciplina, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/db/migrate/20260527135740_create_discentes.rb b/db/migrate/20260527135740_create_discentes.rb new file mode 100644 index 0000000000..ab22a43f00 --- /dev/null +++ b/db/migrate/20260527135740_create_discentes.rb @@ -0,0 +1,16 @@ +class CreateDiscentes < ActiveRecord::Migration[8.1] + def change + create_table :discentes do |t| + t.string :nome + t.string :curso + t.string :matricula + t.string :usuario + t.string :formacao + t.string :ocupacao + t.string :email + t.references :turma, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/db/migrate/20260527135834_create_docentes.rb b/db/migrate/20260527135834_create_docentes.rb new file mode 100644 index 0000000000..49b54a80aa --- /dev/null +++ b/db/migrate/20260527135834_create_docentes.rb @@ -0,0 +1,15 @@ +class CreateDocentes < ActiveRecord::Migration[8.1] + def change + create_table :docentes do |t| + t.string :nome + t.string :departamento + t.string :formacao + t.string :usuario + t.string :ocupacao + t.string :email + t.references :turma, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/db/migrate/20260527141115_create_users.rb b/db/migrate/20260527141115_create_users.rb new file mode 100644 index 0000000000..4072d68b91 --- /dev/null +++ b/db/migrate/20260527141115_create_users.rb @@ -0,0 +1,12 @@ +class CreateUsers < ActiveRecord::Migration[8.1] + def change + create_table :users do |t| + t.string :email + t.string :matricula + t.string :password_digest + t.string :role + + t.timestamps + end + end +end diff --git a/db/migrate/20260529221534_add_invitation_fields_to_users.rb b/db/migrate/20260529221534_add_invitation_fields_to_users.rb new file mode 100644 index 0000000000..1d1bbdd7fc --- /dev/null +++ b/db/migrate/20260529221534_add_invitation_fields_to_users.rb @@ -0,0 +1,6 @@ +class AddInvitationFieldsToUsers < ActiveRecord::Migration[8.1] + def change + add_column :users, :invitation_token, :string + add_column :users, :invitation_sent_at, :datetime + end +end diff --git a/db/migrate/20260601215521_add_reset_password_fields_to_users.rb b/db/migrate/20260601215521_add_reset_password_fields_to_users.rb new file mode 100644 index 0000000000..aacb096b0a --- /dev/null +++ b/db/migrate/20260601215521_add_reset_password_fields_to_users.rb @@ -0,0 +1,6 @@ +class AddResetPasswordFieldsToUsers < ActiveRecord::Migration[8.1] + def change + add_column :users, :reset_token, :string + add_column :users, :reset_sent_at, :datetime + end +end diff --git a/db/migrate/20260614123031_create_formularios.rb b/db/migrate/20260614123031_create_formularios.rb new file mode 100644 index 0000000000..6cd3cf557c --- /dev/null +++ b/db/migrate/20260614123031_create_formularios.rb @@ -0,0 +1,12 @@ +class CreateFormularios < ActiveRecord::Migration[8.1] + def change + create_table :formularios do |t| + t.string :titulo + t.text :descricao + t.datetime :deadline + t.references :turma, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/db/migrate/20260614123032_create_perguntas.rb b/db/migrate/20260614123032_create_perguntas.rb new file mode 100644 index 0000000000..352cf86607 --- /dev/null +++ b/db/migrate/20260614123032_create_perguntas.rb @@ -0,0 +1,12 @@ +class CreatePerguntas < ActiveRecord::Migration[8.1] + def change + create_table :perguntas do |t| + t.references :formulario, null: false, foreign_key: true + t.text :enunciado + t.string :tipo_pergunta + t.boolean :obrigatoria + + t.timestamps + end + end +end diff --git a/db/migrate/20260614123033_create_respostas.rb b/db/migrate/20260614123033_create_respostas.rb new file mode 100644 index 0000000000..f13d93461d --- /dev/null +++ b/db/migrate/20260614123033_create_respostas.rb @@ -0,0 +1,12 @@ +class CreateRespostas < ActiveRecord::Migration[8.1] + def change + create_table :respostas do |t| + t.references :formulario, null: false, foreign_key: true + t.references :user, null: false, foreign_key: true + t.references :pergunta, null: false, foreign_key: true + t.text :conteudo + + t.timestamps + end + end +end diff --git a/db/migrate/20260616190000_create_templates.rb b/db/migrate/20260616190000_create_templates.rb new file mode 100644 index 0000000000..984de0602b --- /dev/null +++ b/db/migrate/20260616190000_create_templates.rb @@ -0,0 +1,19 @@ +class CreateTemplates < ActiveRecord::Migration[7.0] + def change + create_table :templates do |t| + t.string :nome, null: false + t.text :descricao + + t.references :user, null: false, foreign_key: true + + # Template do qual este é uma nova versão + t.references :parent_template, + foreign_key: { to_table: :templates } + + # Número da versão + t.integer :version, default: 1, null: false + + t.timestamps + end + end +end \ No newline at end of file diff --git a/db/migrate/20260616193000_add_version_to_templates.rb b/db/migrate/20260616193000_add_version_to_templates.rb new file mode 100644 index 0000000000..16537e3923 --- /dev/null +++ b/db/migrate/20260616193000_add_version_to_templates.rb @@ -0,0 +1,8 @@ +class AddVersionToTemplates < ActiveRecord::Migration[7.0] + def change + unless column_exists?(:templates, :version) + add_column :templates, :version, :integer, default: 1, null: false + add_reference :templates, :parent_template, foreign_key: { to_table: :templates } + end + end +end \ No newline at end of file diff --git a/db/migrate/20260616200000_create_sigaa_logs.rb b/db/migrate/20260616200000_create_sigaa_logs.rb new file mode 100644 index 0000000000..4f92e27e1f --- /dev/null +++ b/db/migrate/20260616200000_create_sigaa_logs.rb @@ -0,0 +1,10 @@ +class CreateSigaaLogs < ActiveRecord::Migration[7.0] + def change + create_table :sigaa_logs do |t| + t.string :status + t.text :message + t.references :user, null: false, foreign_key: true + t.timestamps + end + end +end \ No newline at end of file diff --git a/db/migrate/20260617000001_add_nome_to_users.rb b/db/migrate/20260617000001_add_nome_to_users.rb new file mode 100644 index 0000000000..d08d1ee06e --- /dev/null +++ b/db/migrate/20260617000001_add_nome_to_users.rb @@ -0,0 +1,6 @@ +class AddNomeToUsers < ActiveRecord::Migration[8.1] + def change + add_column :users, :nome, :string + add_column :users, :departamento, :string + end +end diff --git a/db/migrate/20260617000002_add_nome_and_fields_to_turmas.rb b/db/migrate/20260617000002_add_nome_and_fields_to_turmas.rb new file mode 100644 index 0000000000..e0a8e29203 --- /dev/null +++ b/db/migrate/20260617000002_add_nome_and_fields_to_turmas.rb @@ -0,0 +1,5 @@ +class AddNomeAndFieldsToTurmas < ActiveRecord::Migration[8.1] + def change + add_column :turmas, :nome, :string + end +end diff --git a/db/migrate/20260617000003_add_semester_to_templates.rb b/db/migrate/20260617000003_add_semester_to_templates.rb new file mode 100644 index 0000000000..14815482ef --- /dev/null +++ b/db/migrate/20260617000003_add_semester_to_templates.rb @@ -0,0 +1,6 @@ +class AddSemesterToTemplates < ActiveRecord::Migration[8.1] + def change + add_column :templates, :semester, :string + change_column_null :templates, :user_id, true + end +end diff --git a/db/migrate/20260617000004_create_turma_memberships.rb b/db/migrate/20260617000004_create_turma_memberships.rb new file mode 100644 index 0000000000..4ffcda47ad --- /dev/null +++ b/db/migrate/20260617000004_create_turma_memberships.rb @@ -0,0 +1,10 @@ +class CreateTurmaMemberships < ActiveRecord::Migration[8.1] + def change + create_table :turma_memberships do |t| + t.references :turma, null: false, foreign_key: true + t.references :user, null: false, foreign_key: true + t.timestamps + end + add_index :turma_memberships, [:turma_id, :user_id], unique: true + end +end diff --git a/db/migrate/20260617000005_create_questions.rb b/db/migrate/20260617000005_create_questions.rb new file mode 100644 index 0000000000..9365bcd29e --- /dev/null +++ b/db/migrate/20260617000005_create_questions.rb @@ -0,0 +1,10 @@ +class CreateQuestions < ActiveRecord::Migration[8.1] + def change + create_table :questions do |t| + t.references :template, null: false, foreign_key: true + t.string :kind, null: false + t.text :text, null: false + t.timestamps + end + end +end diff --git a/db/migrate/20260617000006_create_forms.rb b/db/migrate/20260617000006_create_forms.rb new file mode 100644 index 0000000000..fde48d6b15 --- /dev/null +++ b/db/migrate/20260617000006_create_forms.rb @@ -0,0 +1,10 @@ +class CreateForms < ActiveRecord::Migration[8.1] + def change + create_table :forms do |t| + t.references :template, null: false, foreign_key: true + t.datetime :start_date + t.datetime :end_date + t.timestamps + end + end +end diff --git a/db/migrate/20260617000007_create_forms_turmas.rb b/db/migrate/20260617000007_create_forms_turmas.rb new file mode 100644 index 0000000000..e3e36b2ff7 --- /dev/null +++ b/db/migrate/20260617000007_create_forms_turmas.rb @@ -0,0 +1,8 @@ +class CreateFormsTurmas < ActiveRecord::Migration[8.1] + def change + create_join_table :forms, :turmas do |t| + t.index [:form_id, :turma_id], unique: true + t.index [:turma_id, :form_id] + end + end +end diff --git a/db/migrate/20260617000008_create_form_responses.rb b/db/migrate/20260617000008_create_form_responses.rb new file mode 100644 index 0000000000..c84d02a2fb --- /dev/null +++ b/db/migrate/20260617000008_create_form_responses.rb @@ -0,0 +1,11 @@ +class CreateFormResponses < ActiveRecord::Migration[8.1] + def change + create_table :form_responses do |t| + t.references :form, null: false, foreign_key: true + t.references :user, null: false, foreign_key: true + t.references :turma, null: false, foreign_key: true + t.text :answers + t.timestamps + end + end +end diff --git a/db/migrate/20260630120000_consolidate_form_systems.rb b/db/migrate/20260630120000_consolidate_form_systems.rb new file mode 100644 index 0000000000..26a11bbf9d --- /dev/null +++ b/db/migrate/20260630120000_consolidate_form_systems.rb @@ -0,0 +1,170 @@ +# Consolida os dois sistemas paralelos de formulário existentes no projeto +# (Formulario/Pergunta/Resposta x Form/Template/Question/FormResponse) +# em um único sistema: Form/Template/Question/FormResponse. +# +# O que esta migração faz: +# 1. Adiciona as colunas que faltavam no sistema "vencedor" para cobrir +# regras de negócio que só existiam no sistema antigo: +# - questions.obrigatoria (Pergunta#obrigatoria) +# - templates.status (rascunho/publicado — feature #5) +# - forms.target_audience (público-alvo — feature #16) +# 2. Migra os dados existentes de formularios/perguntas/respostas para +# templates/forms/questions/form_responses, preservando o conteúdo. +# 3. Remove as tabelas antigas (formularios, perguntas, respostas). +# +# Esta migração é destrutiva (remove tabelas) e por isso não é reversível +# de forma automática — ver método `down`. +class ConsolidateFormSystems < ActiveRecord::Migration[8.1] + def up + add_new_columns + migrate_legacy_data + drop_legacy_tables + end + + def down + raise ActiveRecord::IrreversibleMigration, + "Esta migração consolida dois sistemas e remove tabelas legadas; " \ + "não é possível desfazer automaticamente. Restaure a partir de um backup, se necessário." + end + + private + + # Passo 1 — novas colunas no sistema consolidado + def add_new_columns + add_column :questions, :obrigatoria, :boolean, default: false, null: false unless column_exists?(:questions, :obrigatoria) + add_column :templates, :status, :string, default: "rascunho", null: false unless column_exists?(:templates, :status) + add_column :forms, :target_audience, :string, default: "ambos", null: false unless column_exists?(:forms, :target_audience) + end + + # Passo 2 — copia os dados de formularios/perguntas/respostas para o + # sistema novo, criando um Template "técnico" para cada Formulario + # (já que Form exige um template_id, e Formulario não tinha essa noção). + def migrate_legacy_data + return unless table_exists?(:formularios) + + formulario_rows = select_all("SELECT * FROM formularios") + return if formulario_rows.empty? + + formulario_rows.each do |formulario| + template_id = create_legacy_template(formulario) + pergunta_id_map = create_legacy_questions(formulario, template_id) + form_id = create_legacy_form(formulario, template_id) + migrate_legacy_respostas(formulario, form_id, pergunta_id_map) + end + end + + # Cria um Template equivalente ao Formulario legado (1 para 1), já + # publicado, pois o Formulario antigo não tinha conceito de rascunho. + def create_legacy_template(formulario) + execute <<~SQL + INSERT INTO templates (nome, descricao, status, version, created_at, updated_at) + VALUES ( + #{quote("[Migrado] #{formulario['titulo']}")}, + #{quote(formulario["descricao"])}, + #{quote("publicado")}, + 1, + #{quote(formulario["created_at"])}, + #{quote(formulario["updated_at"])} + ) + SQL + select_value("SELECT last_insert_rowid()") + end + + # Migra as Perguntas do formulario para Questions do novo template. + # Retorna um hash { pergunta_id_antigo => question_id_novo } para + # permitir remapear as Respostas no passo seguinte. + def create_legacy_questions(formulario, template_id) + perguntas = select_all("SELECT * FROM perguntas WHERE formulario_id = #{formulario['id'].to_i}") + id_map = {} + + perguntas.each do |pergunta| + execute <<~SQL + INSERT INTO questions (template_id, kind, text, obrigatoria, created_at, updated_at) + VALUES ( + #{template_id}, + #{quote(map_tipo_pergunta(pergunta["tipo_pergunta"]))}, + #{quote(pergunta["enunciado"])}, + #{pergunta["obrigatoria"] ? 1 : 0}, + #{quote(pergunta["created_at"])}, + #{quote(pergunta["updated_at"])} + ) + SQL + id_map[pergunta["id"]] = select_value("SELECT last_insert_rowid()") + end + + id_map + end + + # Mapeia o tipo_pergunta antigo (texto/multipla_escolha/escala) para o + # kind novo. "escala" não existia em Question — vira um kind próprio, + # que precisa ser adicionado à validação do model (ver observação ao final). + def map_tipo_pergunta(tipo) + case tipo + when "multipla_escolha" then "radio" + when "escala" then "escala" + else "text" + end + end + + # Cria o Form equivalente, associado à turma do Formulario antigo. + # target_audience fica como "discente", já que o sistema antigo só + # era usado para o fluxo de resposta dos discentes. + def create_legacy_form(formulario, template_id) + execute <<~SQL + INSERT INTO forms (template_id, start_date, end_date, target_audience, created_at, updated_at) + VALUES ( + #{template_id}, + #{quote(formulario["created_at"])}, + #{quote(formulario["deadline"])}, + #{quote("discente")}, + #{quote(formulario["created_at"])}, + #{quote(formulario["updated_at"])} + ) + SQL + form_id = select_value("SELECT last_insert_rowid()") + + execute <<~SQL + INSERT INTO forms_turmas (form_id, turma_id) + VALUES (#{form_id}, #{formulario["turma_id"].to_i}) + SQL + + form_id + end + + # Agrupa as Respostas antigas por usuário e cria um FormResponse por + # (form, user), com answers serializado em JSON, no formato já usado + # por FormResponse#answers em todo o restante do sistema. + def migrate_legacy_respostas(formulario, form_id, pergunta_id_map) + respostas = select_all("SELECT * FROM respostas WHERE formulario_id = #{formulario['id'].to_i}") + respostas.group_by { |r| r["user_id"] }.each do |user_id, user_respostas| + answers = user_respostas.map do |r| + { + "question_id" => pergunta_id_map[r["pergunta_id"]], + "value" => r["conteudo"] + } + end + + created_at = user_respostas.map { |r| r["created_at"] }.min + updated_at = user_respostas.map { |r| r["updated_at"] }.max + + execute <<~SQL + INSERT INTO form_responses (form_id, user_id, turma_id, answers, created_at, updated_at) + VALUES ( + #{form_id}, + #{user_id.to_i}, + #{formulario["turma_id"].to_i}, + #{quote(answers.to_json)}, + #{quote(created_at)}, + #{quote(updated_at)} + ) + SQL + end + end + + # Passo 3 — remove as tabelas do sistema legado + def drop_legacy_tables + drop_table :respostas, if_exists: true + drop_table :perguntas, if_exists: true + drop_table :formularios, if_exists: true + end +end diff --git a/db/migrate/20260630130000_add_options_to_questions.rb b/db/migrate/20260630130000_add_options_to_questions.rb new file mode 100644 index 0000000000..45edcfb286 --- /dev/null +++ b/db/migrate/20260630130000_add_options_to_questions.rb @@ -0,0 +1,5 @@ +class AddOptionsToQuestions < ActiveRecord::Migration[8.1] + def change + add_column :questions, :options, :text + end +end diff --git a/db/migrate/20260630140000_add_obrigatoria_to_questions.rb b/db/migrate/20260630140000_add_obrigatoria_to_questions.rb new file mode 100644 index 0000000000..c523c342fb --- /dev/null +++ b/db/migrate/20260630140000_add_obrigatoria_to_questions.rb @@ -0,0 +1,5 @@ +class AddObrigatoriaToQuestions < ActiveRecord::Migration[8.1] + def change + add_column :questions, :obrigatoria, :boolean, default: false, null: false + end +end diff --git a/db/migrate/20260630150000_add_destinatario_to_forms.rb b/db/migrate/20260630150000_add_destinatario_to_forms.rb new file mode 100644 index 0000000000..2f2d14871b --- /dev/null +++ b/db/migrate/20260630150000_add_destinatario_to_forms.rb @@ -0,0 +1,5 @@ +class AddDestinatarioToForms < ActiveRecord::Migration[8.1] + def change + add_column :forms, :destinatario, :string, default: "discente", null: false + 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..4245c2d373 --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,197 @@ +# 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: 2026_06_30_150000) do + create_table "discentes", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "curso" + t.string "email" + t.string "formacao" + t.string "matricula" + t.string "nome" + t.string "ocupacao" + t.integer "turma_id", null: false + t.datetime "updated_at", null: false + t.string "usuario" + t.index ["turma_id"], name: "index_discentes_on_turma_id" + end + + create_table "disciplinas", force: :cascade do |t| + t.string "code" + t.datetime "created_at", null: false + t.string "name" + t.datetime "updated_at", null: false + end + + create_table "docentes", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "departamento" + t.string "email" + t.string "formacao" + t.string "nome" + t.string "ocupacao" + t.integer "turma_id", null: false + t.datetime "updated_at", null: false + t.string "usuario" + t.index ["turma_id"], name: "index_docentes_on_turma_id" + end + + create_table "form_responses", force: :cascade do |t| + t.text "answers" + t.datetime "created_at", null: false + t.integer "form_id", null: false + t.integer "turma_id", null: false + t.datetime "updated_at", null: false + t.integer "user_id", null: false + t.index ["form_id"], name: "index_form_responses_on_form_id" + t.index ["turma_id"], name: "index_form_responses_on_turma_id" + t.index ["user_id"], name: "index_form_responses_on_user_id" + end + + create_table "forms", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "destinatario", default: "discente", null: false + t.datetime "end_date" + t.datetime "start_date" + t.integer "template_id", null: false + t.datetime "updated_at", null: false + t.index ["template_id"], name: "index_forms_on_template_id" + end + + create_table "forms_turmas", id: false, force: :cascade do |t| + t.integer "form_id", null: false + t.integer "turma_id", null: false + t.index ["form_id", "turma_id"], name: "index_forms_turmas_on_form_id_and_turma_id", unique: true + t.index ["turma_id", "form_id"], name: "index_forms_turmas_on_turma_id_and_form_id" + end + + create_table "formularios", force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "deadline" + t.text "descricao" + t.string "destinatario", default: "discente", null: false + t.string "titulo" + t.integer "turma_id", null: false + t.datetime "updated_at", null: false + end + + create_table "perguntas", force: :cascade do |t| + t.datetime "created_at", null: false + t.text "enunciado" + t.integer "formulario_id", null: false + t.boolean "obrigatoria" + t.string "tipo_pergunta" + t.datetime "updated_at", null: false + t.index ["formulario_id"], name: "index_perguntas_on_formulario_id" + end + + create_table "questions", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "kind", null: false + t.boolean "obrigatoria", default: false, null: false + t.text "options" + t.integer "template_id", null: false + t.text "text", null: false + t.datetime "updated_at", null: false + t.index ["template_id"], name: "index_questions_on_template_id" + end + + create_table "respostas", force: :cascade do |t| + t.text "conteudo" + t.datetime "created_at", null: false + t.integer "formulario_id", null: false + t.integer "pergunta_id", null: false + t.string "status", default: "submitted", null: false + t.datetime "updated_at", null: false + t.integer "user_id", null: false + t.index ["formulario_id"], name: "index_respostas_on_formulario_id" + t.index ["pergunta_id"], name: "index_respostas_on_pergunta_id" + t.index ["user_id"], name: "index_respostas_on_user_id" + end + + create_table "sigaa_logs", force: :cascade do |t| + t.datetime "created_at", null: false + t.text "message" + t.string "status" + t.datetime "updated_at", null: false + t.integer "user_id", null: false + t.index ["user_id"], name: "index_sigaa_logs_on_user_id" + end + + create_table "templates", force: :cascade do |t| + t.datetime "created_at", null: false + t.text "descricao" + t.string "nome", null: false + t.integer "parent_template_id" + t.string "semester" + t.datetime "updated_at", null: false + t.integer "user_id" + t.integer "version", default: 1, null: false + t.index ["parent_template_id"], name: "index_templates_on_parent_template_id" + t.index ["user_id"], name: "index_templates_on_user_id" + end + + create_table "turma_memberships", force: :cascade do |t| + t.datetime "created_at", null: false + t.integer "turma_id", null: false + t.datetime "updated_at", null: false + t.integer "user_id", null: false + t.index ["turma_id", "user_id"], name: "index_turma_memberships_on_turma_id_and_user_id", unique: true + t.index ["turma_id"], name: "index_turma_memberships_on_turma_id" + t.index ["user_id"], name: "index_turma_memberships_on_user_id" + end + + create_table "turmas", force: :cascade do |t| + t.string "classCode" + t.datetime "created_at", null: false + t.integer "disciplina_id", null: false + t.string "nome" + t.string "semester" + t.string "time" + t.datetime "updated_at", null: false + t.index ["disciplina_id"], name: "index_turmas_on_disciplina_id" + end + + create_table "users", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "departamento" + t.string "email" + t.datetime "invitation_sent_at" + t.string "invitation_token" + t.string "matricula" + t.string "nome" + t.string "password_digest" + t.datetime "reset_sent_at" + t.string "reset_token" + t.string "role" + t.datetime "updated_at", null: false + end + + add_foreign_key "discentes", "turmas" + add_foreign_key "docentes", "turmas" + add_foreign_key "form_responses", "forms" + add_foreign_key "form_responses", "turmas" + add_foreign_key "form_responses", "users" + add_foreign_key "forms", "templates" + add_foreign_key "formularios", "turmas" + add_foreign_key "perguntas", "formularios_old", column: "formulario_id" + add_foreign_key "questions", "templates" + add_foreign_key "respostas", "formularios_old", column: "formulario_id" + add_foreign_key "respostas", "perguntas" + add_foreign_key "respostas", "users" + add_foreign_key "sigaa_logs", "users" + add_foreign_key "templates", "templates", column: "parent_template_id" + add_foreign_key "templates", "users" + add_foreign_key "turma_memberships", "turmas" + add_foreign_key "turma_memberships", "users" + add_foreign_key "turmas", "disciplinas" +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/env.example b/env.example new file mode 100644 index 0000000000..a2c4b2e0a4 --- /dev/null +++ b/env.example @@ -0,0 +1,9 @@ +# Copie este arquivo para .env e preencha com suas credenciais reais. +# O arquivo .env NUNCA deve ser commitado no git. + +# Conta Gmail usada para envio de emails do sistema +GMAIL_USER=seu_email@gmail.com + +# Senha de App do Gmail (não é a senha da sua conta Google!) +# Gere em: https://myaccount.google.com/apppasswords +GMAIL_PASSWORD=xxxx xxxx xxxx xxxx \ No newline at end of file diff --git a/features/.DS_Store b/features/.DS_Store new file mode 100644 index 0000000000..b9e4ee01a0 Binary files /dev/null and b/features/.DS_Store differ diff --git a/features/atualizar_base_com_sigaa.feature b/features/atualizar_base_com_sigaa.feature new file mode 100644 index 0000000000..3bfcdda06a --- /dev/null +++ b/features/atualizar_base_com_sigaa.feature @@ -0,0 +1,65 @@ +# language: pt + +Funcionalidade: Atualização da base de dados com o SIGAA + + 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 estou logado como administrador + E o sistema possui integração ativa com o SIGAA + + Cenário: Atualização da base de dados com sucesso + Quando solicito a atualização dos dados a partir do SIGAA + E o SIGAA retorna dados válidos + Então a base de dados do sistema deve ser atualizada com os novos dados + E devo ver uma mensagem de sucesso na atualização + + Cenário: SIGAA retorna dados inconsistentes + Quando solicito a atualização dos dados a partir do SIGAA + E o SIGAA retorna dados inconsistentes + Então a atualização não deve ser concluída + E devo ver uma mensagem de erro informando inconsistência de dados + + Cenário: Falha de conexão com o SIGAA + Quando solicito a atualização dos dados a partir do SIGAA + E não há conexão com o SIGAA + Então a atualização não deve ser realizada + E devo ver uma mensagem informando falha de comunicação + + Cenário: Usuário sem permissão tenta atualizar base + Dado que estou logado como usuário comum + Quando tento solicitar a atualização da base de dados + Então devo ser impedido de realizar a ação + E devo ver uma mensagem de acesso negado + + Cenário: Atualização parcial dos dados + Quando solicito a atualização dos dados a partir do SIGAA + E o SIGAA retorna parcialmente os dados com sucesso + Então o sistema deve atualizar apenas os dados válidos + E deve registrar os dados que falharam na atualização + + Cenário: Evitar atualização duplicada da base de dados + Dado que já existe uma atualização em andamento + Quando solicito uma nova atualização dos dados a partir do SIGAA + Então devo ser impedido de iniciar outra atualização + E devo ver uma mensagem informando que já existe um processo em execução + + Cenário: Reprocessar atualização após falha + Dado que uma atualização anterior falhou + Quando solicito uma nova tentativa de atualização + Então o sistema deve reprocessar os dados do SIGAA + E a base de dados deve ser atualizada com sucesso caso os dados sejam válidos + + Cenário: Atualização sem dados retornados pelo SIGAA + Quando solicito a atualização dos dados a partir do SIGAA + E o SIGAA não retorna nenhum dado + Então a atualização não deve ser realizada + E devo ver uma mensagem informando ausência de dados + + Cenário: Registro de auditoria da atualização + Quando solicito a atualização dos dados a partir do SIGAA + E a atualização é executada + Então o sistema deve registrar um log da atualização + E o log deve conter data, usuário responsável e resultado da operação \ No newline at end of file diff --git a/features/cadastrar_usuarios.feature b/features/cadastrar_usuarios.feature new file mode 100644 index 0000000000..299c141486 --- /dev/null +++ b/features/cadastrar_usuarios.feature @@ -0,0 +1,56 @@ +# language: pt + +Funcionalidade: Cadastrar usuários do sistema + Como Administrador + Quero cadastrar participantes de turmas do SIGAA ao importar dados de usuários novos para o sistema + A fim de que eles acessem o sistema CAMAAR + + Contexto: + Dado que estou logado como Administrador de teste + E estou na página de "Gerenciamento - Usuários" + + # ==================== CENÁRIOS FELIZES ==================== + + Cenário: Solicitação de definição de senha para novo usuário importado + Dado que importei o arquivo "class_members.json" + E o sistema identificou the novo usuário with matrícula "190084006" + Quando o usuário "190084006" acessa a página de login + E preencho o campo de cadastro "Email ou Matrícula" com "190084006" + E deixo o campo de cadastro "Senha" em branco + E clico no botão de cadastro "Entrar" + Então sou redirecionado para a página de cadastro "Definição de Senha" + E vejo a mensagem de cadastro "Este é seu primeiro acesso. Por favor, defina uma senha." + + Cenário: Efetivação do cadastro após definição de senha + Dado que o usuário "190084006" está na página "Definição de Senha" + Quando preencho o campo de cadastro "Nova Senha" com "senhaSegura123" + E preencho o campo de cadastro "Confirmar Senha" com "senhaSegura123" + E clico no botão de cadastro "Definir Senha" + Então sou redirecionado para a página de cadastro "painel principal" + E vejo a mensagem de sucesso de cadastro "Cadastro efetivado com sucesso!" + E meu status no sistema passa a ser "Ativo" + + # ==================== CENÁRIOS TRISTES ==================== + Cenário: Tentativa de definição de senha com campos divergentes + Dado que o usuário "190084006" está na página "Definição de Senha" + Quando preencho o campo de cadastro "Nova Senha" com "senha123" + E preencho o campo de cadastro "Confirmar Senha" com "senha456" + E clico no botão de cadastro "Definir Senha" + Então permaneço na página de cadastro "Definição de Senha" + E vejo a mensagem de erro de cadastro "As senhas não coincidem" + + Cenário: Tentativa de definição de senha muito curta + Dado que o usuário "190084006" está na página "Definição de Senha" + Quando preencho o campo de cadastro "Nova Senha" com "123" + E preencho o campo de cadastro "Confirmar Senha" com "123" + E clico no botão de cadastro "Definir Senha" + Então permaneço na página de cadastro "Definição de Senha" + E vejo a mensagem de erro de cadastro "A senha deve ter no mínimo 8 caracteres" + + + Cenário: Tentativa de acesso sem definir senha após importação + Dado que o usuário "190084006" foi importado mas não definiu senha + Quando tento acessar a página "Avaliações" diretamente + Então sou redirecionado para a página de cadastro "Definição de Senha" + E vejo o alerta de cadastro "Este é seu primeiro acesso. Por favor, defina uma senha." + diff --git a/features/criacao_form_para_dic_doc.feature b/features/criacao_form_para_dic_doc.feature new file mode 100644 index 0000000000..41c2f592b1 --- /dev/null +++ b/features/criacao_form_para_dic_doc.feature @@ -0,0 +1,131 @@ +# language: pt + +@javascript +Funcionalidade: Criar formulário para Docentes e Dicentes + Como um Administrador + 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 + + Contexto: + Dado que existem turmas e templates para criação de formulários + E que estou logado como Administrador + E estou na página "Gerenciamento" + E clico no botão "Criar Formulário" + Então um modal é exibido com os campos "Tipo de Destinatário", "Template", "Turma(s)" e data de disponibilidade + + # ==================== CENÁRIOS FELIZES ==================== + + Cenário: Criar formulário para dicentes de uma turma + Quando seleciono "Dicentes" no campo "Tipo de Destinatário" + E seleciono o template "Avaliação Engenharia de Software" no dropdown "Template" + E seleciono a turma "CIC0105" no campo "Turma(s)" + E preencho a data de início com "26/05/2026" + E preencho a data de término com "01/06/2026" + E clico no botão "Criar" + Então o modal é fechado + E vejo a mensagem de sucesso "Formulário criado com sucesso para dicentes" + E o formulário fica disponível para os dicentes da turma "CIC0105" + + Cenário: Criar formulário para docentes de uma turma + Quando seleciono "Docentes" no campo "Tipo de Destinatário" + E seleciono o template "Avaliação de Didática" no dropdown "Template" + E seleciono a turma "CIC0105" no campo "Turma(s)" + E preencho a data de início com "26/05/2026" + E preencho a data de término com "01/06/2026" + E clico no botão "Criar" + Então o modal é fechado + E vejo a mensagem de sucesso "Formulário criado com sucesso para docentes" + E o formulário fica disponível para os docentes da turma "CIC0105" + + Cenário: Criar formulário para múltiplas turmas simultaneamente + Quando seleciono "Dicentes" no campo "Tipo de Destinatário" + E seleciono o template "Avaliação Engenharia de Software" no dropdown "Template" + E seleciono a turma "CIC0105" no campo "Turma(s)" + E clico no campo "Turma(s)" novamente para adicionar mais turmas + E seleciono a turma "CIC0202" no campo "Turma(s)" + E preencho a data de início com "26/05/2026" + E preencho a data de término com "01/06/2026" + E clico no botão "Criar" + Então o modal é fechado + E vejo a mensagem de sucesso "Formulário criado com sucesso para 2 turmas" + E o formulário fica disponível para os dicentes das turmas "CIC0105" e "CIC0202" + + Cenário: Visualizar formulário criado na lista de formulários ativos + Dado que criei um formulário para dicentes da turma "CIC0105" com o template "Avaliação Engenharia de Software" + Quando acesso a página "Gerenciamento - Formulários Ativos" + Então vejo na tabela uma linha com os dados do formulário criado + E a linha contém "Dicentes", "CIC0105", "Avaliação Engenharia de Software" e as datas de vigência + + Cenário: Dicente visualiza formulário criado no painel de Avaliações + Dado que um formulário foi criado para dicentes da turma "CIC0105" + Quando um Dicente matriculado na turma "CIC0105" acessa o painel de Avaliações + Então o dicente visualiza o card do formulário dentro do período de vigência + E o card exibe o nome do template e a data de término + + Cenário: Docente visualiza formulário criado no painel de Avaliações + Dado que um formulário foi criado para docentes da turma "CIC0105" + Quando um Docente vinculado à turma "CIC0105" acessa o painel de Avaliações + Então o docente visualiza o card do formulário dentro do período de vigência + E o card exibe o nome do template e a data de término + + # ==================== CENÁRIOS TRISTES ==================== + + Cenário: Tentativa de criar formulário sem selecionar tipo de destinatário + Quando deixo o campo de opções "Tipo de Destinatário" em branco + E seleciono o template "Avaliação Engenharia de Software" no dropdown "Template" + E seleciono a turma "CIC0105" no campo "Turma(s)" + E preencho as datas de vigência + E clico no botão "Criar" + Então o modal permanece aberto + E vejo a mensagem de erro "Selecione o tipo de destinatário (Dicentes ou Docentes)" + + Cenário: Tentativa de criar formulário sem selecionar template + Quando seleciono "Dicentes" no campo "Tipo de Destinatário" + E não seleciono nenhum template no dropdown "Template" + E seleciono a turma "CIC0105" no campo "Turma(s)" + E preencho as datas de vigência + E clico no botão "Criar" + Então o modal permanece aberto + E vejo a mensagem de erro "Selecione um template para o formulário" + + Cenário: Tentativa de criar formulário sem selecionar nenhuma turma + Quando seleciono "Dicentes" no campo "Tipo de Destinatário" + E seleciono o template "Avaliação Engenharia de Software" no dropdown "Template" + E não seleciono nenhuma turma no campo "Turma(s)" + E preencho as datas de vigência + E clico no botão "Criar" + Então o modal permanece aberto + E vejo a mensagem de erro "Selecione ao menos uma turma" + + Cenário: Tentativa de criar formulário com data de término anterior à data de início + Quando seleciono "Dicentes" no campo "Tipo de Destinatário" + E seleciono o template "Avaliação Engenharia de Software" no dropdown "Template" + E seleciono a turma "CIC0105" no campo "Turma(s)" + E preencho a data de início com "01/06/2026" + E preencho a data de término com "26/05/2026" + E clico no botão "Criar" + Então o modal permanece aberto + E vejo a mensagem de erro "A data de término não pode ser anterior à data de início" + + Cenário: Tentativa de criar formulário sem preencher as datas de vigência + Quando seleciono "Dicentes" no campo "Tipo de Destinatário" + E seleciono o template "Avaliação Engenharia de Software" no dropdown "Template" + E seleciono a turma "CIC0105" no campo "Turma(s)" + E deixo os campos de data em branco + E clico no botão "Criar" + Então o modal permanece aberto + E vejo a mensagem de erro "As datas de vigência são obrigatórias" + + Cenário: Tentativa de acesso à criação de formulários por usuário não administrador + Dado que estou logado como Dicente + Quando tento acessar a página "Gerenciamento" + Então sou redirecionado para o painel do usuário + E vejo a mensagem de erro "Acesso não autorizado" + + Cenário: Nenhuma turma disponível para criação de formulário + Dado que não existem turmas cadastradas no sistema + Quando clico no botão "Criar Formulário" + Então o modal é exibido + E o campo "Turma(s)" está vazio + E vejo a mensagem "Nenhuma turma disponível" + E o botão "Criar" está desabilitado diff --git a/features/def_senha.feature b/features/def_senha.feature new file mode 100644 index 0000000000..3a43f9c8a3 --- /dev/null +++ b/features/def_senha.feature @@ -0,0 +1,48 @@ +# language: pt + +Funcionalidade: Definir senha a partir do e-mail de solicitação de cadastro + Como um usuário + 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 recebi um e-mail de solicitação de cadastro no endereço "user@gmail.com" + E cliquei no link de definição de senha contido no e-mail + + # ==================== CENÁRIOS FELIZES ==================== + + Cenário: Definição de senha com sucesso + Quando preencho o campo "Nova Senha" com "senhaUser" + E preencho o campo "Confirmar Senha" com "senhaUser" + E clico no botão "Definir Senha" + Então sou redirecionado para o painel principal + + # ==================== CENÁRIOS TRISTES ==================== + + Cenário: Senhas digitadas não coincidem + Quando preencho o campo "Nova Senha" com "senhaUser" + E preencho o campo "Confirmar Senha" com "senhaUserErrada" + E clico no botão "Definir Senha" + Então permaneço na página de definição de senha + E vejo a mensagem de erro "As senhas não coincidem" + + Cenário: Senha não atende aos requisitos mínimos + Quando preencho o campo "Nova Senha" com "senha" + E preencho o campo "Confirmar Senha" com "senha" + E clico no botão "Definir Senha" + Então permaneço na página de definição de senha + E vejo a mensagem de erro "A senha deve ter no mínimo 8 caracteres" + + Cenário: Campos de senha deixados em branco + Quando deixo o campo "Nova Senha" em branco + E deixo o campo "Confirmar Senha" em branco + E clico no botão "Definir Senha" + Então permaneço na página de definição de senha + E vejo a mensagem de erro "Preencha todos os campos obrigatórios" + + Cenário: Link de definição de senha expirado + Dado que o link de definição de senha foi enviado há mais de 24 horas + Quando preencho o campo "Nova Senha" com "senhaUser" + E preencho o campo "Confirmar Senha" com "senhaUser" + E clico no botão "Definir Senha" + Então vejo a mensagem de erro "Este link expirou. Solicite um novo cadastro." diff --git a/features/edicao_delecao_templates.feature b/features/edicao_delecao_templates.feature new file mode 100644 index 0000000000..4a9cd5882f --- /dev/null +++ b/features/edicao_delecao_templates.feature @@ -0,0 +1,87 @@ +# language: pt + +Funcionalidade: Edição e deleção de templates + + Como administrador + Quero editar e/ou deletar templates que eu criei sem afetar os formulários já criados + A fim de organizar os templates existentes + + Contexto: + Dado que estou logado como administrador + E existem templates cadastrados no sistema + + Cenário: Editar template gera nova versão sem afetar formulários existentes + Dado que existe um template criado + E existem formulários já criados baseados na versão atual do template + Quando eu edito o template + E salvo as alterações + Então uma nova versão do template deve ser criada + E a versão anterior deve ser preservada + E os formulários já criados devem permanecer vinculados à versão antiga + + Cenário: Excluir template com sucesso + Dado que existe um template criado + E o template não está em uso por formulários + Quando solicito a exclusão do template + E confirmo a exclusão do template + Então o template deve ser removido com sucesso + + Cenário: Cancelar exclusão de template + Dado que existe um template criado + Quando solicito a exclusão do template + Então devo ser solicitado a confirmar a exclusão + Quando cancelo a exclusão + Então o template não deve ser removido + + Cenário: Excluir template inexistente + Quando tento excluir um template que não existe + Então devo ver uma mensagem de erro informando que o template não foi encontrado + + Cenário: Editar template inexistente + Quando tento editar um template que não existe + Então devo ver uma mensagem de erro informando que o template não foi encontrado + + Cenário: Excluir template em uso por formulários + Dado que existe um template criado + E existem formulários baseados nesse template + Quando tento excluir o template + Então devo ser impedido de excluir + E devo ver uma mensagem informando que o template está em uso + + Cenário: Editar template com dados inválidos + Dado que existe um template criado + Quando tento salvar o template com dados inválidos + Então devo ver mensagens de validação + E nenhuma nova versão deve ser criada + + Cenário: Editar template com campo obrigatório vazio + Dado que existe um template criado + Quando tento salvar o template com o campo nome vazio + Então devo ver mensagem de erro indicando campo obrigatório + E nenhuma nova versão deve ser criada + + Cenário: Usuário sem permissão tenta editar template + Dado que estou logado como usuário comum + Quando tento editar um template + Então devo ser impedido de realizar a ação + E devo ver uma mensagem de acesso negado + + Cenário: Usuário sem permissão tenta excluir template + Dado que estou logado como usuário comum + Quando tento excluir um template + Então devo ser impedido de realizar a ação + E devo ver uma mensagem de acesso negado + + Cenário: Registrar histórico de versões do template + Dado que existe um template criado + Quando eu edito o template + E salvo as alterações + Então a criação da nova versão deve ser registrada no histórico + E deve conter usuário e data da alteração + + Cenário: Editar template com múltiplos formulários vinculados + Dado que existe um template com muitos formulários vinculados + Quando eu edito o template + E salvo as alterações + Então a nova versão deve ser criada com sucesso + E os formulários devem permanecer vinculados à versão anterior \ No newline at end of file diff --git a/features/form_avaliacao_admin.feature b/features/form_avaliacao_admin.feature new file mode 100644 index 0000000000..dea260fe34 --- /dev/null +++ b/features/form_avaliacao_admin.feature @@ -0,0 +1,90 @@ +# language: pt + +@javascript +Funcionalidade: Gerar formulário de avaliação + Como um 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 existem turmas e templates para criação de formulários + E que estou logado como Administrador + E estou na página "Gerenciamento" + E clico no botão "Criar Formulário" + Então um modal é exibido com os campos "Tipo de Destinatário", "Template", "Turma(s)" e data de disponibilidade + + # ==================== CENÁRIOS FELIZES ==================== + + Cenário: Visualizar modal de criação de formulário com turmas disponíveis + Então o modal exibe o campo "Template" com um dropdown + E o modal exibe o campo "Turma(s)" com um seletor de múltipla escolha + E vejo o botão "Criar" + + Cenário: Enviar formulário para uma turma selecionada + Quando seleciono "Dicentes" no campo "Tipo de Destinatário" + E seleciono o template "Avaliação Engenharia de Software" no dropdown "Template" + E seleciono a turma "CIC0105" no campo "Turma(s)" + E preencho a data de início com "26/05/2026" + E preencho a data de término com "01/06/2026" + E clico no botão "Criar" + Então o modal é fechado + E vejo a mensagem de sucesso "Formulário criado com sucesso para dicentes" + E o formulário fica disponível para os discentes da turma "CIC0105" + + Cenário: Enviar formulário para múltiplas turmas simultaneamente + Quando seleciono "Dicentes" no campo "Tipo de Destinatário" + E seleciono o template "Avaliação Engenharia de Software" no dropdown "Template" + E seleciono a turma "CIC0105" no campo "Turma(s)" + E seleciono a turma "CIC0202" no campo "Turma(s)" + E preencho a data de início com "26/05/2026" + E preencho a data de término com "01/06/2026" + E clico no botão "Criar" + Então o modal é fechado + E vejo a mensagem de sucesso "Formulário criado com sucesso para 2 turmas" + E o formulário fica disponível para os discentes das duas turmas selecionadas + + Cenário: Formulário enviado aparece disponível para os discentes da turma + Dado que o formulário baseado no template "Avaliação Engenharia de Software" foi enviado para a turma "CIC0105" + Quando um Discente matriculado na turma "CIC0105" acessa o painel de Avaliações + Então o discente visualiza o card da turma "CIC0105" disponível para resposta + + Cenário: Discente responde o formulário enviado pelo administrador + Dado que o formulário foi enviado para a turma "CIC0105" + E o Discente acessou o card da turma "CIC0105" + Quando o Discente responde as questões exibidas e clica no botão de envio + Então a resposta é registrada no sistema + E o resultado fica disponível na tela "Gerenciamento - Resultados" do Administrador + + # ==================== CENÁRIOS TRISTES ==================== + + Cenário: Tentativa de enviar formulário sem selecionar um template + Quando seleciono "Dicentes" no campo "Tipo de Destinatário" + E não seleciono nenhum template no dropdown "Template" + E seleciono a turma "CIC0105" no campo "Turma(s)" + E preencho as datas de vigência + E clico no botão "Criar" + Então o modal permanece aberto + E vejo a mensagem de erro "Selecione um template para o formulário" + + Cenário: Tentativa de enviar formulário sem selecionar nenhuma turma + Quando seleciono "Dicentes" no campo "Tipo de Destinatário" + E seleciono o template "Avaliação Engenharia de Software" no dropdown "Template" + E não seleciono nenhuma turma no campo "Turma(s)" + E preencho as datas de vigência + E clico no botão "Criar" + Então o modal permanece aberto + E vejo a mensagem de erro "Selecione ao menos uma turma" + + Cenário: Nenhuma turma cadastrada exibida no campo do modal + Dado que não existem turmas cadastradas no sistema + Quando clico no botão "Criar Formulário" + Então o modal é exibido + E o campo "Turma(s)" está vazio + E vejo a mensagem "Nenhuma turma disponível" + E o botão "Criar" está desabilitado + + Cenário: Tentativa de acesso à criação de formulários por usuário não administrador + Dado que estou logado como Discente + Quando tento acessar a página "Gerenciamento" + Então sou redirecionado para o painel do usuário + E vejo a mensagem de erro "Acesso não autorizado" diff --git a/features/importar_dados_sigaa.feature b/features/importar_dados_sigaa.feature new file mode 100644 index 0000000000..59fd3160c3 --- /dev/null +++ b/features/importar_dados_sigaa.feature @@ -0,0 +1,42 @@ +# language: pt + +Funcionalidade: Importar dados do SIGAA + 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 estou logado como Administrador + Dado que acesso a página "Gerenciamento - Importar Dados" + + # ==================== CENÁRIOS FELIZES ==================== + + Cenário: Importação de dados de turmas inéditas com sucesso + Quando seleciono o arquivo "classes.json" para importar do SIGAA + E clico no botão de ação de importação "Confirmar Importação" + Então vejo a mensagem de sucesso da importação "Dados de turmas importados com sucesso: 3 novas turmas processadas" + E a disciplina "ENGENHARIA DE SOFTWARE" passa a ser listada no sistema + + Cenário: Importação de participantes da turma com sucesso + Quando seleciono o arquivo "class_members.json" para importar do SIGAA + E clico no botão de ação de importação "Confirmar Importação" + Então vejo a mensagem de sucesso da importação "Participantes importados com sucesso" + E a lista de discentes da turma exibe o aluno "Ana Clara Jordao Perna" + + # ==================== CENÁRIOS TRISTES ==================== + Cenário: Tentativa de importar arquivo com formato inválido + Quando seleciono o arquivo "relatorio.pdf" para importar do SIGAA + E clico no botão de ação de importação "Confirmar Importação" + Então vejo a mensagem de erro da importação "Formato de arquivo inválido. Por favor, envie um arquivo .json" + E o sistema não realiza nenhuma alteração na base de dados + + Cenário: Importar arquivo JSON com estrutura corrompida + Dado que possuo um arquivo "corrompido.json" com chaves faltando + Quando seleciono o arquivo "corrompido.json" para importar do SIGAA + E clico no botão de ação de importação "Confirmar Importação" + Então vejo a mensagem de erro da importação "Erro na estrutura do arquivo: campos obrigatórios ausentes" + + Cenário: Tentativa de importação sem selecionar nenhum arquivo + Quando clico no botão de ação de importação "Confirmar Importação" sem selecionar um arquivo + Então vejo o alerta da importação "Por favor, selecione um arquivo para importar" + E permaneço na página de importação diff --git a/features/login.feature b/features/login.feature new file mode 100644 index 0000000000..6cdd706a75 --- /dev/null +++ b/features/login.feature @@ -0,0 +1,60 @@ +# language: pt + +Funcionalidade: Login no sistema CAMAAR + Como um 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 os usuários estão cadastrados no sistema + Dado que estou na página de login do CAMAAR + + # ==================== CENÁRIOS FELIZES ==================== + + @javascript + Cenário: Login bem-sucedido com e-mail válido + Quando preencho o campo "Email ou Matrícula" com "user@gmail.com" + E preencho o campo "Senha" com "senhaUser" + E clico no botão "Entrar" + Então sou redirecionado para o painel principal + + @javascript + Cenário: Login bem-sucedido com matrícula válida + Quando preencho o campo "Email ou Matrícula" com "190084006" + E preencho o campo "Senha" com "senhaUser" + E clico no botão "Entrar" + Então sou redirecionado para o painel principal + + @javascript + Cenário: Login como administrador exibe menu de gerenciamento + Quando preencho o campo "Email ou Matrícula" com "admin@gmail.com" + E preencho o campo "Senha" com "senhaAdmin" + E clico no botão "Entrar" + Então sou redirecionado para o painel principal + E vejo a opção "Gerenciamento" no menu lateral + + # ==================== CENÁRIOS TRISTES ==================== + + @javascript + Cenário: Login com senha incorreta + Quando preencho o campo "Email ou Matrícula" com "user@gmail.com" + E preencho o campo "Senha" com "senhaErrada" + E clico no botão "Entrar" + Então permaneço na página de login + E vejo a mensagem de erro "Credenciais inválidas" + + @javascript + Cenário: Login com usuário não cadastrado + Quando preencho o campo "Email ou Matrícula" com "nao.cadastrado@gmail.com" + E preencho o campo "Senha" com "qualquerSenha" + E clico no botão "Entrar" + Então permaneço na página de login + E vejo a mensagem de erro "Credenciais inválidas" + + @javascript + Cenário: Login como usuário comum não exibe menu de gerenciamento + Quando preencho o campo "Email ou Matrícula" com "user@gmail.com" + E preencho o campo "Senha" com "senhaUser" + E clico no botão "Entrar" + Então sou redirecionado para o painel principal + E não vejo a opção "Gerenciamento" no menu lateral \ No newline at end of file diff --git a/features/redef_senha.feature b/features/redef_senha.feature new file mode 100644 index 0000000000..f5b364c78c --- /dev/null +++ b/features/redef_senha.feature @@ -0,0 +1,48 @@ +# language: pt + +Funcionalidade: ReRedefinir senha a partir do e-mail de solicitação de troca de senha + Como usuario + 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 + + Contexto: + Dado que recebi um e-mail de solicitação de troca de senha no endereço "user@gmail.com" + E cliquei no link de redefinição de senha contido no e-mail + + # ==================== CENÁRIOS FELIZES ==================== + + Cenário: Redefinição de senha com sucesso + Quando preencho o campo "Nova Senha" com "senhaUser" + E preencho o campo "Confirmar Senha" com "senhaUser" + E clico no botão "Redefinir Senha" + Então sou redirecionado para o painel principal + + # ==================== CENÁRIOS TRISTES ==================== + + Cenário: Senhas digitadas não coincidem + Quando preencho o campo "Nova Senha" com "senhaUser" + E preencho o campo "Confirmar Senha" com "senhaUserErrada" + E clico no botão "Redefinir Senha" + Então permaneço na página de redefinição de senha + E vejo a mensagem de erro "As senhas não coincidem" + + Cenário: Senha não atende aos requisitos mínimos + Quando preencho o campo "Nova Senha" com "senha" + E preencho o campo "Confirmar Senha" com "senha" + E clico no botão "Redefinir Senha" + Então permaneço na página de redefinição de senha + E vejo a mensagem de erro "A senha deve ter no mínimo 8 caracteres" + + Cenário: Campos de senha deixados em branco + Quando deixo o campo "Nova Senha" em branco + E deixo o campo "Confirmar Senha" em branco + E clico no botão "Redefinir Senha" + Então permaneço na página de redefinição de senha + E vejo a mensagem de erro "Preencha todos os campos obrigatórios" + + Cenário: Link de redefinição de senha expirado + Dado que o link de redefinição de senha foi enviado há mais de 24 horas + Quando preencho o campo "Nova Senha" com "senhaUser" + E preencho o campo "Confirmar Senha" com "senhaUser" + E clico no botão "Redefinir Senha" + Então vejo a mensagem de erro "Este link expirou. Solicite um novo link." diff --git a/features/relatorio_admin.feature b/features/relatorio_admin.feature new file mode 100644 index 0000000000..5d1032b877 --- /dev/null +++ b/features/relatorio_admin.feature @@ -0,0 +1,55 @@ +#language: pt + +Funcionalidade: Gerar relatório do Administrador + Como um Administrador + Quero baixar um arquivo CSV contendo os resultados de um formulário + A fim de avaliar o desempenho das turmas + + Contexto: + Dado que estou logado como Administrador + E estou na página "Gerenciamento" + E clico no botão "Resultados" + + # ==================== CENÁRIOS FELIZES ==================== + + Cenário: Visualizar listagem de turmas com resultados disponíveis + Dado que a turma "Engenharia de Software - 2026.1 - Prof. Genaina" possui respostas registradas + Quando acesso "Gerenciamento - Resultados" + Então vejo uma listagem de cards com turmas + E cada card exibe o "Nome da matéria", o "semestre" e o "Professor" + + Cenário: Download do CSV com resultados de uma turma + Dado que a turma "Engenharia de Software - 2026.1 - Prof. Genaina" possui respostas registradas + Quando clico no card da turma "Engenharia de Software - 2026.1 - Prof. Genaina" + E clico no botão "Baixar CSV" + Então um arquivo CSV é baixado com os resultados da turma selecionada + E o arquivo contém as colunas "Turma", "Discente", "Questão" e "Resposta" + E cada linha corresponde a uma resposta submetida por um discente + + Cenário: Download do CSV contendo todos os tipos de resposta do formulário + Dado que a turma "Engenharia de Software - 2026.1 - Prof. Genaina" possui respostas de questões do tipo Radio e Texto + Quando clico no card da turma "Engenharia de Software - 2026.1 - Prof. Genaina" + E clico no botão "Baixar CSV" + Então o arquivo CSV baixado contém uma coluna para cada questão do formulário + E os valores correspondem às respostas selecionadas ou digitadas pelos discentes + + # ==================== CENÁRIOS TRISTES ==================== + + Cenário: Tentativa de download de CSV de turma sem respostas registradas + Dado que a turma "Software Básico - 2026.1 - Prof. Ladeira" não possui nenhuma resposta registrada + Quando clico no card da turma "Software Básico - 2026.1 - Prof. Ladeira" + E clico no botão "Baixar CSV" + Então nenhum arquivo é baixado + E vejo a mensagem de aviso "Esta turma ainda não possui respostas registradas" + + Cenário: Nenhuma turma disponível na tela de Resultados + Dado que nenhum formulário foi enviado para nenhuma turma + Quando acesso "Gerenciamento - Resultados" + Então vejo a mensagem "Nenhum resultado disponível" + E nenhum card de turma é exibido + + Cenário: Tentativa de acesso à tela de Resultados por usuário não administrador + Dado que estou logado como Discente + Quando tento acessar a página "Gerenciamento - Resultados" + Então sou redirecionado para o painel do usuário + E vejo a mensagem de erro "Acesso não autorizado" diff --git a/features/responder_formulario.feature b/features/responder_formulario.feature new file mode 100644 index 0000000000..d20bc69fcf --- /dev/null +++ b/features/responder_formulario.feature @@ -0,0 +1,51 @@ +# language: pt + +Funcionalidade: Responder Formulário + 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 estou logado como Participante de teste + E estou matriculado na turma "TA" da disciplina "ENGENHARIA DE SOFTWARE" + E existe um formulário pendente para esta turma + E estou na página de "Avaliações" + + # ==================== CENÁRIOS FELIZES ==================== + + Cenário: Submissão de avaliação com sucesso + Quando clico no botão "Responder" do formulário da turma "TA" + E preencho todas as questões obrigatórias com valores válidos + E clico no botão de formulário "Enviar Avaliação" + Então vejo o texto "Avaliação submetida com sucesso!" + E o card do formulário da turma "TA" exibe o status "Respondido" + + Cenário: Visualizar formulário já respondido + Dado que já respondi ao formulário da turma "TA" + Quando tento clicar no botão "Responder" novamente + Então o botão deve estar desabilitado + E vejo o texto "Você já respondeu esta avaliação" + + # ==================== CENÁRIOS TRISTES ==================== + + Cenário: Tentativa de submissão de formulário incompleto + Quando clico no botão "Responder" do formulário da turma "TA" + E deixo a questão obrigatória "Avaliação do Professor" em branco + E clico no botão de formulário "Enviar Avaliação" + Então permaneço na página do formulário + E vejo o alerta "Por favor, responda todas as questões obrigatórias" + E a questão "Avaliação do Professor" é destacada em vermelho + + Cenário: Tentativa de responder formulário fora do prazo + Dado que o prazo para o formulário da turma "TA" expirou + Quando acesso a página de "Avaliações" + Então não vejo o botão "Responder" para a turma "TA" + E vejo o texto "Avaliação encerrada em" + + Cenário: Erro ao enviar formulário por falha de conexão + Quando clico no botão "Responder" do formulário da turma "TA" + E preencho o formulário + E perco a conexão com a internet + E clico no botão de formulário "Enviar Avaliação" + Então vejo o texto "Falha na conexão. Tente novamente." + E minhas respostas preenchidas devem ser preservadas localmente diff --git a/features/step_definitions/.keep b/features/step_definitions/.keep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/features/step_definitions/atualizar_base_com_sigaa_steps.rb b/features/step_definitions/atualizar_base_com_sigaa_steps.rb new file mode 100644 index 0000000000..9f74c67e71 --- /dev/null +++ b/features/step_definitions/atualizar_base_com_sigaa_steps.rb @@ -0,0 +1,182 @@ +# encoding: utf-8 + +# ==================== HELPER ==================== + +def executar_atualizacao_sigaa + visit admin_management_path + click_button 'Atualizar Base SIGAA' +end + +# ==================== CONTEXTO ==================== + +Dado('o sistema possui integração ativa com o SIGAA') do + ENV['SIGAA_SCENARIO'] = 'success' +end + +# ==================== CONDIÇÕES DO SIGAA ==================== +# Nesses cenários o feature faz "Quando " e depois "E ". +# Por isso cada step de condição reexecuta a ação com o ENV correto, +# sobrescrevendo o resultado anterior. + +Dado('o SIGAA retorna dados válidos') do + ENV['SIGAA_SCENARIO'] = 'success' + # cenário de sucesso: a ação já foi executada com success no Contexto, + # nada a reexecutar. +end + +Dado('o SIGAA retorna dados inconsistentes') do + ENV['SIGAA_SCENARIO'] = 'invalid' + executar_atualizacao_sigaa +end + +Dado('não há conexão com o SIGAA') do + ENV['SIGAA_SCENARIO'] = 'connection_error' + executar_atualizacao_sigaa +end + +Dado('o SIGAA retorna parcialmente os dados com sucesso') do + ENV['SIGAA_SCENARIO'] = 'success' + allow(SigaaImporter).to receive(:import_classes).and_return({ success: true }) + allow(SigaaImporter).to receive(:import_members).and_return({ success: false, failed: ['mat_001'] }) + executar_atualizacao_sigaa +end + +Dado('o SIGAA não retorna nenhum dado') do + ENV['SIGAA_SCENARIO'] = 'empty' + executar_atualizacao_sigaa +end + +# ==================== ESTADO DO SISTEMA ==================== + +Dado('que já existe uma atualização em andamento') do + SyncLock.start! +end + +Dado('que uma atualização anterior falhou') do + SigaaLog.create!( + user: @admin, + status: 'error', + message: 'Falha na sincronização anterior' + ) +end + +# ==================== AÇÕES ==================== + +Quando('solicito a atualização dos dados a partir do SIGAA') do + # ENV já foi setado no Contexto (success por padrão). + # Para cenários tristes, o step "E " reexecuta com o ENV correto. + executar_atualizacao_sigaa +end + +Quando('solicito uma nova atualização dos dados a partir do SIGAA') do + executar_atualizacao_sigaa +end + +Quando('solicito uma nova tentativa de atualização') do + ENV['SIGAA_SCENARIO'] = 'success' + executar_atualizacao_sigaa +end + +Quando('tento solicitar a atualização da base de dados') do + # Usuário discente é barrado pelo require_admin antes de ver o botão. + visit admin_management_path +end + +Quando('a atualização é executada') do + ENV['SIGAA_SCENARIO'] = 'success' + executar_atualizacao_sigaa +end + +# ==================== USUÁRIO SEM PERMISSÃO ==================== + +Dado('que estou logado como usuário comum') do + @common_user = User.create!( + email: 'comum@gmail.com', + matricula: '190000001', + password: 'senha123', + password_confirmation: 'senha123', + role: 'discente' + ) + visit login_path + fill_in 'Email ou Matrícula', with: 'comum@gmail.com' + fill_in 'Senha', with: 'senha123' + click_button 'Entrar' +end + +# ==================== VERIFICAÇÕES - CENÁRIOS FELIZES ==================== + +Então('a base de dados do sistema deve ser atualizada com os novos dados') do + expect(page).to have_content('Atualização concluída com sucesso') +end + +Então('devo ver uma mensagem de sucesso na atualização') do + expect(page).to have_content('Atualização concluída com sucesso') +end + +Então('o sistema deve atualizar apenas os dados válidos') do + expect(page).to have_content('SIGAA retornou dados inconsistentes') +end + +Então('deve registrar os dados que falharam na atualização') do + expect(SigaaLog.last).not_to be_nil +end + +Então('o sistema deve reprocessar os dados do SIGAA') do + expect(page).to have_content('Atualização concluída com sucesso') +end + +Então('a base de dados deve ser atualizada com sucesso caso os dados sejam válidos') do + expect(page).to have_content('Atualização concluída com sucesso') +end + +# ==================== VERIFICAÇÕES - CENÁRIOS TRISTES ==================== + +Então('a atualização não deve ser concluída') do + expect(page).not_to have_content('Atualização concluída com sucesso') +end + +Então('devo ver uma mensagem de erro informando inconsistência de dados') do + expect(page).to have_content('Dados inválidos retornados pelo SIGAA') +end + +Então('a atualização não deve ser realizada') do + expect(page).not_to have_content('Atualização concluída com sucesso') +end + +Então('devo ver uma mensagem informando falha de comunicação') do + expect(page).to have_content('Falha de conexão com o SIGAA') +end + +Então('devo ser impedido de realizar a ação') do + expect(page).not_to have_content('Atualização concluída com sucesso') +end + +Então('devo ver uma mensagem de acesso negado') do + expect(page).to have_text(/Acesso não autorizado|Acesso negado/) +end + +Então('devo ser impedido de iniciar outra atualização') do + expect(page).to have_content('Já existe uma atualização em andamento') +end + +Então('devo ver uma mensagem informando que já existe um processo em execução') do + expect(page).to have_content('Já existe uma atualização em andamento') +end + +Então('devo ver uma mensagem informando ausência de dados') do + expect(page).to have_content('SIGAA não retornou dados') +end + +# ==================== AUDITORIA ==================== + +Então('o sistema deve registrar um log da atualização') do + expect(SigaaLog.count).to be >= 1 +end + +Então('o log deve conter data, usuário responsável e resultado da operação') do + log = SigaaLog.last + expect(log.user).not_to be_nil + expect(log.status).not_to be_blank + expect(log.message).not_to be_blank + expect(log.created_at).not_to be_nil +end diff --git a/features/step_definitions/auth_steps.rb b/features/step_definitions/auth_steps.rb new file mode 100644 index 0000000000..2bc2148fe4 --- /dev/null +++ b/features/step_definitions/auth_steps.rb @@ -0,0 +1,33 @@ +Dado('que estou logado como administrador') do + @admin = User.find_or_create_by!(matricula: 'admin') do |u| + u.email = 'admin@admin.com' + u.role = 'admin' + u.password = 'password123' + u.password_confirmation = 'password123' + end + + visit login_path + fill_in 'Email ou Matrícula', with: 'admin' + fill_in 'Senha', with: 'password123' + click_button 'Entrar' + expect(page).not_to have_current_path(login_path, wait: 5) +end + +Dado('que estou logado como Administrador') do + step "que estou logado como administrador" +end + +Dado('que estou logado como Participante') do + @user = User.find_or_create_by!(matricula: '190084006') do |u| + u.email = 'user@user.com' + u.role = 'discente' + u.password = 'password123' + u.password_confirmation = 'password123' + end + + visit login_path + fill_in 'Email ou Matrícula', with: '190084006' + fill_in 'Senha', with: 'password123' + click_button 'Entrar' + expect(page).not_to have_current_path(login_path, wait: 5) +end \ No newline at end of file diff --git a/features/step_definitions/cadastrar_usuarios_steps.rb b/features/step_definitions/cadastrar_usuarios_steps.rb new file mode 100644 index 0000000000..f209a35ba6 --- /dev/null +++ b/features/step_definitions/cadastrar_usuarios_steps.rb @@ -0,0 +1,114 @@ +# encoding: utf-8 + +Dado('que estou logado como Administrador de teste') do + @user = User.find_by(email: 'admin@teste.com') + unless @user + @user = User.create!( + email: 'admin@teste.com', + matricula: 'admin123', + password: 'password123', + password_confirmation: 'password123', + role: 'admin' + ) + end + visit login_path + fill_in 'Email ou Matrícula', with: 'admin@teste.com' + fill_in 'Senha', with: 'password123' + click_button 'Entrar' +end + +Dado('que importei o arquivo {string}') do |arquivo| + d = Disciplina.find_or_create_by!(code: 'CIC0097', name: 'BANCOS DE DADOS') + Turma.find_or_create_by!(classCode: 'TA', semester: '2021.2', disciplina: d) + + visit admin_imports_path + attach_file('file', File.join(Rails.root, arquivo)) + select 'Participantes', from: 'import_type' + click_button 'Confirmar Importação' +end + +Dado('o sistema identificou the novo usuário with matrícula {string}') do |matricula| + @imported_user = User.find_by(matricula: matricula) + expect(@imported_user).not_to be_nil +end + +Quando('o usuário {string} acessa a página de login') do |matricula| + click_button 'Logout' + visit login_path +end + +Quando('preencho o campo de cadastro {string} com {string}') do |campo, valor| + fill_in campo, with: valor +end + +Quando('deixo o campo de cadastro {string} em branco') do |campo| + fill_in campo, with: '' +end + +Quando('clico no botão de cadastro {string}') do |botao| + click_button botao +end + +Então('sou redirecionado para a página de cadastro {string}') do |pagina| + case pagina + when 'Definição de Senha' + user = User.find_by(matricula: @current_matricula || '190084006') + expect(page.current_path).to include("/definir-senha/") + when 'painel principal' + if @user&.discente? || @user&.docente? + expect(page).to have_current_path(avaliacoes_path) + else + expect(page).to have_current_path(home_path) + end + end +end + +Dado('que o usuário {string} está na página {string}') do |matricula, pagina| + @user = User.find_by(matricula: matricula) + if @user.nil? + @user = User.create!(matricula: matricula, email: "test_#{matricula}@example.com", role: 'discente') + end + @user.generate_invitation_token! if @user.invitation_token.nil? + visit password_set_path(@user.invitation_token) +end + +Então('meu status no sistema passa a ser {string}') do |status| + @user.reload + expect(@user.password_digest).not_to be_nil +end + +Então('permaneço na página de cadastro {string}') do |pagina| + expect(page).to have_content('DEFINIR SENHA') +end + +Dado('que o usuário {string} foi importado mas não definiu senha') do |matricula| + @user = User.find_or_create_by!(matricula: matricula) do |u| + u.email = "test_#{matricula}@example.com" + u.role = 'discente' + u.generate_invitation_token! + end + @current_matricula = matricula + # login and redirection happens in the 'Quando' step +end + +Quando('tento acessar a página {string} diretamente') do |pagina| + visit login_path + fill_in 'Email ou Matrícula', with: @current_matricula + click_button 'Entrar' +end + +Então('vejo o alerta de cadastro {string}') do |alerta| + expect(page).to have_content(alerta) +end + +Então('vejo a mensagem de sucesso de cadastro {string}') do |mensagem| + expect(page).to have_content(mensagem) +end + +Então('vejo a mensagem de erro de cadastro {string}') do |mensagem| + expect(page).to have_content(mensagem) +end + +Então('vejo a mensagem de cadastro {string}') do |mensagem| + expect(page).to have_content(mensagem) +end diff --git a/features/step_definitions/criacao_form_para_dic_doc_steps.rb b/features/step_definitions/criacao_form_para_dic_doc_steps.rb new file mode 100644 index 0000000000..f98eae8486 --- /dev/null +++ b/features/step_definitions/criacao_form_para_dic_doc_steps.rb @@ -0,0 +1,208 @@ +# encoding: utf-8 + +# ==================== CONTEXTO ==================== + +Dado('que existem turmas e templates para criação de formulários') do + disciplina = Disciplina.find_or_create_by!(code: 'CIC0001') do |d| + d.name = 'Engenharia de Software' + end + + @turma_cic0105 = Turma.find_or_create_by!(classCode: 'CIC0105') do |t| + t.disciplina = disciplina + t.nome = 'Engenharia de Software' + t.semester = '2026.1' + t.time = '10:00' + end + + @turma_cic0202 = Turma.find_or_create_by!(classCode: 'CIC0202') do |t| + t.disciplina = disciplina + t.nome = 'Engenharia de Software' + t.semester = '2026.1' + t.time = '14:00' + end + + Template.find_or_create_by!(nome: 'Avaliação Engenharia de Software') + Template.find_or_create_by!(nome: 'Avaliação de Didática') +end + +Então('um modal é exibido com os campos {string}, {string}, {string} e data de disponibilidade') do |campo1, campo2, campo3| + expect(page).to have_selector('.modal') + expect(page).to have_field(campo1) + expect(page).to have_field(campo2) + expect(page).to have_field(campo3) +end + +# ==================== CENÁRIOS FELIZES - AÇÕES ==================== + +Quando('seleciono {string} no campo {string}') do |opcao, campo| + select opcao, from: campo +end + +Quando('seleciono a turma {string} no campo {string}') do |turma, campo| + select_field = find_field(campo) + select_field.find(:option, turma).select_option +end + +Quando('preencho a data de início com {string}') do |data| + fill_in 'data_inicio', with: Date.strptime(data, '%d/%m/%Y').strftime('%Y-%m-%d') +end + +Quando('preencho a data de término com {string}') do |data| + fill_in 'data_termino', with: Date.strptime(data, '%d/%m/%Y').strftime('%Y-%m-%d') +end + +Quando('clico no campo {string} novamente para adicionar mais turmas') do |campo| + # Em um