diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..03a268b8ba --- /dev/null +++ b/.dockerignore @@ -0,0 +1,34 @@ +# Include any files or directories that you don't want to be copied to your +# container here (e.g., local build artifacts, temporary files, etc.). +# +# For more help, visit the .dockerignore file reference guide at +# https://docs.docker.com/go/build-context-dockerignore/ + +**/.DS_Store +**/__pycache__ +**/.venv +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/bin +**/charts +**/docker-compose* +**/compose.y*ml +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md 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..f0527e6be1 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: +- package-ecosystem: bundler + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 10 +- package-ecosystem: github-actions + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 10 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000..7b7c0c59b3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,90 @@ +name: CI + +on: + pull_request: + push: + branches: [ main ] + +jobs: + scan_ruby: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + + - name: Scan for common Rails security vulnerabilities using static analysis + run: bin/brakeman --no-pager + + scan_js: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + + - name: Scan for security vulnerabilities in JavaScript dependencies + run: bin/importmap audit + + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + + - name: Lint code for consistent style + run: bin/rubocop -f github + + test: + runs-on: ubuntu-latest + + # services: + # redis: + # image: redis + # 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 build-essential git libyaml-dev pkg-config google-chrome-stable + + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + + - name: Run tests + env: + RAILS_ENV: test + # REDIS_URL: redis://localhost:6379/0 + run: bin/rails db:test:prepare test 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..1303e39019 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# 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 master key for decrypting credentials and more. +/config/master.key + +/app/assets/builds/* +!/app/assets/builds/.keep 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..9a771a3985 --- /dev/null +++ b/.kamal/secrets @@ -0,0 +1,17 @@ +# 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}) + +# 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..3b47f2e4f8 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +3.3.9 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000..4ba3d017fd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,72 @@ +# syntax=docker/dockerfile:1 +# check=error=true + +# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand: +# docker build -t camaar_g1 . +# docker run -d -p 80:80 -e RAILS_MASTER_KEY= --name camaar_g1 camaar_g1 + +# 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.3.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 && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Set production environment +ENV RAILS_ENV="production" \ + BUNDLE_DEPLOYMENT="1" \ + BUNDLE_PATH="/usr/local/bundle" \ + BUNDLE_WITHOUT="development" + +# Throw-away build stage to reduce size of final image +FROM base AS build + +# Install packages needed to build gems +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Install application gems +COPY Gemfile Gemfile.lock ./ +RUN bundle install && \ + rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \ + bundle exec bootsnap precompile --gemfile + +# Copy application code +COPY . . + +# Precompile bootsnap code for faster boot times +RUN bundle exec bootsnap precompile app/ lib/ + +# Precompiling assets for production without requiring secret RAILS_MASTER_KEY +RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile + + + + +# Final stage for app image +FROM base + +# Copy built artifacts: gems, application +COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" +COPY --from=build /rails /rails + +# 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 && \ + chown -R rails:rails db log storage tmp +USER 1000:1000 + +# 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..0b8e531472 --- /dev/null +++ b/Gemfile @@ -0,0 +1,76 @@ +source "https://rubygems.org" + +# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" +gem "rails", "~> 8.0.2", ">= 8.0.2.1" +# 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" +gem "csv" +gem "bcrypt", "~> 3.1.7" + +# Windows does not include zoneinfo files, so bundle the tzinfo-data gem +gem "tzinfo-data", platforms: %i[ windows jruby ] + +# Use the database-backed adapters for Rails.cache, Active Job, and Action Cable +gem "solid_cache" +gem "solid_queue" +gem "solid_cable" + +# Reduces boot times through caching; required in config/boot.rb +gem "bootsnap", require: false + +# Deploy this application anywhere as a Docker container [https://kamal-deploy.org] +gem "kamal", require: false + +# Add HTTP asset caching/compression and X-Sendfile acceleration to Puma [https://github.com/basecamp/thruster/] +gem "thruster", require: false + +# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] +# gem "image_processing", "~> 1.2" + +group :development, :test do + # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem + gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" + gem "rspec-rails" + + # Static analysis for security vulnerabilities [https://brakemanscanner.org/] + gem "brakeman", require: false + + # Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/] + gem "rubocop-rails-omakase", require: false + + gem "factory_bot_rails" + +end + +group :development do + # Use console on exceptions pages [https://github.com/rails/web-console] + gem "web-console" +end + +# Testing gems +group :test do + # Rails default system testing + gem "capybara" + gem "selenium-webdriver" + + + # Cucumber support + gem "cucumber-rails", require: false + gem "database_cleaner-active_record" +end +gem "tailwindcss-rails", "~> 4.4" diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000000..875bc7e9b3 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,488 @@ +GEM + remote: https://rubygems.org/ + specs: + actioncable (8.0.4) + actionpack (= 8.0.4) + activesupport (= 8.0.4) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + zeitwerk (~> 2.6) + actionmailbox (8.0.4) + actionpack (= 8.0.4) + activejob (= 8.0.4) + activerecord (= 8.0.4) + activestorage (= 8.0.4) + activesupport (= 8.0.4) + mail (>= 2.8.0) + actionmailer (8.0.4) + actionpack (= 8.0.4) + actionview (= 8.0.4) + activejob (= 8.0.4) + activesupport (= 8.0.4) + mail (>= 2.8.0) + rails-dom-testing (~> 2.2) + actionpack (8.0.4) + actionview (= 8.0.4) + activesupport (= 8.0.4) + 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.0.4) + actionpack (= 8.0.4) + activerecord (= 8.0.4) + activestorage (= 8.0.4) + activesupport (= 8.0.4) + globalid (>= 0.6.0) + nokogiri (>= 1.8.5) + actionview (8.0.4) + activesupport (= 8.0.4) + builder (~> 3.1) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activejob (8.0.4) + activesupport (= 8.0.4) + globalid (>= 0.3.6) + activemodel (8.0.4) + activesupport (= 8.0.4) + activerecord (8.0.4) + activemodel (= 8.0.4) + activesupport (= 8.0.4) + timeout (>= 0.4.0) + activestorage (8.0.4) + actionpack (= 8.0.4) + activejob (= 8.0.4) + activerecord (= 8.0.4) + activesupport (= 8.0.4) + marcel (~> 1.0) + activesupport (8.0.4) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) + ast (2.4.3) + base64 (0.3.0) + bcrypt (3.1.20) + bcrypt_pbkdf (1.1.1) + bcrypt_pbkdf (1.1.1-arm64-darwin) + bcrypt_pbkdf (1.1.1-x86_64-darwin) + benchmark (0.5.0) + bigdecimal (3.3.1) + bindex (0.8.1) + bootsnap (1.18.6) + msgpack (~> 1.2) + brakeman (7.1.1) + racc + builder (3.3.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) + concurrent-ruby (1.3.5) + connection_pool (2.5.4) + crass (1.0.6) + csv (3.3.5) + cucumber (10.1.1) + base64 (~> 0.2) + builder (~> 3.2) + cucumber-ci-environment (> 9, < 11) + cucumber-core (> 15, < 17) + cucumber-cucumber-expressions (> 17, < 19) + cucumber-html-formatter (> 20.3, < 22) + diff-lcs (~> 1.5) + logger (~> 1.6) + mini_mime (~> 1.1) + multi_test (~> 1.1) + sys-uname (~> 1.3) + cucumber-ci-environment (10.0.1) + cucumber-core (15.3.0) + cucumber-gherkin (> 27, < 35) + cucumber-messages (> 26, < 30) + cucumber-tag-expressions (> 5, < 9) + cucumber-cucumber-expressions (18.0.1) + bigdecimal + cucumber-gherkin (34.0.0) + cucumber-messages (> 25, < 29) + cucumber-html-formatter (21.15.1) + cucumber-messages (> 19, < 28) + cucumber-messages (27.2.0) + cucumber-rails (4.0.0) + capybara (>= 3.25, < 4) + cucumber (>= 7, < 11) + railties (>= 6.1, < 9) + cucumber-tag-expressions (8.0.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.0) + debug (1.11.0) + irb (~> 1.10) + reline (>= 0.3.8) + diff-lcs (1.6.2) + dotenv (3.1.8) + drb (2.2.3) + ed25519 (1.4.0) + erb (5.1.3) + erubi (1.13.1) + et-orbi (1.4.0) + tzinfo + factory_bot (6.5.6) + activesupport (>= 6.1.0) + factory_bot_rails (6.5.1) + factory_bot (~> 6.5) + railties (>= 6.1.0) + ffi (1.17.2-aarch64-linux-gnu) + ffi (1.17.2-aarch64-linux-musl) + ffi (1.17.2-arm-linux-gnu) + ffi (1.17.2-arm-linux-musl) + ffi (1.17.2-arm64-darwin) + ffi (1.17.2-x86_64-darwin) + ffi (1.17.2-x86_64-linux-gnu) + ffi (1.17.2-x86_64-linux-musl) + fugit (1.12.1) + et-orbi (~> 1.4) + raabro (~> 1.4) + globalid (1.3.0) + activesupport (>= 6.1) + i18n (1.14.7) + concurrent-ruby (~> 1.0) + importmap-rails (2.2.2) + actionpack (>= 6.0.0) + activesupport (>= 6.0.0) + railties (>= 6.0.0) + io-console (0.8.1) + irb (1.15.3) + pp (>= 0.6.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + jbuilder (2.14.1) + actionview (>= 7.0.0) + activesupport (>= 7.0.0) + json (2.16.0) + kamal (2.8.2) + 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) + lint_roller (1.1.0) + logger (1.7.0) + loofah (2.24.1) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) + mail (2.9.0) + logger + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp + marcel (1.1.0) + matrix (0.4.3) + memoist3 (1.0.0) + mini_mime (1.1.5) + minitest (5.26.1) + msgpack (1.8.0) + multi_test (1.1.0) + net-imap (0.5.12) + 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.0) + nio4r (2.7.5) + nokogiri (1.18.10-aarch64-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.10-aarch64-linux-musl) + racc (~> 1.4) + nokogiri (1.18.10-arm-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.10-arm-linux-musl) + racc (~> 1.4) + nokogiri (1.18.10-arm64-darwin) + racc (~> 1.4) + nokogiri (1.18.10-x86_64-darwin) + racc (~> 1.4) + nokogiri (1.18.10-x86_64-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.10-x86_64-linux-musl) + racc (~> 1.4) + ostruct (0.6.3) + parallel (1.27.0) + parser (3.3.10.0) + ast (~> 2.4.1) + racc + pp (0.6.3) + prettyprint + prettyprint (0.2.0) + prism (1.6.0) + propshaft (1.3.1) + actionpack (>= 7.0.0) + activesupport (>= 7.0.0) + rack + psych (5.2.6) + date + stringio + public_suffix (6.0.2) + puma (7.1.0) + nio4r (~> 2.0) + raabro (1.4.0) + racc (1.8.1) + rack (3.2.4) + rack-session (2.1.1) + base64 (>= 0.1.0) + rack (>= 3.0.0) + rack-test (2.2.0) + rack (>= 1.3) + rackup (2.2.1) + rack (>= 3) + rails (8.0.4) + actioncable (= 8.0.4) + actionmailbox (= 8.0.4) + actionmailer (= 8.0.4) + actionpack (= 8.0.4) + actiontext (= 8.0.4) + actionview (= 8.0.4) + activejob (= 8.0.4) + activemodel (= 8.0.4) + activerecord (= 8.0.4) + activestorage (= 8.0.4) + activesupport (= 8.0.4) + bundler (>= 1.15.0) + railties (= 8.0.4) + rails-dom-testing (2.3.0) + activesupport (>= 5.0.0) + minitest + nokogiri (>= 1.6) + rails-html-sanitizer (1.6.2) + loofah (~> 2.21) + 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.0.4) + actionpack (= 8.0.4) + activesupport (= 8.0.4) + 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.3.1) + rdoc (6.15.1) + erb + psych (>= 4.0.0) + tsort + regexp_parser (2.11.3) + 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.7) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-rails (8.0.2) + actionpack (>= 7.2) + activesupport (>= 7.2) + railties (>= 7.2) + rspec-core (~> 3.13) + rspec-expectations (~> 3.13) + rspec-mocks (~> 3.13) + rspec-support (~> 3.13) + rspec-support (3.13.6) + rubocop (1.81.7) + 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.47.1, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.48.0) + parser (>= 3.3.7.2) + prism (~> 1.4) + 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.33.4) + 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) + rubyzip (3.2.2) + securerandom (0.4.1) + selenium-webdriver (4.38.0) + base64 (~> 0.2) + logger (~> 1.4) + rexml (~> 3.2, >= 3.2.5) + rubyzip (>= 1.2.2, < 4.0) + websocket (~> 1.0) + 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.2.4) + activejob (>= 7.1) + activerecord (>= 7.1) + concurrent-ruby (>= 1.3.1) + fugit (~> 1.11) + railties (>= 7.1) + thor (>= 1.3.1) + sqlite3 (2.8.0-aarch64-linux-gnu) + sqlite3 (2.8.0-aarch64-linux-musl) + sqlite3 (2.8.0-arm-linux-gnu) + sqlite3 (2.8.0-arm-linux-musl) + sqlite3 (2.8.0-arm64-darwin) + sqlite3 (2.8.0-x86_64-darwin) + sqlite3 (2.8.0-x86_64-linux-gnu) + sqlite3 (2.8.0-x86_64-linux-musl) + sshkit (1.24.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.1.8) + sys-uname (1.4.1) + ffi (~> 1.1) + memoist3 (~> 1.0.0) + tailwindcss-rails (4.4.0) + railties (>= 7.0.0) + tailwindcss-ruby (~> 4.0) + tailwindcss-ruby (4.1.16) + tailwindcss-ruby (4.1.16-aarch64-linux-gnu) + tailwindcss-ruby (4.1.16-aarch64-linux-musl) + tailwindcss-ruby (4.1.16-arm64-darwin) + tailwindcss-ruby (4.1.16-x86_64-darwin) + tailwindcss-ruby (4.1.16-x86_64-linux-gnu) + tailwindcss-ruby (4.1.16-x86_64-linux-musl) + thor (1.4.0) + thruster (0.1.16) + thruster (0.1.16-aarch64-linux) + thruster (0.1.16-arm64-darwin) + thruster (0.1.16-x86_64-darwin) + thruster (0.1.16-x86_64-linux) + timeout (0.4.4) + tsort (0.2.0) + turbo-rails (2.0.20) + actionpack (>= 7.1.0) + railties (>= 7.1.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.1.0) + uri (1.1.1) + useragent (0.16.11) + web-console (4.2.1) + actionview (>= 6.0.0) + activemodel (>= 6.0.0) + bindex (>= 0.4.0) + railties (>= 6.0.0) + websocket (1.2.11) + websocket-driver (0.8.0) + base64 + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + xpath (3.2.0) + nokogiri (~> 1.8) + zeitwerk (2.7.3) + +PLATFORMS + aarch64-linux + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + arm64-darwin + x86_64-darwin + x86_64-linux + x86_64-linux-gnu + x86_64-linux-musl + +DEPENDENCIES + bcrypt (~> 3.1.7) + bootsnap + brakeman + capybara + csv + cucumber-rails + database_cleaner-active_record + debug + factory_bot_rails + importmap-rails + jbuilder + kamal + propshaft + puma (>= 5.0) + rails (~> 8.0.2, >= 8.0.2.1) + rspec-rails + rubocop-rails-omakase + selenium-webdriver + solid_cable + solid_cache + solid_queue + sqlite3 (>= 2.1) + stimulus-rails + tailwindcss-rails (~> 4.4) + thruster + turbo-rails + tzinfo-data + web-console + +BUNDLED WITH + 2.7.1 diff --git a/Procfile.dev b/Procfile.dev new file mode 100644 index 0000000000..da151fee94 --- /dev/null +++ b/Procfile.dev @@ -0,0 +1,2 @@ +web: bin/rails server +css: bin/rails tailwindcss:watch diff --git a/README.Docker.md b/README.Docker.md new file mode 100644 index 0000000000..ffca340863 --- /dev/null +++ b/README.Docker.md @@ -0,0 +1,17 @@ +### Building and running your application + +When you're ready, start your application by running: +`docker compose up --build`. + +### Deploying your application to the cloud + +First, build your image, e.g.: `docker build -t myapp .`. +If your cloud uses a different CPU architecture than your development +machine (e.g., you are on a Mac M1 and your cloud provider is amd64), +you'll want to build the image for that platform, e.g.: +`docker build --platform=linux/amd64 -t myapp .`. + +Then, push it to your registry, e.g. `docker push myregistry.com/myapp`. + +Consult Docker's [getting started](https://docs.docker.com/go/get-started-sharing/) +docs for more detail on building and pushing. \ No newline at end of file diff --git a/README.md b/README.md index 9d7fe1bf53..6635e710fb 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,114 @@ -# CAMAAR -Sistema para avaliação de atividades acadêmicas remotas do CIC +# 📝 Wiki do Projeto – Sprint 2: + +**Grupo 1 – Engenharia de Software** +**Integrantes:** + +| Nome | Matrícula | +| :---------- | :-------- | +| Caroline | 232050975 | +| Célio | 211010350 | +| Luís Filipe | 190091975 | +| Mário | 231035778 | + + +# 🔰 Sprint 2 + +Implementar cada User Story (US) previamente especificada na Sprint 1 nos arquivos `.feature`. + +# 🔰 Papéis na Sprint 2 + + +● A implementação das features cujos cenários BDD especificados na segunda etapa da Sprint 1 +Responsáveis: Caroline, Célio, Luís Filipe e Mário. + +● Os respectivos testes em RSpec implementados com sucesso. +Responsáveis: Caroline, Célio, Luís Filipe e Mário. + +● A documentação das features na Wiki do repositório (com os respectivos responsáveis) +Responsáveis: Caroline, Célio, Luís Filipe e Mário. + +● O kanban das issues distribuídas entre as lanes: Backlog - Doing - Done - Accepted usando a própria interface de projetos do GitHub. +Responsável: Célio + +● O link do repositório do Github do grupo com todas as informações acima atualizadas. +Responsável: Mário + +● Abrir uma Pull Request com as especificações dos testes de aceitação +(BDD) no repositório principal. +Responsável: Mário ou Célio + +● Entregar arquivo .txt contendo um link para o repositório, o nome e a +matrícula dos integrantes. +Responsável: Mário + +● Criar um arquivo Markdown como Wiki, contendo +as informações sobre a Sprint 1. +Responsáveis: Caroline, Célio, Luís Filipe e Mário. + + +## 🧑‍💼 Scrum Master + +Caroline + +## 🧑‍💻 Product Owner + +Célio + +## 🧑‍💻 Produção + +Caroline, Célio, Luís Filipe e Mário. + + +# Quais funcionalidades foram desenvolvidas? + + +As US desta sprint são: +[02 - Edição e deleção de templates](https://github.com/mariosantos-05/CAMAAR-G1/issues/2) +[03 - Visualização dos templates criados](https://github.com/mariosantos-05/CAMAAR-G1/issues/3) +[04 - Importar dados do SIGAA](https://github.com/mariosantos-05/CAMAAR-G1/issues/4) +[05 - Responder formulário](https://github.com/mariosantos-05/CAMAAR-G1/issues/5) +[06 - Cadastrar usuários do sistema](https://github.com/mariosantos-05/CAMAAR-G1/issues/6) +[07 - Gerar relatório do Administrador](https://github.com/mariosantos-05/CAMAAR-G1/issues/7) +[08 - Criar template de formulário](https://github.com/mariosantos-05/CAMAAR-G1/issues/8) +[09 - Criar formulários de avaliação](https://github.com/mariosantos-05/CAMAAR-G1/issues/9) +[10 - Sistema de login](https://github.com/mariosantos-05/CAMAAR-G1/issues/10) +[11 - Sistema de definição de senha](https://github.com/mariosantos-05/CAMAAR-G1/issues/11) +[12 - Sistema de gerenciamento por departamento](https://github.com/mariosantos-05/CAMAAR-G1/issues/12) +[13 - Redefinição de senha](https://github.com/mariosantos-05/CAMAAR-G1/issues/13) +[14 - Atualizar base de dados com os dados do SIGAA](https://github.com/mariosantos-05/CAMAAR-G1/issues/14) +[15 - Visualização de formlários para responder](https://github.com/mariosantos-05/CAMAAR-G1/issues/15) +[16 - Visualização de Resultado dos Formulários](https://github.com/mariosantos-05/CAMAAR-G1/issues/16) +[17 - Criação de formulário para docentes ou discentes](https://github.com/mariosantos-05/CAMAAR-G1/issues/17) + + + +## Quem ficou responsável por cada implementação BDD em relação as US/Issues? + +#[02](https://github.com/mariosantos-05/CAMAAR-G1/issues/2) Luís Filipe +#[03](https://github.com/mariosantos-05/CAMAAR-G1/issues/3) Luís Filipe +#[04](https://github.com/mariosantos-05/CAMAAR-G1/issues/4) Caroline +#[05](https://github.com/mariosantos-05/CAMAAR-G1/issues/5) Mário +#[06](https://github.com/mariosantos-05/CAMAAR-G1/issues/6) Célio +#[07](https://github.com/mariosantos-05/CAMAAR-G1/issues/7) Caroline +#[08](https://github.com/mariosantos-05/CAMAAR-G1/issues/8) Luís Filipe +#[09](https://github.com/mariosantos-05/CAMAAR-G1/issues/9) Mário +#[10](https://github.com/mariosantos-05/CAMAAR-G1/issues/10) Célio +#[11](https://github.com/mariosantos-05/CAMAAR-G1/issues/11) Célio +#[12](https://github.com/mariosantos-05/CAMAAR-G1/issues/12) Caroline +#[13](https://github.com/mariosantos-05/CAMAAR-G1/issues/13) Célio +#[14](https://github.com/mariosantos-05/CAMAAR-G1/issues/14) Caroline +#[15](https://github.com/mariosantos-05/CAMAAR-G1/issues/15) Mário +#[16](https://github.com/mariosantos-05/CAMAAR-G1/issues/16) Mário +#[17](https://github.com/mariosantos-05/CAMAAR-G1/issues/17) Luís Filipe + +--- + +# 🌿 Política de Branching Utilizada pelo Grupo + +Sprint Branching + Feature Branching (variação do GitLab Flow): + +- A equipe cria uma branch representando a sprint a partir da main. + +- Todas as feature branches da sprint nascem a partir dela. + +- No final da sprint, tudo é consolidado e mergeado para a branch da sprint. 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/Usuario = Usuario.find_by(matricula: \"111\")" "b/Usuario = Usuario.find_by(matricula: \"111\")" new file mode 100644 index 0000000000..ec953869b9 --- /dev/null +++ "b/Usuario = Usuario.find_by(matricula: \"111\")" @@ -0,0 +1,102 @@ +=> +[#, + #, + #, + #, + #, + #, + #, + #, + #, + #, + "..."] diff --git a/Wiki.md b/Wiki.md new file mode 100644 index 0000000000..1e009d7e9e --- /dev/null +++ b/Wiki.md @@ -0,0 +1,107 @@ +# 📝 Wiki do Projeto – Sprint 1 +**Grupo 1 – Engenharia de Software** +**Integrantes:** + +| Nome | Matrícula | +| :--- | :--- | +| Caroline | 232050975 | +| Célio | 211010350 | +| Luís Filipe | 190091975 | +| Mário | 231035778 | + +--- + +# 📌 Nome do Projeto +**CAMAAR – Sistema para avaliação de atividades acadêmicas remotas do CIC Pipipipópópó(ainda a ser editado)** + +--- + +# 📌 Escopo do Projeto +O sistema **CAMAAR** tem como objetivo auxiliar na avaliação acadêmica de atividades, tarefas e outras atividades remotas do CIC. +O projeto contempla funcionalidades de cadastro de usuários, criação de tarefas, acompanhamento de entregas e visualização de desempenho. + +--- + +# 🔰 Papéis na Sprint 1 + +## 🧑‍💼 Scrum Master +> **Função:** Responsável por garantir que a equipe siga a metodologia Scrum. Remove impedimentos que possam atrapalhar os desenvolvedores, facilita as cerimônias (Reuniões Diárias, Planejamento, Revisão e Retrospectiva) e protege o time de interrupções externas. +**Fulano** +### Funções: +- Facilitar as cerimônias da Sprint (Planning, Review, Retrospective). +- Remover impedimentos enfrentados pela equipe. +- Assegurar que o time siga os princípios ágeis. + +--- + +## 🧑‍💻 Product Owner +> **Função:** O "dono do produto". É a voz do cliente/usuário. Sua principal responsabilidade é definir *o que* será construído, criar e priorizar os itens do Product Backlog (as funcionalidades) para maximizar o valor entregue pela equipe a cada Sprint. + +**Fulana (00000000)** +### Funções: +- Definir e priorizar o Product Backlog. +- Garantir que as funcionalidades entreguem valor ao usuário final. +- Esclarecer dúvidas sobre requisitos. + +--- + +# 🚀 Funcionalidades da Sprint 1 + +A Sprint 1 tem como foco as **funcionalidades essenciais** relacionadas ao cadastro e acesso de usuários. + +## 📦 Funcionalidade 1 – Cadastro de Usuário +**Regra de Negócio:** +- O sistema deve validar e-mail único. +- Senha deve ter pelo menos 8 caracteres. +- O usuário só pode acessar o sistema após confirmar o e-mail. + +**Responsável:** Bombardilno crocodilo +**Histórias:** +- **US01**: “Como usuário, quero criar uma conta usando e-mail e senha para acessar o sistema.” + - **Story Points:** x +- **US02**: “Como usuário, quero receber um e-mail de confirmação para ativar minha conta.” + - **Story Points:** x + +--- + +## 📦 Funcionalidade 2 – Login no Sistema +**Regra de Negócio:** +- O sistema deve bloquear após 5 tentativas falhas consecutivas. +- O login só será autorizado caso o e-mail esteja verificado. + +**Responsável:** Bombardilno crocodilo +**Histórias:** +- **US03**: “Como usuário, quero fazer login com meu e-mail e senha para acessar o sistema.” + - **Story Points:** x +- **US04**: “Como usuário, quero recuperar minha senha caso eu a esqueça.” + - **Story Points:** x + +--- + +# 📊 Métrica Velocity da Sprint 1 + +| História | Pontos | +|----------|--------| +| US01 | X | +| US02 | X | +| US03 | X | +| US04 | X | +| **Total** | **x story points** | + +A *velocity* da Sprint 1 é **x pontos**. + +--- + +# 🌿 Política de Branching Utilizada pelo Grupo + + +--- + +# 📌 Resumo +Esta página da Wiki apresenta: +- Integrantes do grupo e papéis na Sprint +- Funcionalidades planejadas +- Regras de negócio +- Responsáveis +- Pontuação das histórias (Velocity) +- Política de branching adotada diff --git a/app/assets/builds/.keep b/app/assets/builds/.keep new file mode 100644 index 0000000000..e69de29bb2 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/admin_templates.css b/app/assets/stylesheets/admin_templates.css new file mode 100644 index 0000000000..edea579aa5 --- /dev/null +++ b/app/assets/stylesheets/admin_templates.css @@ -0,0 +1,219 @@ +body { + margin: 0; + font-family: 'Roboto', sans-serif; + background-color: #F5F5F5; +} + +.top-navbar { + height: 60px; + background: #FFFFFF; + box-shadow: 0px 11px 10.7px rgba(0, 0, 0, 0.05); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 24px; + position: relative; + z-index: 10; +} + +.nav-left, .nav-right { + display: flex; + align-items: center; + gap: 15px; +} + +.admin-container { + display: flex; + height: calc(100vh - 60px); + width: 100%; +} + +.sidebar { + width: 257px; + min-width: 0; + background: #FFFFFF; + border-right: 1px solid #D9D9D9; + display: flex; + flex-direction: column; + + transition: width 0.3s ease; + white-space: nowrap; + overflow-x: hidden; +} + +.sidebar-item { + height: 46px; + display: flex; + align-items: center; + justify-content: center; + border-bottom: 1px solid #D9D9D9; + text-decoration: none; + font-size: 16px; + color: #000; + font-family: 'Roboto', sans-serif; +} + +.sidebar-item.active { + background: #6C2365; + color: #FFFFFF; +} + +.sidebar.collapsed { + width: 0; + border: none; +} + +.main-content { + flex: 1; + background: #DBDBDB; + padding: 25px 43px; + overflow-y: auto; + position: relative; +} + +.cards-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(278px, 1fr)); + gap: 24px; + width: 100%; +} + +.template-card { + background: #FFFFFF; + border-radius: 8px; + padding: 24px; + height: 130px; + position: relative; + display: flex; + flex-direction: column; + box-shadow: 0 2px 4px rgba(0,0,0,0.05); + text-decoration: none; + color: inherit; +} + +.card-title { + font-weight: 500; + font-size: 24px; + color: #000; + margin-bottom: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.card-subtitle { + font-weight: 400; + font-size: 16px; + color: #000; +} + +.card-icons { + position: absolute; + top: 24px; + right: 24px; + display: flex; + gap: 12px; +} + +.icon-btn { + background: none; + border: none; + cursor: pointer; + padding: 0; + color: #000; + display: flex; + align-items: center; +} + +.add-card { + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + text-decoration: none; +} + +.plus-sign { + font-size: 48px; + color: #000; + line-height: 0; + font-weight: 300; +} + +.empty-message { + text-align: center; + margin-top: 50px; + font-size: 18px; + color: #666; + font-family: 'Roboto', sans-serif; + width: 100%; +} + +.modal-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.3); + display: flex; + justify-content: center; + padding-top: 85px; + z-index: 20; +} + +.modal-content { + width: 453px; + background: #FFFFFF; + box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.5); + padding: 20px; + display: flex; + flex-direction: column; + gap: 16px; + border-radius: 4px; +} + +.input-group { + display: flex; + align-items: center; + gap: 10px; + width: 100%; +} + +.input-label { + font-family: 'Inter', sans-serif; + font-size: 12px; + color: #000; + width: 110px; +} + +.input-field-line { + border: none; + border-bottom: 1px solid #000000; + font-family: 'Roboto', sans-serif; + font-size: 13px; + color: #000; + flex: 1; + padding: 5px 0; + outline: none; + background: transparent; +} + +.input-field-line::placeholder { + color: #8E8E8E; +} + +.btn-create { + width: 125px; + height: 35px; + background: #22C55E; + border-radius: 6px; + border: none; + color: #F0FDF4; + font-family: 'Poppins', sans-serif; + font-weight: 500; + font-size: 16px; + cursor: pointer; + margin: 20px auto 0; + display: block; +} diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css new file mode 100644 index 0000000000..3b8eb57946 --- /dev/null +++ b/app/assets/stylesheets/application.css @@ -0,0 +1,399 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 0; + font-family: "Arial", sans-serif; + height: 100vh; + overflow: hidden; +} + +.admin-container { + display: flex; + flex: 1; + height: calc(100vh - 60px); + width: 100vw; +} + +.sidebar { + width: 250px; + background-color: #fff; + border-right: 1px solid #ddd; + display: flex; + flex-direction: column; + flex-shrink: 0; + overflow-y: auto; + transition: width 0.3s ease; + white-space: nowrap; + overflow-x: hidden; +} + +.sidebar.collapsed { + width: 0; + border: none; +} + +.sidebar.collapsed .menu-text { + display: none; +} + +.sidebar.collapsed .sidebar-header { + justify-content: center; + padding: 0; + height: 60px; +} + +.sidebar.collapsed .sidebar-menu a { + display: none; +} + +.sidebar-menu { + display: flex; + flex-direction: column; +} + +.sidebar-menu a { + padding: 15px 20px; + text-decoration: none; + color: #333; + font-size: 1rem; +} + +.sidebar-menu a.active { + background-color: #6a1b57; + color: white; + border-left: 5px solid #4a0d3b; +} + +.main-content { + flex: 1; + background-color: #e0e0e0; + display: flex; + flex-direction: column; + overflow-y: auto; + position: relative; +} + +.top-navbar { + height: 60px; + background-color: white; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 30px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + z-index: 10; + flex-shrink: 0; +} + +.nav-search { + display: flex; + align-items: center; + gap: 20px; +} + +.search-input { + padding: 8px 15px; + border-radius: 20px; + border: 1px solid #ccc; + width: 250px; + outline: none; +} + +.profile-circle { + width: 40px; + height: 40px; + background-color: #6a1b57; + color: white; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; + font-size: 1.2rem; +} + +.content-area { + flex: 1; + display: flex; + justify-content: center; + align-items: center; + width: 100%; +} +.action-card { + background-color: white; + width: 450px; + padding: 40px; + border-radius: 8px; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1); + display: flex; + flex-direction: column; + gap: 15px; +} + +.btn-action { + width: 100%; + padding: 15px 0; + border-radius: 6px; + text-decoration: none; + font-weight: bold; + text-align: center; + border: none; + cursor: pointer; + font-size: 1rem; + font-family: inherit; + display: block; +} + +.btn-primary-green { + background-color: #2ecc71; + color: white; +} +.btn-primary-green:hover { + background-color: #27ae60; +} + +.btn-secondary-green { + background-color: #8ceabb; + color: white; +} +.btn-secondary-green:hover { + background-color: #7bd3a6; +} + +.flash-container { + position: fixed; + bottom: 20px; + right: 20px; + z-index: 9999; + display: flex; + flex-direction: column; + gap: 10px; +} + +.flash-message { + min-width: 200px; + max-width: 280px; + padding: 10px 15px; + border-radius: 6px; + text-align: center; + font-size: 0.85rem; + font-weight: bold; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); + opacity: 1; + transition: opacity 1s ease-out, visibility 1s ease-out; +} + +.flash-success { + background-color: #d4edda; + color: #155724; + border: 1px solid #c3e6cb; +} + +.flash-error { + background-color: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; +} + +.fade-out { + opacity: 0; + visibility: hidden; +} + +.modal-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + + background: rgba(0, 0, 0, 0.35); + + display: flex; + justify-content: center; + align-items: center; + + z-index: 20; +} + +.modal-window { + background: white; + padding: 40px; + border-radius: 16px; + width: 750px; + max-width: 95%; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12); +} + +.template-row { + display: flex; + align-items: center; + width: 100%; +} + +.template-center { + margin: 0 auto; /* pushes it to the center */ + display: flex; + align-items: center; + gap: 8px; +} + +.modal-close { + border: none; + background: none; + font-size: 28px; + cursor: pointer; + color: #555; +} + +.turmas-table { + width: 90%; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 10px; +} + +.turmas-table-header { + font-weight: bold; + font-size: 0.95rem; + display: grid; + grid-template-columns: 40px 1fr 120px; + color: #666; + width: 100%; + padding-bottom: 8px; + border-bottom: 1px solid #eee; +} + +.turmas-row { + display: grid; + grid-template-columns: 40px 1fr 120px; + align-items: center; + padding: 10px 0; + border-bottom: 1px solid #f1f1f1; +} + +.turmas-col { + display: flex; + justify-content: center; + align-items: center; +} + +.turma-checkbox { + width: 20px; + height: 20px; + accent-color: #2ecc71; + cursor: pointer; +} + +.submit-row { + display: flex; + justify-content: center; + margin-top: 20px; +} + +.submit-btn { + width: 125px; + padding: 12px 0; + background: #2ecc71; + color: white; + font-size: 1.1rem; + border-radius: 10px; + border: none; + cursor: pointer; + font-weight: bold; +} + +.submit-btn:hover { + background: #27ae60; +} + +.select-template { + padding: 8px 12px; + font-size: 15px; + border: 1px solid #ccc; + border-radius: 6px; + + background-color: white; + color: #333; + + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + + background-image: url('data:image/svg+xml;utf8,'); + background-repeat: no-repeat; + background-position: right 10px center; + background-size: 14px; + padding-right: 32px; +} + +.select-template:focus { + border-color: #888; + outline: none; + box-shadow: 0 0 5px rgba(0, 0, 0, 0.15); +} + +.main-container { + flex: 1; + display: flex; + justify-content: center; + align-items: center; + padding: 2rem; + width: 100%; + overflow-y: auto; +} +/* FORM ENVELOPE */ +.form-envelope { + background: white; + width: 100%; + max-width: 600px; + padding: 2rem; + border-radius: 12px; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1); + position: relative; +} + +/* QUESTIONS */ +.question-card { + background: #e0e0e0; + padding: 1rem; + border-radius: 8px; + margin-bottom: 1rem; +} + +.question-text { + margin: 0 0 0.5rem 0; + font-weight: bold; +} + +.text-input { + width: 100%; + padding: 0.5rem; + border-radius: 4px; + border: 1px solid #ccc; +} + +.radio-option { + margin-bottom: 0.25rem; +} + +/* SUBMIT BUTTON */ +.submit-button { + position: absolute; + bottom: -25px; + right: -80px; + width: 50px; + height: 50px; + border-radius: 50%; + background: #6a1b57; + color: white; + border: none; + font-size: 1.5rem; + display: flex; + align-items: center; + justify-content: center; +} diff --git a/app/assets/stylesheets/templates.css b/app/assets/stylesheets/templates.css new file mode 100644 index 0000000000..097561197b --- /dev/null +++ b/app/assets/stylesheets/templates.css @@ -0,0 +1,106 @@ +.templates-container { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: flex-start; + align-content: flex-start; + padding: 25px 43px; + gap: 50px; + width: 100%; + height: 100%; + background: #DBDBDB; + overflow-y: auto; +} + +.template-card { + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 24px; + gap: 8px; + width: 278px; + height: 110px; + background: #FFFFFF; + border-radius: 8px; + position: relative; + box-shadow: 0 2px 4px rgba(0,0,0,0.05); + text-decoration: none; + color: inherit; +} + +.template-card.add-card { + align-items: center; + justify-content: center; + cursor: pointer; + border: 2px dashed #ccc; +} + +.template-title { + font-family: 'Roboto', sans-serif; + font-style: normal; + font-weight: 500; + font-size: 24px; + line-height: 24px; + color: #000000; + width: 80%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.template-subtitle { + font-family: 'Roboto', sans-serif; + font-style: normal; + font-weight: 400; + font-size: 16px; + line-height: 24px; + color: #000000; +} + +.card-actions { + position: absolute; + right: 20px; + top: 24px; + display: flex; + gap: 10px; +} + +.action-icon { + width: 16px; + height: 16px; + cursor: pointer; + color: #000; + border: none; + background: none; + padding: 0; +} + +.plus-icon { + font-size: 48px; + color: #000; + font-weight: 300; + line-height: 0; + padding-bottom: 8px; +} + +.search-bar-mockup { + width: 271px; + position: relative; +} + +.search-bar-mockup input { + width: 100%; + padding: 8px 35px 8px 15px; + border-radius: 47px; + border: 1px solid #8E8E8E; + outline: none; +} + +.empty-message { + width: 100%; + text-align: center; + font-family: 'Roboto', sans-serif; + font-size: 18px; + color: #666; + margin-top: 50px; +} diff --git a/app/assets/tailwind/application.css b/app/assets/tailwind/application.css new file mode 100644 index 0000000000..af10ca04f7 --- /dev/null +++ b/app/assets/tailwind/application.css @@ -0,0 +1,21 @@ +@import "tailwindcss"; + +@source "../../views"; +@source "../../helpers"; +@source "../../javascript"; + +@theme { + /* Cores do Projeto (Extraídas do seu Config anterior) */ + --color-brand-purple: #5D1F5D; + --color-brand-green: #22C55E; + --color-bg-main: #E5E5E5; + --color-surface-white: #FFFFFF; + + /* Fonte */ + --font-sans: "Inter", sans-serif; +} + +/* Garante altura total para o layout funcionar */ +html, body { + height: 100%; +} \ No newline at end of file diff --git a/app/controllers/admins/forms_controller.rb b/app/controllers/admins/forms_controller.rb new file mode 100644 index 0000000000..78bc244542 --- /dev/null +++ b/app/controllers/admins/forms_controller.rb @@ -0,0 +1,47 @@ +class Admins::FormsController < ApplicationController + def new + # Carrega todos os templates do banco (incluindo o template de teste se estiver vazio) + @templates = Template.all + + # Carrega turmas ativas + @turmas = Turma.where(is_active: true) + end + + def create + template_id = params[:template_id] + turma_ids = params[:turma_ids] || [] + + if template_id.blank? || turma_ids.empty? + flash.now[:alert] = "Selecione um template e pelo menos uma turma." + + respond_to do |format| + format.turbo_stream do + render turbo_stream: [ + turbo_stream.remove("modal"), + turbo_stream.replace( + "flash_messages", + partial: "shared/flash" + ) + ] + end + + format.html do + redirect_to admin_management_path, alert: "Selecione um template e pelo menos uma turma." + end + end + + return + end + + # Cria formulários para cada turma selecionada + turma_ids.each do |tid| + Form.create!( + template_id: template_id, + turma_id: tid, + is_active: true + ) + end + + redirect_to admin_management_path, notice: "Formulários criados com sucesso!" + end +end diff --git a/app/controllers/admins/templates_controller.rb b/app/controllers/admins/templates_controller.rb new file mode 100644 index 0000000000..7377ce53f4 --- /dev/null +++ b/app/controllers/admins/templates_controller.rb @@ -0,0 +1,69 @@ +class Admins::TemplatesController < ApplicationController + + def index + @templates = Template.all + + # Cenário: Visualizar lista vazia + if @templates.empty? + flash.now[:notice] = "Nenhum template foi criado" + end + end + + def show + @template = Template.find(params[:id]) + end + + def new + @template = Template.new + # Cria uma questão vazia para aparecer no formulário + @template.questions.build + end + + def create + @template = Template.new(template_params) + @template.criado_por = current_user + + if @template.save + redirect_to admins_templates_path, notice: "Template criado com sucesso" + else + + flash.now[:alert] = @template.errors.map(&:message).uniq.join(", ") + render :new, status: :unprocessable_entity + end + end + + def edit + @template = Template.find(params[:id]) + end + + def update + @template = Template.find(params[:id]) + + if @template.update(template_params) + redirect_to admins_templates_path, notice: "Template atualizado com sucesso" + else + + flash.now[:alert] = @template.errors.map(&:message).uniq.join(", ") + render :edit, status: :unprocessable_entity + end + end + + def destroy + @template = Template.find(params[:id]) + if @template.destroy + redirect_to admins_templates_path, notice: "Template removido com sucesso" + else + redirect_to admins_templates_path, alert: "Não foi possível remover o template." + end + end + + private + + def template_params + params.require(:template).permit( + :titulo, + :target_audience, + questions_attributes: [:id, :text, :question_type, :options, :_destroy] + ) + end +end diff --git a/app/controllers/admins_controller.rb b/app/controllers/admins_controller.rb new file mode 100644 index 0000000000..43eb01ab23 --- /dev/null +++ b/app/controllers/admins_controller.rb @@ -0,0 +1,110 @@ +require 'csv' + +class AdminsController < ApplicationController + before_action :require_admin, except: [:management] + + def management + end + + def new_import + end + + def create_import + uploaded_file = params[:file] + + if uploaded_file.nil? + redirect_to import_sigaa_path, alert: 'Nenhum arquivo selecionado.' + return + end + + begin + SigaaImportService.new(uploaded_file.path).call + redirect_to admin_management_path, notice: 'Dados importados com sucesso!' + + rescue JSON::ParserError + redirect_to import_sigaa_path, alert: 'Erro: O arquivo enviado não é um JSON válido.' + + rescue SigaaImportService::InvalidFileError => e + redirect_to import_sigaa_path, alert: "Erro de Validação: #{e.message}" + + rescue StandardError => e + redirect_to import_sigaa_path, alert: "Ocorreu um erro inesperado: #{e.message}" + end + end + + def results + user = current_user + + admin_dept_id = user&.departamento_id || 1 + + prefixo_departamento = case admin_dept_id + when 1 then "CIC" + when 2 then "MAT" + when 3 then "EST" + else "CIC" + end + + @turmas = Turma.where("nome LIKE ?", "%(#{prefixo_departamento}%") + end + + def show_respostas + @turma = Turma.find(params[:turma_id]) + + @forms = Form.where(turma_id: @turma.id) + + @respostas = Resposta + .where(form_id: @forms.pluck(:id)) + .includes(:usuario, form: { template: :questions }) + end + + def export_csv + @turma = Turma.find(params[:turma_id]) + + forms = Form.where(turma_id: @turma.id) + + respostas = Resposta + .where(form_id: forms.pluck(:id)) + .includes(form: { template: :questions }) + + if respostas.empty? + redirect_to admin_results_path, alert: "Este formulário ainda não possui respostas." + return + end + + perguntas = forms.first.template.questions.order(:id) + + codigo_materia = @turma.nome.match(/\((.*?)\s-/)&.captures&.first || "TURMA_#{@turma.id}" + filename = "resultados_anonimos_#{codigo_materia}.csv" + + csv_data = CSV.generate(headers: true) do |csv| + + csv << ( + ["Data do Envio"] + + perguntas.map(&:text) + ) + + respostas.each do |resp| + respostas_user = (resp.answers || {}) + + csv << ( + [ + resp.created_at.strftime("%d/%m/%Y %H:%M") + ] + + perguntas.map { |q| respostas_user[q.id.to_s] || "—" } + ) + end + end + + send_data csv_data, filename: filename, type: "text/csv" + end + + private + + def require_admin + user = try(:current_user) + + if user.present? && user.profile != 'Admin' + redirect_to admin_management_path, alert: "Acesso negado" + end + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb new file mode 100644 index 0000000000..0f46bf9e26 --- /dev/null +++ b/app/controllers/application_controller.rb @@ -0,0 +1,39 @@ +class ApplicationController < ActionController::Base + # Configuração padrão do Rails 8 (mantenha) + allow_browser versions: :modern + + # Disponibiliza estes métodos para serem usados nas Views (ex: no navbar) + helper_method :current_user, :logged_in?, :admin? + + # Retorna o usuário logado buscando pela sessão + def current_user + # Se já carregou, usa a variável de instância. + # Se não, busca no banco usando o ID salvo no cookie de sessão. + @current_user ||= Usuario.find_by(id: session[:usuario_id]) if session[:usuario_id] + end + + # Retorna true se existe alguém logado + def logged_in? + !!current_user + end + + # Retorna true se o usuário logado é Admin + def admin? + logged_in? && current_user.profile == 'Admin' + end + + # Filtro de segurança para usar com 'before_action' nos controllers de Admin + def require_admin + unless admin? + flash[:alert] = "Acesso negado. Apenas administradores." + + if logged_in? + # Se é aluno tentando acessar área admin, manda pro dashboard de aluno + redirect_to avaliacoes_path + else + # Se não tá logado, manda pro login + redirect_to root_path + end + end + end +end \ No newline at end of file diff --git a/app/controllers/avaliacoes_controller.rb b/app/controllers/avaliacoes_controller.rb new file mode 100644 index 0000000000..6c45b6684a --- /dev/null +++ b/app/controllers/avaliacoes_controller.rb @@ -0,0 +1,83 @@ +require "ostruct" + +class AvaliacoesController < ApplicationController + before_action :require_user + + def index + @turmas = current_user.vinculos.includes(turma: :forms).map(&:turma) + + @turmas = @turmas.map do |turma| + OpenStruct.new( + id: turma.id, + nome: turma.nome, + semestre: turma.semestre, + forms: turma.forms.where(is_active: true).map do |form| + OpenStruct.new( + id: form.id, + titulo: form.template.titulo, + turma_nome: turma.nome, + semestre: turma.semestre + ) + end + ) + end + end + + def responder + @form = Form.includes(:template, :turma).find(params[:form_id]) + + # Verifica se o usuário participa da turma + unless current_user.vinculos.exists?(turma_id: @form.turma_id) + redirect_to avaliacoes_path, alert: "Você não tem acesso a esse formulário." + return + end + + # 🚨 Impede que responda mais de 1 vez + if Resposta.exists?(form_id: @form.id, usuario_id: current_user.id) + redirect_to avaliacoes_path, alert: "Você já respondeu este formulário." + return + end + end + + def enviar_resposta + form = Form.find(params[:form_id]) + + # Verifica permissão + unless current_user.vinculos.exists?(turma_id: form.turma_id) + redirect_to avaliacoes_path, alert: "Você não tem acesso a esse formulário." + return + end + + # 🚨 Impede que o aluno envie novamente (segurança) + if Resposta.exists?(form_id: form.id, usuario_id: current_user.id) + redirect_to avaliacoes_path, alert: "Você já respondeu este formulário." + return + end + + normalized_answers = params[:answers] || {} + + # 👉 Aqui criamos o registro dizendo que ESTE aluno respondeu + Resposta.create!( + form_id: form.id, + usuario_id: current_user.id, + answers: normalized_answers + ) + + # ❗ Nada de destruir o form — cada aluno responde o mesmo form + redirect_to avaliacoes_path, notice: "Formulário enviado com sucesso!" + end + + + private + + def answers_params + params.require(:answers).permit!.to_h + end + + + private + + def require_user + redirect_to "/", alert: "Fake user not configured." unless current_user + 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/first_access_controller.rb b/app/controllers/first_access_controller.rb new file mode 100644 index 0000000000..8fe96858cc --- /dev/null +++ b/app/controllers/first_access_controller.rb @@ -0,0 +1,33 @@ +class FirstAccessController < ApplicationController + # GET /first_access/:id/edit + def edit + @user = Usuario.find(params[:id]) + + # RN-DS-01 (Indireto): Se o usuário já estiver ativo, o link "expira" + if @user.status == true + redirect_to root_path, alert: "Sua conta já foi ativada. Faça login." + end + end + + # PATCH /first_access/:id + def update + @user = Usuario.find(params[:id]) + + # Verifica se as senhas batem e se atendem aos requisitos (RN-DS-02, RN-DS-03) + # E muda o status para true (RN-DS-04) + if @user.update(user_params.merge(status: true)) + # Loga o usuário automaticamente após definir a senha + session[:usuario_id] = @user.id + redirect_to avaliacoes_path, notice: "Senha definida com sucesso! Bem-vindo." + else + # Se der erro (senha fraca ou não coincidir), volta para a tela de edição + render :edit, status: :unprocessable_entity + end + end + + private + + def user_params + params.require(:usuario).permit(:password, :password_confirmation) + end +end \ No newline at end of file diff --git a/app/controllers/forms_controller.rb b/app/controllers/forms_controller.rb new file mode 100644 index 0000000000..eb878195f3 --- /dev/null +++ b/app/controllers/forms_controller.rb @@ -0,0 +1,31 @@ +class FormsController < ApplicationController + def new + @form = Form.new + end + + def create + # Cenário Triste: Tentativa de criar sem selecionar público + if params[:target_audience].blank? + redirect_to new_form_path, alert: "Selecione o público-alvo da avaliação" + return + end + + # Lógica para Docentes (sem turma) e Discentes (com turma) + if params[:target_audience] == "Discentes" && params[:turma_id].blank? + #BDD foca no público-alvo no cenário triste simples + end + + # Criação simulada + @form = Form.new( + template_id: params[:template_id], + turma_id: params[:turma_id], + is_active: true + ) + + if @form.save + redirect_to forms_path, notice: "Formulário enviado com sucesso" + else + render :new + end + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb new file mode 100644 index 0000000000..4ce36ee723 --- /dev/null +++ b/app/controllers/sessions_controller.rb @@ -0,0 +1,53 @@ +class SessionsController < ApplicationController + + # ADICIONE ESTA LINHA: Define que este controller usa o layout isolado + layout 'auth' + + def new + end + + def create + # RN-L-01: Autenticar por email OU matrícula + login = params[:login] + user = Usuario.find_by(email: login) || Usuario.find_by(matricula: login) + + # RN-L-06: Só permite login se status for true (Ativo) + if user + # 1. Sem senha definida (senha é nil) + if user.password_digest.nil? + redirect_to edit_first_access_path(user), notice: "Bem-vindo! Por favor, defina sua senha para continuar." + return + end + + # 2. Conta inativa (status é false) + if user.status == false + redirect_to edit_first_access_path(user), alert: "Sua conta ainda não foi ativada." + return + end + # 3. Tentativa de Login + if user.authenticate(params[:password]) + session[:usuario_id] = user.id + redirect_to destination_by_profile(user) + else + flash.now[:alert] = "Senha incorreta" + render :new + end + else + # Usuário não encontrado + flash.now[:alert] = "E-mail ou matrícula não encontrados" + render :new + end + end + + def destroy + session[:usuario_id] = nil + redirect_to root_path, notice: "Logout realizado com sucesso" + end + + private + + def destination_by_profile(user) + # RN-L-04 e RN-L-05 + user.profile == 'Admin' ? admin_management_path : avaliacoes_path + 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/javascript/application.js b/app/javascript/application.js new file mode 100644 index 0000000000..0d7b49404c --- /dev/null +++ b/app/javascript/application.js @@ -0,0 +1,3 @@ +// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails +import "@hotwired/turbo-rails" +import "controllers" diff --git a/app/javascript/controllers/application.js b/app/javascript/controllers/application.js new file mode 100644 index 0000000000..1213e85c7a --- /dev/null +++ b/app/javascript/controllers/application.js @@ -0,0 +1,9 @@ +import { Application } from "@hotwired/stimulus" + +const application = Application.start() + +// Configure Stimulus development experience +application.debug = false +window.Stimulus = application + +export { application } diff --git a/app/javascript/controllers/hello_controller.js b/app/javascript/controllers/hello_controller.js new file mode 100644 index 0000000000..5975c0789d --- /dev/null +++ b/app/javascript/controllers/hello_controller.js @@ -0,0 +1,7 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + connect() { + this.element.textContent = "Hello World!" + } +} diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js new file mode 100644 index 0000000000..1156bf8362 --- /dev/null +++ b/app/javascript/controllers/index.js @@ -0,0 +1,4 @@ +// Import and register all your controllers from the importmap via controllers/**/*_controller +import { application } from "controllers/application" +import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading" +eagerLoadControllersFrom("controllers", application) diff --git a/app/javascript/controllers/modal_controller.js b/app/javascript/controllers/modal_controller.js new file mode 100644 index 0000000000..416f854544 --- /dev/null +++ b/app/javascript/controllers/modal_controller.js @@ -0,0 +1,17 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + close() { + this.element.remove(); + } + + closeBackground(event) { + if (event.target === this.element) { + this.close(); + } + } + + stop(event) { + event.stopPropagation(); + } +} diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb new file mode 100644 index 0000000000..d394c3d106 --- /dev/null +++ b/app/jobs/application_job.rb @@ -0,0 +1,7 @@ +class ApplicationJob < ActiveJob::Base + # Automatically retry jobs that encountered a deadlock + # retry_on ActiveRecord::Deadlocked + + # Most jobs are safe to ignore if the underlying records are no longer available + # discard_on ActiveJob::DeserializationError +end diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb new file mode 100644 index 0000000000..3c34c8148f --- /dev/null +++ b/app/mailers/application_mailer.rb @@ -0,0 +1,4 @@ +class ApplicationMailer < ActionMailer::Base + default from: "from@example.com" + layout "mailer" +end diff --git a/app/models/application_record.rb b/app/models/application_record.rb new file mode 100644 index 0000000000..b63caeb8a5 --- /dev/null +++ b/app/models/application_record.rb @@ -0,0 +1,3 @@ +class ApplicationRecord < ActiveRecord::Base + primary_abstract_class +end diff --git a/app/models/concerns/.keep b/app/models/concerns/.keep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app/models/form.rb b/app/models/form.rb new file mode 100644 index 0000000000..f605754de6 --- /dev/null +++ b/app/models/form.rb @@ -0,0 +1,4 @@ +class Form < ApplicationRecord + belongs_to :template + belongs_to :turma +end diff --git a/app/models/question.rb b/app/models/question.rb new file mode 100644 index 0000000000..478fb2eb2c --- /dev/null +++ b/app/models/question.rb @@ -0,0 +1,20 @@ +class Question < ApplicationRecord + belongs_to :template + + # options é ARRAY no banco (via JSON) + attribute :options, :json, default: [] + + validates :text, presence: true + validates :question_type, inclusion: { in: %w[text radio] } + + + def options=(value) + if value.is_a?(String) + super(value.split(",").map(&:strip)) + elsif value.nil? + super([]) + else + super(value) + end + end +end diff --git a/app/models/resposta.rb b/app/models/resposta.rb new file mode 100644 index 0000000000..959d0e8a88 --- /dev/null +++ b/app/models/resposta.rb @@ -0,0 +1,9 @@ +class Resposta < ApplicationRecord + belongs_to :form + belongs_to :usuario + + # answers será um hash armazenado no campo JSON; garantimos um hash vazio por padrão + def answers_hash + (self.answers || {}).with_indifferent_access + end +end diff --git a/app/models/subject.rb b/app/models/subject.rb new file mode 100644 index 0000000000..a594dac65a --- /dev/null +++ b/app/models/subject.rb @@ -0,0 +1,3 @@ +class Subject < ApplicationRecord + has_many :turmas +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..0f99a95d2b --- /dev/null +++ b/app/models/template.rb @@ -0,0 +1,24 @@ +class Template < ApplicationRecord + belongs_to :criado_por, class_name: 'Usuario', optional: true + has_many :forms, dependent: :restrict_with_error + + has_many :questions, dependent: :destroy + + accepts_nested_attributes_for :questions, allow_destroy: true, reject_if: :all_blank + + validates :titulo, presence: { message: "O campo Título é obrigatório" } + validates :titulo, format: { + with: /\A[a-zA-Z0-9\s\-\.]+\z/, + message: "Formato de título inválido" + }, allow_blank: true + + validate :must_have_questions + + private + + def must_have_questions + if questions.reject(&:marked_for_destruction?).empty? + errors.add(:base, "O template deve conter pelo menos uma questão") + end + end +end diff --git a/app/models/turma.rb b/app/models/turma.rb new file mode 100644 index 0000000000..184e29d81b --- /dev/null +++ b/app/models/turma.rb @@ -0,0 +1,5 @@ +class Turma < ApplicationRecord + has_many :forms + has_many :vinculos, dependent: :destroy + has_many :usuarios, through: :vinculos +end diff --git a/app/models/usuario.rb b/app/models/usuario.rb new file mode 100644 index 0000000000..94546b1708 --- /dev/null +++ b/app/models/usuario.rb @@ -0,0 +1,17 @@ +class Usuario < ApplicationRecord + has_many :vinculos, dependent: :destroy + has_many :turmas, through: :vinculos + has_secure_password validations: false + # Validações básicas para o teste passar + validates :password, presence: true, if: -> { status == true || password.present? } + validate :password_complexity, if: -> { password.present? } + validates :matricula, presence: true, uniqueness: true + validates :email, presence: true, uniqueness: true + validates :profile, presence: true + validates :nome, presence: true + + def password_complexity + return if password =~ /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9]).{8,}$/ + errors.add :password, 'deve ter no mínimo 8 caracteres, maiúsculas, minúsculas e números' + end +end diff --git a/app/models/vinculo.rb b/app/models/vinculo.rb new file mode 100644 index 0000000000..21e563a333 --- /dev/null +++ b/app/models/vinculo.rb @@ -0,0 +1,4 @@ +class Vinculo < ApplicationRecord + belongs_to :usuario + belongs_to :turma +end diff --git a/app/services/sigaa_import_service.rb b/app/services/sigaa_import_service.rb new file mode 100644 index 0000000000..3fc1eed5c9 --- /dev/null +++ b/app/services/sigaa_import_service.rb @@ -0,0 +1,139 @@ +require 'json' + +class SigaaImportService + class InvalidFileError < StandardError; end + + REQUIRED_SUBJECT_KEYS = %w[code name class] + REQUIRED_CLASS_KEYS = %w[classCode semester time] + REQUIRED_MEMBER_FILE_KEYS = %w[code classCode semester dicente] + + # Chaves específicas para Alunos + REQUIRED_STUDENT_KEYS = %w[nome matricula curso email ocupacao formacao] + + # Chaves específicas para Professores (Docente não tem 'curso', tem 'departamento') + REQUIRED_TEACHER_KEYS = %w[nome usuario email ocupacao formacao departamento] + + def initialize(file_path) + @file_path = file_path + end + + def call + file_content = File.read(@file_path) + + begin + json_data = JSON.parse(file_content) + rescue JSON::ParserError + raise InvalidFileError, "O arquivo não é um JSON válido." + end + + unless json_data.is_a?(Array) + raise InvalidFileError, "O JSON deve ser uma lista (Array) de objetos." + end + + ActiveRecord::Base.transaction do + json_data.each_with_index do |entry, index| + if entry.key?('class') + process_classes_file(entry, index) + elsif entry.key?('dicente') + process_members_file(entry, index) + else + raise InvalidFileError, "Objeto não reconhecido na linha #{index + 1}." + end + end + end + + { success: true, message: "Importação realizada com sucesso" } + end + + private + + def validate_keys!(data, required_keys, context_message) + required_keys.each do |key| + value = data[key] + if value.nil? || (value.respond_to?(:strip) && value.strip.empty?) + raise InvalidFileError, "Erro #{context_message}: O campo obrigatório '#{key}' está ausente ou vazio." + end + end + end + + def process_classes_file(entry, index) + validate_keys!(entry, REQUIRED_SUBJECT_KEYS, "na Matéria (item #{index + 1})") + validate_keys!(entry['class'], REQUIRED_CLASS_KEYS, "na Turma") + + nome_completo = "#{entry['name']} (#{entry['code']} - #{entry['class']['classCode']})" + semestre = entry['class']['semester'] + + turma = Turma.find_or_initialize_by(nome: nome_completo, semestre: semestre) + turma.is_active = true + turma.save! + end + + def process_members_file(entry, index) + validate_keys!(entry, REQUIRED_MEMBER_FILE_KEYS, "no cabeçalho da Turma") + + # 1. Validação dos Alunos + if entry['dicente'] + entry['dicente'].each_with_index do |student_data, s_index| + validate_keys!(student_data, REQUIRED_STUDENT_KEYS, "no Aluno ##{s_index + 1}") + + # 'matricula' pode vir como integer ou string no JSON + unless student_data['matricula'].to_s.match?(/^\d+$/) + raise InvalidFileError, "Erro no Aluno ##{s_index + 1}: A matrícula deve conter apenas números." + end + end + end + + # 2. Validação do Docente (NOVO) + if entry['docente'] + validate_keys!(entry['docente'], REQUIRED_TEACHER_KEYS, "no Docente") + end + + # Busca a turma correspondente + turma = Turma.where(semestre: entry['semester']).find do |t| + t.nome.include?(entry['code']) && t.nome.include?(entry['classCode']) + end + + return unless turma + + # --- PROCESSAMENTO UNIFICADO (Alunos e Professores) --- + + # Processa Docente (Papel 1) + if entry['docente'] + process_single_user(entry['docente'], 'Professor', turma, 1) + end + + # Processa Discentes (Papel 0) + if entry['dicente'] + entry['dicente'].each do |student_data| + process_single_user(student_data, 'Aluno', turma, 0) + end + end + end + + # Método genérico que aplica a regra de Primeiro Acesso para TODOS + def process_single_user(data, profile, turma, papel) + # Tenta 'matricula' (alunos) ou 'usuario' (docentes/alunos) + matricula = data['matricula'] || data['usuario'] + return if matricula.blank? + + usuario = Usuario.find_or_initialize_by(matricula: matricula.to_s) + is_new = usuario.new_record? + + usuario.nome = data['nome'] + usuario.email = data['email'] + usuario.profile = profile + usuario.departamento_id = 1 + + # REGRA DE PRIMEIRO ACESSO (Válida para Aluno e Professor): + # Se é novo, nasce sem senha (nil) e com status false (pendente). + if is_new + usuario.status = false + end + + usuario.save! + + Vinculo.find_or_create_by(usuario: usuario, turma: turma) do |v| + v.papel_turma = papel + end + end +end \ No newline at end of file diff --git a/app/views/admins/forms/_form.html.erb b/app/views/admins/forms/_form.html.erb new file mode 100644 index 0000000000..784b65961c --- /dev/null +++ b/app/views/admins/forms/_form.html.erb @@ -0,0 +1,49 @@ +