diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..6ad66bb7e2 --- /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. +project/db/schema.rb linguist-generated + +# Mark any vendored files as having been vendored. +project/vendor/* linguist-vendored +project/config/credentials/*.yml.enc diff=rails_credentials +project/config/credentials.yml.enc diff=rails_credentials diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..262523cdd0 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: +- package-ecosystem: bundler + directory: "/project" + schedule: + interval: weekly + open-pull-requests-limit: 10 +- package-ecosystem: github-actions + directory: "/project" + 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..dc38e4fbf2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,139 @@ +name: CI + +defaults: + run: + working-directory: project + +on: + pull_request: + push: + branches: [ main, sprint-3 ] + +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 + working-directory: project + + - 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 + working-directory: project + + - 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 + working-directory: project + + - name: Prepare RuboCop cache + uses: actions/cache@v4 + env: + DEPENDENCIES_HASH: ${{ hashFiles('project/.ruby-version', '**/.rubocop.yml', '**/.rubocop_todo.yml', 'project/Gemfile.lock') }} + with: + path: project/${{ 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: Checkout code + uses: actions/checkout@v6 + + - name: Install packages + run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libvips + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + working-directory: project + + - name: Run tests + env: + RAILS_ENV: test + # RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }} + # REDIS_URL: redis://localhost:6379/0 + run: bin/rails db:test:prepare && bundle exec rspec + + 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: Checkout code + uses: actions/checkout@v6 + + - name: Install packages + run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libvips + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + working-directory: project + + - 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 && bundle exec cucumber + + - name: Keep screenshots from failed system tests + uses: actions/upload-artifact@v4 + if: failure() + with: + name: screenshots + path: ${{ github.workspace }}/project/tmp/screenshots + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..1ed0afd7ac --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# 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. +project/.bundle + +# Ignore all environment files. +project/.env* + +# Ignore all logfiles and tempfiles. +project/log/* +project/tmp/* +!project/log/.keep +!project/tmp/.keep + +# Ignore pidfiles, but keep the directory. +project/tmp/pids/* +!project/tmp/pids/ +!project/tmp/pids/.keep + +# Ignore storage (uploaded files in development and any SQLite databases). +project/storage/* +!project/storage/.keep +project/tmp/storage/* +!project/tmp/storage/ +!project/tmp/storage/.keep + +project/public/assets + +# Ignore key files for decrypting credentials and more. +project/config/*.key + +AGENTS.md +PROJECT_CONTEXT.md + +CLAUDE.md +.claude/ + +codex/ \ No newline at end of file diff --git a/README.md b/README.md index 9d7fe1bf53..6dcede49d5 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,94 @@ # CAMAAR -Sistema para avaliação de atividades acadêmicas remotas do CIC + +**CAMAAR** (Sistema para Avaliação de Atividades Acadêmicas Remotas) é uma aplicação web desenvolvida para o **CIC (Departamento de Ciência da Computação)** que automatiza a criação, aplicação e análise de questionários de avaliação de disciplinas e turmas. O sistema substitui processos manuais de coleta de feedback acadêmico, integrando-se aos dados institucionais do **SIGAA** para gerenciar turmas, docentes e discentes. + +Projeto desenvolvido como parte da disciplina de Engenharia de Software (forked de `EngSwCIC/CAMAAR`), ao longo de três sprints de desenvolvimento documentadas na [Wiki do repositório](https://github.com/GabLopes12/CAMAAR/wiki). + +## Equipe + +- Diego Guedes Gontijo +- Gabriel Lopes Soares Damasceno +- Luiz Felipe Ducat — *Product Owner* +- Rafael Dias Ghiorzi — *Scrum Master* + +## Funcionalidades + +### Importação e gestão de dados (SIGAA) +- Importação de turmas, departamentos, docentes e discentes a partir de arquivos do SIGAA +- Detecção e relatório de inconsistências durante a importação +- Provisionamento automático de contas de usuário, com notificação por e-mail +- Definição e recuperação de senha por token enviado por e-mail + +### Templates e formulários +- Criação de templates de questionário reutilizáveis pelo administrador +- Geração de formulários de avaliação a partir de templates, distribuídos para turmas específicas +- Exclusão de templates sem afetar formulários já gerados a partir deles + +### Respostas e submissões +- Listagem de formulários pendentes e respondidos para cada participante (docente/discente) +- Submissão de respostas a questionários pelos participantes das turmas +- Exportação dos resultados de um formulário em CSV para análise externa + +### Autenticação e perfis +- Login por e-mail ou matrícula, com senha segura (`has_secure_password`) +- Controle de acesso por papel (administrador, docente, discente) +- Dashboard inicial com visão geral das atividades pendentes + +## Tecnologias + +| Categoria | Tecnologia | +|---|---| +| Linguagem / Framework | Ruby 3.4.9, Rails ~> 8.1 | +| Frontend | Hotwire (Turbo + Stimulus), Propshaft | +| Banco de dados | SQLite | +| Servidor de aplicação | Puma | +| Autenticação | bcrypt (`has_secure_password`) | +| Testes unitários/integração | RSpec, FactoryBot, SimpleCov | +| Testes BDD | Cucumber (Gherkin) | +| Qualidade de código | RuboCop, Brakeman, RubyCritic | +| Documentação | RDoc | +| Containerização | Docker | +| Deploy | Kamal | + +## Como executar o projeto + +A aplicação Rails está em [`project/`](project). + +### Pré-requisitos +- Ruby 3.4.9 +- Bundler + +### Instalação local + +```bash +cd project +bundle install +bin/rails db:setup +bin/rails server +``` + +A aplicação ficará disponível em `http://localhost:3000`. + +### Via Docker + +```bash +cd project +docker build -f Dockerfile.dev -t camaar-dev . +docker run -p 3000:3000 camaar-dev +``` + +### Executando os testes + +```bash +cd project +bundle exec rspec # testes unitários e de integração +bundle exec cucumber # cenários BDD +``` + +## Histórico de desenvolvimento + +O projeto foi construído em três sprints, documentadas na wiki: + +- [Sprint 1](https://github.com/GabLopes12/CAMAAR/wiki/Documenta%C3%A7%C3%A3o-e-Implementa%C3%A7%C3%A3o:-Sprint-1) — definição do escopo, padrões de desenvolvimento e estrutura inicial do projeto (login, templates, formulários, importação SIGAA, exportação CSV). +- [Sprint 2](https://github.com/GabLopes12/CAMAAR/wiki/Documenta%C3%A7%C3%A3o-e-Implementa%C3%A7%C3%A3o:-Sprint-2) — implementação das funcionalidades principais: templates de formulário, submissão de respostas, exportação CSV, autenticação e sincronização com o SIGAA. +- [Sprint 3](https://github.com/GabLopes12/CAMAAR/wiki/Documenta%C3%A7%C3%A3o-e-Implementa%C3%A7%C3%A3o:-Sprint-3) — refino e qualidade: redução de complexidade ciclomática, eliminação de violações de ABC Score, ampliação da cobertura de testes (RSpec/SimpleCov) e documentação via RDoc. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000..17dce87c13 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +services: + web: + build: + context: ./project + dockerfile: Dockerfile.dev + ports: + - "3000:3000" + volumes: + - ./project:/rails + - ./class_members.json:/class_members.json:ro + - ./classes.json:/classes.json:ro + - bundle_cache:/usr/local/bundle + environment: + RAILS_ENV: development + +volumes: + bundle_cache: diff --git a/project/.dockerignore b/project/.dockerignore new file mode 100644 index 0000000000..325bfc036d --- /dev/null +++ b/project/.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/project/.document b/project/.document new file mode 100644 index 0000000000..1db7bfa655 --- /dev/null +++ b/project/.document @@ -0,0 +1,7 @@ +# Ruby production code documented by RDoc. +app/controllers +app/helpers +app/jobs +app/mailers +app/models +app/services diff --git a/project/.gitignore b/project/.gitignore new file mode 100644 index 0000000000..404abb2212 --- /dev/null +++ b/project/.gitignore @@ -0,0 +1 @@ +coverage/ diff --git a/project/.kamal/hooks/docker-setup.sample b/project/.kamal/hooks/docker-setup.sample new file mode 100755 index 0000000000..2fb07d7d7a --- /dev/null +++ b/project/.kamal/hooks/docker-setup.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Docker set up on $KAMAL_HOSTS..." diff --git a/project/.kamal/hooks/post-app-boot.sample b/project/.kamal/hooks/post-app-boot.sample new file mode 100755 index 0000000000..70f9c4bc95 --- /dev/null +++ b/project/.kamal/hooks/post-app-boot.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Booted app version $KAMAL_VERSION on $KAMAL_HOSTS..." diff --git a/project/.kamal/hooks/post-deploy.sample b/project/.kamal/hooks/post-deploy.sample new file mode 100755 index 0000000000..fd364c2a77 --- /dev/null +++ b/project/.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/project/.kamal/hooks/post-proxy-reboot.sample b/project/.kamal/hooks/post-proxy-reboot.sample new file mode 100755 index 0000000000..1435a677f2 --- /dev/null +++ b/project/.kamal/hooks/post-proxy-reboot.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Rebooted kamal-proxy on $KAMAL_HOSTS" diff --git a/project/.kamal/hooks/pre-app-boot.sample b/project/.kamal/hooks/pre-app-boot.sample new file mode 100755 index 0000000000..45f7355045 --- /dev/null +++ b/project/.kamal/hooks/pre-app-boot.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Booting app version $KAMAL_VERSION on $KAMAL_HOSTS..." diff --git a/project/.kamal/hooks/pre-build.sample b/project/.kamal/hooks/pre-build.sample new file mode 100755 index 0000000000..c5a55678b2 --- /dev/null +++ b/project/.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/project/.kamal/hooks/pre-connect.sample b/project/.kamal/hooks/pre-connect.sample new file mode 100755 index 0000000000..77744bdca8 --- /dev/null +++ b/project/.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/project/.kamal/hooks/pre-deploy.sample b/project/.kamal/hooks/pre-deploy.sample new file mode 100755 index 0000000000..05b3055b72 --- /dev/null +++ b/project/.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/project/.kamal/hooks/pre-proxy-reboot.sample b/project/.kamal/hooks/pre-proxy-reboot.sample new file mode 100755 index 0000000000..061f8059e6 --- /dev/null +++ b/project/.kamal/hooks/pre-proxy-reboot.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Rebooting kamal-proxy on $KAMAL_HOSTS..." diff --git a/project/.kamal/secrets b/project/.kamal/secrets new file mode 100644 index 0000000000..b3089d6f5a --- /dev/null +++ b/project/.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/project/.rdoc_options b/project/.rdoc_options new file mode 100644 index 0000000000..37e191c943 --- /dev/null +++ b/project/.rdoc_options @@ -0,0 +1,4 @@ +--- +encoding: UTF-8 +title: CAMAAR - Documentação do Código +visibility: :private diff --git a/project/.rspec b/project/.rspec new file mode 100644 index 0000000000..5be63fcb08 --- /dev/null +++ b/project/.rspec @@ -0,0 +1,2 @@ +--require spec_helper +--format documentation diff --git a/project/.rubocop.yml b/project/.rubocop.yml new file mode 100644 index 0000000000..dd0527f061 --- /dev/null +++ b/project/.rubocop.yml @@ -0,0 +1,15 @@ +# 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 + +AllCops: + Exclude: + - "lib/tasks/cucumber.rake" + +Metrics/AbcSize: + Max: 15 \ No newline at end of file diff --git a/project/.ruby-version b/project/.ruby-version new file mode 100644 index 0000000000..61a52c9149 --- /dev/null +++ b/project/.ruby-version @@ -0,0 +1 @@ +ruby-3.4.9 diff --git a/project/Dockerfile b/project/Dockerfile new file mode 100644 index 0000000000..633d3305bc --- /dev/null +++ b/project/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 project . +# docker run -d -p 80:80 -e RAILS_MASTER_KEY= --name project project + +# 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/project/Dockerfile.dev b/project/Dockerfile.dev new file mode 100644 index 0000000000..283ec58229 --- /dev/null +++ b/project/Dockerfile.dev @@ -0,0 +1,20 @@ +ARG RUBY_VERSION=3.4.9 +FROM docker.io/library/ruby:$RUBY_VERSION-slim + +WORKDIR /rails + +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y \ + build-essential curl git libffi-dev libvips libyaml-dev pkg-config sqlite3 && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +ENV RAILS_ENV="development" \ + BUNDLE_PATH="/usr/local/bundle" + +COPY Gemfile Gemfile.lock ./ +RUN bundle install + +COPY . . + +EXPOSE 3000 +CMD ["./bin/rails", "server", "-b", "0.0.0.0"] diff --git a/project/Gemfile b/project/Gemfile new file mode 100644 index 0000000000..a660beccb7 --- /dev/null +++ b/project/Gemfile @@ -0,0 +1,80 @@ +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 ] +# Ruby 4 no longer ships fiddle as a default gem; Rails console uses it through reline on Windows. +gem "fiddle" + +# Use the database-backed adapters for Rails.cache, Active Job, and Action Cable +gem "solid_cache" +gem "solid_queue" +gem "solid_cable" + +# Reduces boot times through caching; required in config/boot.rb +gem "bootsnap", require: false + +# Deploy this application anywhere as a Docker container [https://kamal-deploy.org] +gem "kamal", require: false + +# Add HTTP asset caching/compression and X-Sendfile acceleration to Puma [https://github.com/basecamp/thruster/] +gem "thruster", require: false + +# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] +gem "image_processing", "~> 1.2" + +group :development, :test do + # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem + gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" + + # Audits gems for known security defects (use config/bundler-audit.yml to ignore issues) + gem "bundler-audit", require: false + + # Static analysis for security vulnerabilities [https://brakemanscanner.org/] + gem "brakeman", require: false + + # Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/] + gem "rubocop-rails-omakase", require: false + + gem "factory_bot_rails" + gem "rspec-rails", "~> 8.0" +end + +group :development do + # Use console on exceptions pages [https://github.com/rails/web-console] + gem "web-console" + + gem "rubycritic", require: false + # Generate API documentation for the application source code + gem "rdoc", "~> 7.2", require: false +end + +group :test do + # Use system testing [https://guides.rubyonrails.org/testing.html#system-testing] + gem "capybara" + gem "selenium-webdriver" + gem "cucumber-rails", require: false + gem "database_cleaner" + gem "simplecov", require: false +end + +gem "csv", "~> 3.3" diff --git a/project/Gemfile.lock b/project/Gemfile.lock new file mode 100644 index 0000000000..fb021dba50 --- /dev/null +++ b/project/Gemfile.lock @@ -0,0 +1,792 @@ +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) + bigdecimal (4.1.2) + bindex (0.8.1) + bootsnap (1.24.5) + msgpack (~> 1.2) + brakeman (8.0.5) + 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.7) + connection_pool (3.0.2) + crass (1.0.7) + csv (3.3.5) + 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.1) + 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 (2.1.0) + database_cleaner-active_record (>= 2, < 3) + database_cleaner-active_record (2.2.2) + activerecord (>= 5.a) + database_cleaner-core (~> 2.0) + database_cleaner-core (2.0.1) + date (3.5.1) + debug (1.11.1) + irb (~> 1.10) + reline (>= 0.3.8) + descendants_tracker (0.0.4) + thread_safe (~> 0.3, >= 0.3.1) + diff-lcs (1.6.2) + docile (1.4.1) + dotenv (3.2.0) + drb (2.2.3) + dry-configurable (1.4.0) + dry-core (~> 1.0) + 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-x64-mingw-ucrt) + ffi (1.17.4-x86_64-linux-gnu) + ffi (1.17.4-x86_64-linux-musl) + fiddle (1.1.8) + 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.3) + multi_test (1.1.0) + net-imap (0.6.4.1) + 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.4-aarch64-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.4-aarch64-linux-musl) + racc (~> 1.4) + nokogiri (1.19.4-arm-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.4-arm-linux-musl) + racc (~> 1.4) + nokogiri (1.19.4-x64-mingw-ucrt) + racc (~> 1.4) + nokogiri (1.19.4-x86_64-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.4-x86_64-linux-musl) + racc (~> 1.4) + ostruct (0.6.3) + parallel (2.1.0) + parser (3.3.11.1) + ast (~> 2.4.1) + racc + path_expander (2.0.1) + pp (0.6.3) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) + propshaft (1.3.2) + actionpack (>= 7.0.0) + activesupport (>= 7.0.0) + rack + psych (5.3.1) + date + stringio + public_suffix (7.0.5) + puma (8.0.2) + 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) + 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 (4.0.0) + 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-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-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.2) + +PLATFORMS + aarch64-linux + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + 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 (~> 3.3) + cucumber-rails + database_cleaner + debug + factory_bot_rails + fiddle + image_processing (~> 1.2) + importmap-rails + jbuilder + kamal + propshaft + puma (>= 5.0) + rails (~> 8.1.3) + rdoc (~> 7.2) + rspec-rails (~> 8.0) + rubocop-rails-omakase + rubycritic + selenium-webdriver + simplecov + solid_cable + solid_cache + solid_queue + sqlite3 (>= 2.1) + stimulus-rails + thruster + turbo-rails + tzinfo-data + web-console + +CHECKSUMS + action_text-trix (2.1.19) sha256=7012f59421009cf284aa651294896414d653a61a2417c9b8714c8476d2f74009 + actioncable (8.1.3) sha256=e5bc7f75e44e6a22de29c4f43176927c3a9ce4824464b74ed18d8226e75a80f0 + actionmailbox (8.1.3) sha256=df7da474eaa0e70df4ed5a6fef66eb3b3b0f2dbf7f14518deee8d77f1b4aae59 + actionmailer (8.1.3) sha256=831f724891bb70d0aaa4d76581a6321124b6a752cb655c9346aae5479318448d + actionpack (8.1.3) sha256=af998cae4d47c5d581a2cc363b5c77eb718b7c4b45748d81b1887b25621c29a3 + actiontext (8.1.3) sha256=d291019c00e1ea9e6463011fa214f6081a56d7b9a1d224e7d3f6384c1dafc7d2 + actionview (8.1.3) sha256=1347c88c7f3edb38100c5ce0e9fb5e62d7755f3edc1b61cce2eb0b2c6ea2fd5d + activejob (8.1.3) sha256=a149b1766aa8204c3c3da7309e4becd40fcd5529c348cffbf6c9b16b565fe8d3 + activemodel (8.1.3) sha256=90c05cbe4cef3649b8f79f13016191ea94c4525ce4a5c0fb7ef909c4b91c8219 + activerecord (8.1.3) sha256=8003be7b2466ba0a2a670e603eeb0a61dd66058fccecfc49901e775260ac70ab + activestorage (8.1.3) sha256=0564ce9309143951a67615e1bb4e090ee54b8befed417133cae614479b46384d + activesupport (8.1.3) sha256=21a5e0dfbd4c3ddd9e1317ec6a4d782fa226e7867dc70b0743acda81a1dca20e + addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af + ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 + axiom-types (0.1.1) sha256=c1ff113f3de516fa195b2db7e0a9a95fd1b08475a502ff660d04507a09980383 + base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + bcrypt (3.1.22) sha256=1f0072e88c2d705d94aff7f2c5cb02eb3f1ec4b8368671e19112527489f29032 + bcrypt_pbkdf (1.1.2) sha256=c2414c23ce66869b3eb9f643d6a3374d8322dfb5078125c82792304c10b94cf6 + bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd + bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e + bootsnap (1.24.5) sha256=36b677448524d279b470469aabd5dff4a980e3fa4931a0df68da4a500eb1b6c4 + brakeman (8.0.5) sha256=03735f9690d3fd4b32d66aacbf0a6d15a84266bdd06b32c05c8ecc8f6021d2be + builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f + bundler-audit (0.9.3) sha256=81c8766c71e47d0d28a0f98c7eed028539f21a6ea3cd8f685eb6f42333c9b4e9 + capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef + childprocess (5.1.0) sha256=9a8d484be2fd4096a0e90a0cd3e449a05bc3aa33f8ac9e4d6dcef6ac1455b6ec + coercible (1.0.0) sha256=5081ad24352cc8435ce5472bc2faa30260c7ea7f2102cc6a9f167c4d9bffaadc + concurrent-ruby (1.3.7) sha256=4412caec3a5ea2e5fdc52076724c071a81f2c0593d83b2ac8cbb8ca63b3151b0 + connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a + crass (1.0.7) sha256=94868719948664c89ddcaf0a37c65048413dfcb1c869470a5f7a7ceb5390b295 + csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f + cucumber (10.2.0) sha256=fdedbd31ecf40858b60f04853f2aa15c44f5c30bbac29c6a227fa1e7005a8158 + cucumber-ci-environment (11.0.0) sha256=0df79a9e1d0b015b3d9def680f989200d96fef206f4d19ccf86a338c4f71d1e2 + cucumber-core (16.2.0) sha256=592b58a95cf42feef8e5a349f68e363784ba3b6568ffbcf6776e38e136cf970b + cucumber-cucumber-expressions (19.0.1) sha256=648ec09045190d818fb797af46e1648148599fd67a086a34a7f0e647d9e36c8c + cucumber-gherkin (39.1.0) sha256=aed12a0c955d8563d80a012633c1a72075525f4d64d4cc983001df2181b379ed + cucumber-html-formatter (22.3.0) sha256=f9768ed05588dbd73a5f3824c2cc648bd86b00206e6972d743af8051281d0729 + cucumber-messages (32.3.1) sha256=ddc88e4c1cf7afb96c06005b92a4a6f221a2fa435a8b4ca04677d215fd82771c + cucumber-rails (4.0.1) sha256=bd3513ec47dc06188cc05703648cbc3560fb115f3f5cfb8b616065b4d6e8024d + cucumber-tag-expressions (8.1.0) sha256=9bd8c4b6654f8e5bf2a9c99329b6f32136a75e50cd39d4cfb3927d0fa9f52e21 + database_cleaner (2.1.0) sha256=1dcba26e3b1576da692fc6bac10136a4744da5bcc293d248aae19640c65d89cd + database_cleaner-active_record (2.2.2) sha256=88296b9f3088c31f7c0d4fcec10f68e4b71c96698043916de59b04debec10388 + database_cleaner-core (2.0.1) sha256=8646574c32162e59ed7b5258a97a208d3c44551b854e510994f24683865d846c + date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 + debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6 + descendants_tracker (0.0.4) sha256=e9c41dd4cfbb85829a9301ea7e7c48c2a03b26f09319db230e6479ccdc780897 + diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 + docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e + dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d + drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 + dry-configurable (1.4.0) sha256=e35d1b5f3c081753ef361f564919db79000f32cfa6f20ee3a3ba5921b41b73ce + dry-core (1.2.0) sha256=0cc5a7da88df397f153947eeeae42e876e999c1e30900f3c536fb173854e96a1 + dry-inflector (1.3.1) sha256=7fb0c2bb04f67638f25c52e7ba39ab435d922a3a5c3cd196120f63accb682dcc + dry-initializer (3.2.0) sha256=37d59798f912dc0a1efe14a4db4a9306989007b302dcd5f25d0a2a20c166c4e3 + dry-logic (1.6.0) sha256=da6fedbc0f90fc41f9b0cc7e6f05f5d529d1efaef6c8dcc8e0733f685745cea2 + dry-schema (1.16.0) sha256=cd3aaeabc0f1af66ec82a29096d4c4fb92a0a58b9dae29a22b1bbceb78985727 + dry-types (1.9.1) sha256=baebeecdb9f8395d6c9d227b62011279440943e3ef2468fe8ccc1ba11467f178 + ed25519 (1.4.0) sha256=16e97f5198689a154247169f3453ef4cfd3f7a47481fde0ae33206cdfdcac506 + erb (6.0.4) sha256=38e3803694be357fe2bfe312487c74beaf9fb4e5beb3e22498952fe1645b95d9 + erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 + et-orbi (1.4.0) sha256=6c7e3c90779821f9e3b324c5e96fda9767f72995d6ae435b96678a4f3e2de8bc + factory_bot (6.6.0) sha256=1fc1b3b5620ec980a6a27aec1b6ec8c250ca82962e970e8a40f93e8d388d4b89 + factory_bot_rails (6.5.1) sha256=d3cc4851eae4dea8a665ec4a4516895045e710554d2b5ac9e68b94d351bc6d68 + ffi (1.17.4-aarch64-linux-gnu) sha256=b208f06f91ffd8f5e1193da3cae3d2ccfc27fc36fba577baf698d26d91c080df + ffi (1.17.4-aarch64-linux-musl) sha256=9286b7a615f2676245283aef0a0a3b475ae3aae2bb5448baace630bb77b91f39 + ffi (1.17.4-arm-linux-gnu) sha256=d6dbddf7cb77bf955411af5f187a65b8cd378cb003c15c05697f5feee1cb1564 + ffi (1.17.4-arm-linux-musl) sha256=9d4838ded0465bef6e2426935f6bcc93134b6616785a84ffd2a3d82bc3cf6f95 + ffi (1.17.4-x64-mingw-ucrt) sha256=f6ff9618cfccc494138bddade27aa06c74c6c7bc367a1ea1103d80c2fcb9ed35 + ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d + ffi (1.17.4-x86_64-linux-musl) sha256=3fdf9888483de005f8ef8d1cf2d3b20d86626af206cbf780f6a6a12439a9c49e + fiddle (1.1.8) sha256=7fa8ee3627271497f3add5503acdbc3f40b32f610fc1cf49634f083ef3f32eee + flay (2.14.4) sha256=a62d96a51d1da185aa41ba95b696966df9f7d1d91a457709277f24515895de77 + flog (4.9.4) sha256=12cc054fab7a2cbd2a906514397c4d7788954d530564782d6f14939dc2dfbcbb + fugit (1.12.1) sha256=5898f478ede9b415f0804e42b8f3fd53f814bd85eebffceebdbc34e1107aaf68 + globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11 + i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5 + ice_nine (0.11.2) sha256=5d506a7d2723d5592dc121b9928e4931742730131f22a1a37649df1c1e2e63db + image_processing (1.14.0) sha256=754cc169c9c262980889bec6bfd325ed1dafad34f85242b5a07b60af004742fb + importmap-rails (2.2.3) sha256=7101be2a4dc97cf1558fb8f573a718404c5f6bcfe94f304bf1f39e444feeb16a + io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc + irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 + jbuilder (2.15.0) sha256=fe36cd45b47dd88cb2cbebc3adc4348f041825f580d503578594e8255817f889 + json (2.19.5) sha256=218a18553e4801d579ca7e0f5bc72bafd776d7397238a1fb4e74db5b0a812c59 + kamal (2.11.0) sha256=1408864425e0dec7e0a14d712a3b13f614e9f3a425b7661d3f9d287a51d7dd75 + language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc + launchy (3.1.1) sha256=72b847b5cc961589dde2c395af0108c86ff0119f42d4648d25b5440ebb10059e + lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + loofah (2.25.1) sha256=d436c73dbd0c1147b16c4a41db097942d217303e1f7728704b37e4df9f6d2e04 + mail (2.9.0) sha256=6fa6673ecd71c60c2d996260f9ee3dd387d4673b8169b502134659ece6d34941 + marcel (1.2.1) sha256=1678e9360e32f9eafa917c80029e2f6d10b2715c66a4b87b6d0da9b9cd1f859f + matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b + memoist3 (1.0.0) sha256=686e42402cf150a362050c23143dc57b0ef88f8c344943ff8b7845792b50d56f + mini_magick (5.3.1) sha256=29395dfd76badcabb6403ee5aff6f681e867074f8f28ce08d78661e9e4a351c4 + mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef + minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1 + msgpack (1.8.3) sha256=8bda4a6428d3244e50d6bd55854d354edbada88a4e1f4f5731a39a0f86bee6a1 + multi_test (1.1.0) sha256=e9e550cdd863fb72becfe344aefdcd4cbd26ebf307847f4a6c039a4082324d10 + net-imap (0.6.4.1) sha256=29f0360d75a7efd3539f16ac1957dea5c0a51ddeceb348db4553c3120914ea0d + net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 + net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8 + net-scp (4.1.0) sha256=a99b0b92a1e5d360b0de4ffbf2dc0c91531502d3d4f56c28b0139a7c093d1a5d + net-sftp (4.0.0) sha256=65bb91c859c2f93b09826757af11b69af931a3a9155050f50d1b06d384526364 + net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736 + net-ssh (7.3.2) sha256=65029e213c380e20e5fd92ece663934ab0a0fe888e0cd7cc6a5b664074362dd4 + nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 + nokogiri (1.19.4-aarch64-linux-gnu) sha256=1269fb644a6de405057a53dd5c762b1209b43ca7424f839454d3dbc677c31a8f + nokogiri (1.19.4-aarch64-linux-musl) sha256=35c65b9ce72b3bb03207bdbe7067915019dc18c1b9b59139684bd6690fdd01af + nokogiri (1.19.4-arm-linux-gnu) sha256=a301313e38bb065d68239e79734bcd6f56fb6efaacebde29e9abf2a4735340ca + nokogiri (1.19.4-arm-linux-musl) sha256=588923c101bcfa78869734d247d25b598674323e7f22474fc468f6e5647311eb + nokogiri (1.19.4-x64-mingw-ucrt) sha256=051da97b8eccfdb5444fed40246a35e10d7298b9efe759b4cd25455ea04c587e + nokogiri (1.19.4-x86_64-linux-gnu) sha256=379fae440b28915e3f19d752ce2dcf8465ed2b2fbefd2a7ca0dd497bc981a06a + nokogiri (1.19.4-x86_64-linux-musl) sha256=17dfb7c1fa194ae02fbf7c51a7afc8d278045ab3fdacfd86f91d02d7b274470b + ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 + parallel (2.1.0) sha256=b35258865c2e31134c5ecb708beaaf6772adf9d5efae28e93e99260877b09356 + parser (3.3.11.1) sha256=d17ace7aabe3e72c3cc94043714be27cc6f852f104d81aa284c2281aecc65d54 + path_expander (2.0.1) sha256=2de201164bff4719cc4d0b3767286e9977cc832a59c4d70abab571ec86cb41e4 + pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6 + prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 + propshaft (1.3.2) sha256=1d56a3e56a92c21bfc29caf07406b5386b00d4c47ddf357cf989a5a234b1389e + psych (5.3.1) sha256=eb7a57cef10c9d70173ff74e739d843ac3b2c019a003de48447b2963d81b1974 + public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 + puma (8.0.2) sha256=c8ed871dfbbe66448ea9ffd46692342d9804d4071522b52b5331b7b6e7b686fb + raabro (1.4.0) sha256=d4fa9ff5172391edb92b242eed8be802d1934b1464061ae5e70d80962c5da882 + racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f + rack (3.2.6) sha256=5ed78e1f73b2e25679bec7d45ee2d4483cc4146eb1be0264fc4d94cb5ef212c2 + rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8 + rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463 + rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868 + rails (8.1.3) sha256=6d017ba5348c98fc909753a8169b21d44de14d2a0b92d140d1a966834c3c9cd3 + rails-dom-testing (2.3.0) sha256=8acc7953a7b911ca44588bf08737bc16719f431a1cc3091a292bca7317925c1d + rails-html-sanitizer (1.7.0) sha256=28b145cceaf9cc214a9874feaa183c3acba036c9592b19886e0e45efc62b1e89 + railties (8.1.3) sha256=913eb0e0cb520aac687ffd74916bd726d48fa21f47833c6292576ef6a286de22 + rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a + rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 + rdoc (7.2.0) sha256=8650f76cd4009c3b54955eb5d7e3a075c60a57276766ebf36f9085e8c9f23192 + reek (6.5.0) sha256=d26d3a492773b2bbc228888067a21afe33ac07954a17dbd64cdeae42c4c69be1 + regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb + reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835 + rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 + rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d + rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 + rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47 + rspec-rails (8.0.4) sha256=06235692fc0892683d3d34977e081db867434b3a24ae0dd0c6f3516bad4e22df + rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c + rubocop (1.86.2) sha256=bb2e97f635eda42c448f2588f4a6ff78f221b8bdfdf65b1e9b07fbd57521b45d + rubocop-ast (1.49.1) sha256=4412f3ee70f6fe4546cc489548e0f6fcf76cafcfa80fa03af67098ffed755035 + rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834 + rubocop-rails (2.35.2) sha256=088865be9675922a5c8f13c00055a71ab768ea5eed211437cffd2a8b46b64ac2 + rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d + ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 + ruby-vips (2.3.0) sha256=e685ec02c13969912debbd98019e50492e12989282da5f37d05f5471442f5374 + ruby_parser (3.22.0) sha256=1eb4937cd9eb220aa2d194e352a24dba90aef00751e24c8dfffdb14000f15d23 + rubycritic (5.0.0) sha256=7f3877556d7f52bf0980496b7c907a827a9592ba690b80a593403a60bd6cfb42 + rubyzip (3.3.0) sha256=a372fc67892a4f8c0bc8ec906b720353d8e48807a64b2e63adf99b1e3583a034 + securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 + selenium-webdriver (4.44.0) sha256=6f1df072529af369589c46f0e01132952aabb250cfd683c274d74dc1eb5d8477 + sexp_processor (4.17.5) sha256=ae2b48ba98353d5d465ce8759836b7a05f2e12c5879fcd14d7815b026de32f0e + simplecov (0.22.0) sha256=fe2622c7834ff23b98066bb0a854284b2729a569ac659f82621fc22ef36213a5 + simplecov-html (0.13.2) sha256=bd0b8e54e7c2d7685927e8d6286466359b6f16b18cb0df47b508e8d73c777246 + simplecov_json_formatter (0.1.4) sha256=529418fbe8de1713ac2b2d612aa3daa56d316975d307244399fa4838c601b428 + solid_cable (4.0.0) sha256=8379680ef6bf36e195eb876a6306ea290f87d5fa10bc4a757bc2a918f83229b5 + solid_cache (1.0.10) sha256=bc05a2fb3ac78a6f43cbb5946679cf9db67dd30d22939ededc385cb93e120d41 + solid_queue (1.4.0) sha256=e6a18d196f0b27cb6e3c77c5b31258b05fb634f8ed64fb1866ed164047216c2a + sqlite3 (2.9.4-aarch64-linux-gnu) sha256=ecabed721e6eaad54601d2685f09029d90025efc8d931040dc89cb3f8a2080ec + sqlite3 (2.9.4-aarch64-linux-musl) sha256=ffb4255947fb54c8c3eeca97460c9702b40de91ce390455ef7367ca6a3929a31 + sqlite3 (2.9.4-arm-linux-gnu) sha256=9ee2008b9fbec984c3c165b0d7eedd2bd2a415100b761bfa3a4c6fbec9208bf6 + sqlite3 (2.9.4-arm-linux-musl) sha256=8dc1fe4da6977992cd62decf4a93ccf6cc2e124a5e6a340160d52092f70e837a + sqlite3 (2.9.4-x64-mingw-ucrt) sha256=40997c549b19e2fdfcc5e271f6bdd4d502179742c0bfd678da23d0d09b929848 + sqlite3 (2.9.4-x86_64-linux-gnu) sha256=537a3eda71b1df1336d0055cbebe55a7317c34870c192c7b6b9d8d0be6871847 + sqlite3 (2.9.4-x86_64-linux-musl) sha256=3fc5e865b4be9a85d998203ef8d0c0fdcb92f20acf34a254346ff8a19088efec + sshkit (1.25.0) sha256=c8c6543cdb60f91f1d277306d585dd11b6a064cb44eab0972827e4311ff96744 + stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06 + stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1 + sys-uname (1.5.1) sha256=784d7e6491b0393c25cbbe5ac38324ac7be9fda083a6094832648af669386d7b + sys-uname (1.5.1-universal-mingw32) sha256=aceb618e3276da5eae0ce368e9f6fae8c1f3e9ef23a0595cb88db7b6ecd45f62 + thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 + thread_safe (0.3.6) sha256=9ed7072821b51c57e8d6b7011a8e282e25aeea3a4065eab326e43f66f063b05a + thruster (0.1.21) sha256=dc67928f36e5894844579a95e45637a5091db7a7ea05468ee8c2c6eb0a3f77cf + thruster (0.1.21-aarch64-linux) sha256=f5aff78fb7a6431ed3d6ab4bde03a89c461e9a73981dbc97d6990d85c3db235c + thruster (0.1.21-x86_64-linux) sha256=6e2fbcf826540a72d3710ae4db072c2333287ac2ee57e7e52f35bc10900d74a7 + timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb + tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f + tty-which (0.5.0) sha256=5824055f0d6744c97e7c4426544f01d519c40d1806ef2ef47d9854477993f466 + turbo-rails (2.0.23) sha256=ee0d90733aafff056cf51ff11e803d65e43cae258cc55f6492020ec1f9f9315f + tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b + tzinfo-data (1.2026.2) sha256=7db0d3d3d53b8d7601fc183fccc8c6d056a3004e14eb59ea995bf6aec4ae10bc + unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 + unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f + uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 + useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844 + virtus (2.0.0) sha256=8841dae4eb7fcc097320ba5ea516bf1839e5d056c61ee27138aa4bddd6e3d1c2 + web-console (4.3.0) sha256=e13b71301cdfc2093f155b5aa3a622db80b4672d1f2f713119cc7ec7ac6a6da4 + websocket (1.2.11) sha256=b7e7a74e2410b5e85c25858b26b3322f29161e300935f70a0e0d3c35e0462737 + websocket-driver (0.8.0) sha256=ed0dba4b943c22f17f9a734817e808bc84cdce6a7e22045f5315aa57676d4962 + websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241 + win32ole (1.9.3) sha256=01f43dc5dc13806e6e58204f538b4a28f3d85968ea89074abc9a3cd118e94d96 + xpath (3.2.0) sha256=6dfda79d91bb3b949b947ecc5919f042ef2f399b904013eb3ef6d20dd3a4082e + zeitwerk (2.8.2) sha256=7212a61311083c604184b1ea2574b9aa05cd14f855a0841c06985cabe9181d12 + +BUNDLED WITH + 4.0.9 diff --git a/project/README.md b/project/README.md new file mode 100644 index 0000000000..7db80e4ca1 --- /dev/null +++ b/project/README.md @@ -0,0 +1,24 @@ +# 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/project/Rakefile b/project/Rakefile new file mode 100644 index 0000000000..9a5ea7383a --- /dev/null +++ b/project/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/project/app/assets/images/.keep b/project/app/assets/images/.keep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/project/app/assets/stylesheets/application.css b/project/app/assets/stylesheets/application.css new file mode 100644 index 0000000000..2a37558958 --- /dev/null +++ b/project/app/assets/stylesheets/application.css @@ -0,0 +1,366 @@ +/* + * Paleta e componentes extraídos do design no Figma (arquivo qV0Hu3b33wICdccabr5U7E). + * Com Propshaft os assets são servidos sem pré-processamento: a cascata segue a + * ordem normal do CSS, então mantenha a ordem das seções abaixo. + */ + +:root { + --color-primary: #6C2365; + --color-primary-dark: #551B50; + --color-accent: #22C55E; + --color-accent-dark: #1AA34B; + --color-bg: #DBDBDB; + --color-surface: #FFFFFF; + --color-text: #1F2937; + --color-text-muted: #6B7280; + --color-border: #D1D5DB; + --color-danger: #DC2626; + --radius: 10px; + --shadow-card: 0 1px 4px rgba(0, 0, 0, 0.12); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + color: var(--color-text); + background: var(--color-bg); +} + +h1, h2, h3 { + margin-top: 0; +} + +a { + color: var(--color-primary); + text-decoration: none; +} + +/* Flash messages */ +.flash { + margin: 0 0 16px; + padding: 12px 16px; + border-radius: var(--radius); + font-weight: 500; +} + +.flash-notice { + background: #DCFCE7; + color: #166534; +} + +.flash-alert { + background: #FEE2E2; + color: #991B1B; +} + +/* Layout autenticado: header + sidebar + conteúdo */ +.app-shell { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + background: var(--color-surface); + border-bottom: 1px solid var(--color-border); + padding: 12px 24px; +} + +.app-header__title { + display: flex; + align-items: center; + gap: 16px; + font-size: 1.25rem; + font-weight: 600; +} + +.app-header__menu-icon { + width: 24px; + height: 18px; + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.app-header__menu-icon span { + display: block; + height: 2px; + background: var(--color-text); + border-radius: 1px; +} + +.app-header__search { + flex: 1; + max-width: 360px; +} + +.app-header__search input { + width: 100%; + padding: 8px 14px; + border-radius: 20px; + border: 1px solid var(--color-border); + background: var(--color-surface); +} + +.app-header__avatar-wrapper { + position: relative; +} + +.app-header__avatar { + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--color-primary); + color: var(--color-surface); + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + cursor: pointer; + list-style: none; +} + +.app-header__menu { + position: absolute; + right: 0; + top: 48px; + background: var(--color-surface); + border-radius: var(--radius); + box-shadow: var(--shadow-card); + min-width: 140px; + overflow: hidden; + z-index: 10; +} + +.app-header__menu a, +.app-header__menu button { + display: block; + width: 100%; + padding: 10px 16px; + text-align: left; + background: none; + border: none; + font-size: 0.95rem; + color: var(--color-text); + cursor: pointer; +} + +.app-header__menu a:hover, +.app-header__menu button:hover { + background: var(--color-bg); +} + +.app-body { + flex: 1; + display: flex; +} + +.app-sidebar { + width: 220px; + background: var(--color-surface); + border-right: 1px solid var(--color-border); +} + +.app-sidebar__item { + display: block; + padding: 16px 20px; + color: var(--color-text); + border-bottom: 1px solid var(--color-border); +} + +.app-sidebar__item.active { + background: var(--color-primary); + color: var(--color-surface); +} + +.app-content { + flex: 1; + padding: 24px; +} + +/* Cards */ +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 20px; +} + +.card { + background: var(--color-surface); + border-radius: var(--radius); + box-shadow: var(--shadow-card); + padding: 16px 20px; +} + +.card h3 { + margin: 0 0 4px; + font-size: 1.1rem; +} + +.card .card-subtitle { + margin: 0 0 12px; + font-size: 0.85rem; + color: var(--color-text-muted); +} + +.card .card-footer { + font-weight: 600; + margin-top: 8px; +} + +/* Botões */ +.btn { + display: inline-block; + padding: 10px 20px; + border-radius: var(--radius); + border: none; + font-weight: 600; + cursor: pointer; + text-align: center; +} + +.btn-primary { + background: var(--color-accent); + color: var(--color-surface); +} + +.btn-primary:hover { + background: var(--color-accent-dark); +} + +.btn-secondary { + background: var(--color-surface); + color: var(--color-primary); + border: 1px solid var(--color-primary); +} + +.btn-link { + background: none; + color: var(--color-primary); + padding: 0; + font-weight: 500; + text-decoration: underline; +} + +/* Formulários */ +.form-group { + margin-bottom: 16px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.form-group label { + font-weight: 600; + font-size: 0.9rem; +} + +.form-group input, +.form-group select, +.form-group textarea { + padding: 10px 14px; + border-radius: 8px; + border: 1px solid var(--color-border); + font-size: 1rem; +} + +.form-errors { + background: #FEE2E2; + color: #991B1B; + border-radius: var(--radius); + padding: 12px 16px; + margin-bottom: 16px; +} + +.questao-card { + background: var(--color-surface); + border-radius: var(--radius); + box-shadow: var(--shadow-card); + padding: 16px 20px; +} + +.radio-group { + display: flex; + gap: 24px; + flex-direction: row; +} + +.radio-group label { + display: flex; + align-items: center; + gap: 6px; + font-weight: 400; +} + +/* Resultados de formulário */ +.resultados-lista { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 16px; +} + +.resultado-questao ul { + margin: 8px 0 0; + padding-left: 20px; +} + +/* Tela de autenticação (login / recuperação de senha) */ +.auth-shell { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: var(--color-bg); +} + +.auth-panel { + display: flex; + width: 900px; + max-width: 95vw; + min-height: 520px; + border-radius: var(--radius); + overflow: hidden; + box-shadow: var(--shadow-card); +} + +.auth-panel__form { + flex: 1; + background: var(--color-surface); + padding: 48px; + display: flex; + flex-direction: column; + justify-content: center; +} + +.auth-panel__form h1 { + text-transform: uppercase; + letter-spacing: 0.05em; + font-size: 1.5rem; + margin-bottom: 24px; +} + +.auth-panel__welcome { + flex: 1; + background: var(--color-primary); + color: var(--color-surface); + display: flex; + align-items: center; + justify-content: center; + text-align: center; + font-size: 2rem; + font-weight: 700; + padding: 48px; +} + +.auth-panel__back { + margin-top: 16px; +} diff --git a/project/app/controllers/application_controller.rb b/project/app/controllers/application_controller.rb new file mode 100644 index 0000000000..b5103925d7 --- /dev/null +++ b/project/app/controllers/application_controller.rb @@ -0,0 +1,89 @@ +## +# Controller base da aplicação. Centraliza a identificação do usuário da sessão +# e as verificações de autenticação e autorização usadas pelos demais controllers. +class ApplicationController < ActionController::Base + # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. + allow_browser versions: :modern + + # Changes to the importmap will invalidate the etag for HTML responses + stale_when_importmap_changes + + helper_method :current_admin, :current_user, :user_signed_in? + + private + + ## + # Recupera o usuário autenticado na sessão atual. + # + # Não recebe argumentos. + # + # Retorna o User autenticado ou +nil+ quando a sessão não possui um usuário válido. + # + # Efeitos colaterais: consulta o banco na primeira chamada e memoriza o resultado + # em uma variável de instância. + def current_user + @current_user ||= User.find_by(id: session[:user_id]) if session[:user_id].present? + end + + ## + # Identifica se o usuário autenticado possui o papel de administrador. + # + # Não recebe argumentos. + # + # Retorna o User administrador ou +nil+ para participantes e visitantes. + # + # Efeitos colaterais: pode carregar o usuário da sessão por meio de #current_user. + def current_admin + current_user&.admin? ? current_user : nil + end + + ## + # Informa se existe um usuário autenticado. + # + # Não recebe argumentos. + # + # Retorna +true+ quando há um usuário na sessão e +false+ caso contrário. + # + # Efeitos colaterais: pode carregar o usuário da sessão por meio de #current_user. + def user_signed_in? + current_user.present? + end + + ## + # Exige autenticação antes de continuar a requisição. + # + # Não recebe argumentos. + # + # Retorna +nil+; o resultado relevante é a continuidade ou interrupção da requisição. + # + # Efeitos colaterais: redireciona visitantes para o login e define uma mensagem de alerta. + def require_login + redirect_to login_path, alert: "Faça login para continuar" unless user_signed_in? + end + + ## + # Restringe a requisição a um administrador autenticado. + # + # Não recebe argumentos. + # + # Retorna +nil+; o resultado relevante é a continuidade ou interrupção da requisição. + # + # Efeitos colaterais: redireciona usuários não autorizados para o login e define um alerta. + def require_admin! + redirect_to login_path, alert: "Acesso restrito a administradores" unless current_admin + end + + ## + # Verifica se o usuário autenticado é administrador. + # + # Não recebe argumentos. + # + # Retorna +nil+ quando o acesso é permitido ou após preparar o redirecionamento. + # + # Efeitos colaterais: redireciona usuários não administradores para o painel e define um alerta. + def require_admin + return if user_signed_in? && current_user.admin? + + redirect_to root_path, alert: "Acesso restrito a administradores" + end +end diff --git a/project/app/controllers/concerns/.keep b/project/app/controllers/concerns/.keep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/project/app/controllers/course_classes_controller.rb b/project/app/controllers/course_classes_controller.rb new file mode 100644 index 0000000000..e3fb84a7b7 --- /dev/null +++ b/project/app/controllers/course_classes_controller.rb @@ -0,0 +1,52 @@ +## +# Apresenta ao administrador as turmas pertencentes ao seu departamento e os +# formulários de avaliação vinculados a cada turma. +class CourseClassesController < ApplicationController + before_action :require_admin! + + ## + # Lista as turmas do departamento do administrador no semestre mais recente. + # + # Não recebe argumentos. + # + # Retorna a resposta HTML construída pelo Rails. + # + # Efeitos colaterais: consulta o banco e define +@course_classes+ para a view. + def index + @course_classes = CourseClass.where(department: current_admin.department, semester: current_semester) + end + + ## + # Exibe uma turma e seus formulários, desde que pertença ao departamento do administrador. + # + # Não recebe argumentos explícitos; utiliza +params[:id]+. + # + # Retorna a resposta HTML ou uma resposta de redirecionamento quando o acesso é negado. + # + # Efeitos colaterais: consulta o banco, define variáveis para a view e pode redirecionar + # para a listagem de turmas com uma mensagem de alerta. + def show + @course_class = CourseClass.find(params.expect(:id)) + + if @course_class.department_id != current_admin.department_id + redirect_to course_classes_path, alert: "Acesso negado. Você só pode gerenciar turmas do seu próprio departamento." + return + end + + @formularios = Formulario.where(course_class: @course_class) + end + + private + + ## + # Determina o semestre mais recente existente na base de turmas. + # + # Não recebe argumentos. + # + # Retorna o maior valor de +semester+ ou +nil+ quando não há turmas. + # + # Efeitos colaterais: consulta o banco de dados. + def current_semester + CourseClass.maximum(:semester) + end +end diff --git a/project/app/controllers/dashboard_controller.rb b/project/app/controllers/dashboard_controller.rb new file mode 100644 index 0000000000..449c604bcc --- /dev/null +++ b/project/app/controllers/dashboard_controller.rb @@ -0,0 +1,16 @@ +## +# Exibe o painel inicial adequado ao papel do usuário autenticado. +class DashboardController < ApplicationController + before_action :require_login + + ## + # Renderiza o painel principal da aplicação. + # + # Não recebe argumentos. + # + # Retorna a resposta HTML construída pelo Rails. + # + # Efeitos colaterais: renderiza a view, que consulta os dados associados ao usuário atual. + def show + end +end diff --git a/project/app/controllers/formularios_controller.rb b/project/app/controllers/formularios_controller.rb new file mode 100644 index 0000000000..7a08a3551f --- /dev/null +++ b/project/app/controllers/formularios_controller.rb @@ -0,0 +1,200 @@ +## +# Gerencia formulários de avaliação, apresenta pendências aos participantes e +# disponibiliza resultados e exportação aos administradores. +class FormulariosController < ApplicationController + before_action :require_admin!, only: %i[ new create destroy exportar_csv ] + before_action :set_formulario, only: %i[ show destroy exportar_csv ] + before_action :authorize_formulario_access!, only: %i[ destroy ] + + ## + # Lista os formulários do administrador ou as avaliações pendentes do participante. + # + # Não recebe argumentos. + # + # Retorna a resposta HTML ou JSON construída pelo Rails. + # + # Efeitos colaterais: consulta o banco e define +@formularios+ e + # +@formularios_pendentes+ para a view. + def index + # Issue 110: Visualizar os formulários criados (Listagem geral) + @formularios = current_admin ? current_admin.formularios : [] + + # Issue 109: Visualizar os formulários não respondidos das turmas, respeitando + # o papel (discente/docente) do usuário em cada turma + @formularios_pendentes = current_user ? formularios_pendentes_para(current_user) : [] + end + + ## + # Exibe um formulário para resposta ou, para administradores, seus resultados. + # + # Não recebe argumentos explícitos; usa o formulário carregado por +params[:id]+. + # + # Retorna a resposta HTML ou JSON construída pelo Rails. + # + # Efeitos colaterais: consulta submissões e respostas e define variáveis para a view. + def show + if current_admin + @resultados = @formulario.questaos.includes(respostas: { submissao: :user }) + @tem_submissoes = Submissao.where(formulario: @formulario).exists? + end + end + + ## + # Prepara a tela de criação de um formulário. + # + # Não recebe argumentos. + # + # Retorna a resposta HTML construída pelo Rails. + # + # Efeitos colaterais: instancia um formulário, consulta templates e turmas e define + # as coleções usadas pela view. + def new + @formulario = Formulario.new + @templates = current_admin.templates + @course_classes = CourseClass.all + end + + ## + # Cria um formulário a partir de um template e clona suas questões. + # + # Não recebe argumentos explícitos; utiliza os atributos permitidos da requisição. + # + # Retorna uma resposta de redirecionamento em caso de sucesso ou uma resposta + # com status de conteúdo não processável quando houver erros. + # + # Efeitos colaterais: persiste o formulário e suas questões ou renderiza novamente + # a tela com as mensagens de validação. + def create + result = Formularios::CreateFromTemplate.new(admin: current_admin, params: formulario_params).call + @formulario = result.formulario + + result.success? ? render_create_success : render_create_failure + end + + ## + # Exclui o formulário carregado e seus registros dependentes. + # + # Não recebe argumentos explícitos; usa o formulário carregado por +params[:id]+. + # + # Retorna uma resposta de redirecionamento com status +303 See Other+. + # + # Efeitos colaterais: remove registros do banco e define uma mensagem de sucesso. + def destroy + @formulario.destroy! + redirect_to formularios_path, notice: "Formulário deletado com sucesso.", status: :see_other + end + + ## + # Exporta as questões e respostas do formulário em um arquivo CSV. + # + # Não recebe argumentos explícitos; usa o formulário carregado por +params[:id]+. + # + # Retorna a resposta de arquivo produzida por +send_data+. + # + # Efeitos colaterais: consulta respostas e envia dados CSV para download pelo cliente. + def exportar_csv + send_data gerar_csv, + filename: "respostas_formulario_#{@formulario.id}.csv", + type: "text/csv" + end + + private + ## + # Carrega o formulário indicado na rota. + # + # Não recebe argumentos explícitos; utiliza +params[:id]+. + # + # Retorna o Formulario encontrado. + # + # Efeitos colaterais: consulta o banco, define +@formulario+ e lança + # ActiveRecord::RecordNotFound quando o identificador não existe. + def set_formulario + @formulario = Formulario.find(params.expect(:id)) + end + + ## + # Verifica se o formulário pertence ao administrador atual. + # + # Não recebe argumentos explícitos; utiliza +@formulario+ e +current_admin+. + # + # Retorna +nil+ quando autorizado ou o resultado do redirecionamento quando negado. + # + # Efeitos colaterais: pode redirecionar para a listagem e definir uma mensagem de alerta. + def authorize_formulario_access! + redirect_to formularios_path, alert: "Você não tem acesso a esse formulário" unless @formulario.admin_id == current_admin.id + end + + ## + # Filtra os atributos aceitos para criação de formulário. + # + # Não recebe argumentos explícitos; lê +params[:formulario]+. + # + # Retorna ActionController::Parameters com os atributos permitidos. + # + # Efeitos colaterais: pode lançar ActionController::ParameterMissing quando a chave + # obrigatória não estiver presente. + def formulario_params + params.expect(formulario: [ :title, :status, :template_id, :course_class_id ]) + end + + ## + # Localiza formulários ainda não respondidos e compatíveis com o papel do usuário em cada turma. + # + # === Argumentos + # + # +user+:: User participante cujas pendências serão consultadas. + # + # === Retorno + # + # Retorna um Array de objetos Formulario pendentes. + # + # === Efeitos colaterais + # + # Consulta submissões, vínculos e formulários no banco de dados. + def formularios_pendentes_para(user) + formularios_respondidos_ids = Submissao.where(user: user).select(:formulario_id) + mapa = papel_por_turma(user) + + Formulario + .where(course_class_id: mapa.keys) + .where.not(id: formularios_respondidos_ids) + .select { |formulario| mapa[formulario.course_class_id] == formulario.target_role } + end + + def papel_por_turma(user) + user.class_memberships.each_with_object({}) { |m, h| h[m.course_class_id] = m.role } + end + + def gerar_csv + require "csv" + CSV.generate(headers: true) do |csv| + csv << cabecalho_csv + @formulario.questaos.each { |q| csv << linha_csv(q) } + end + end + + def cabecalho_csv + [ "ID da Pergunta", "Enunciado", "Tipo", "Respostas" ] + end + + def linha_csv(questao) + valores = questao.respostas.map { |r| helpers.formatar_resposta(r, questao.tipo) } + [ questao.id, questao.enunciado, questao.tipo, valores.join("; ") ] + end + + def render_create_success + respond_to do |format| + format.html { redirect_to formularios_path, notice: "Formulário gerado com sucesso!" } + format.json { render :show, status: :created, location: @formulario } + end + end + + def render_create_failure + @templates = current_admin.templates + @course_classes = CourseClass.all + respond_to do |format| + format.html { render :new, status: :unprocessable_content } + format.json { render json: @formulario.errors, status: :unprocessable_content } + end + end +end diff --git a/project/app/controllers/imports/sigaa_controller.rb b/project/app/controllers/imports/sigaa_controller.rb new file mode 100644 index 0000000000..254181650b --- /dev/null +++ b/project/app/controllers/imports/sigaa_controller.rb @@ -0,0 +1,58 @@ +## +# Agrupa os controllers responsáveis por importar dados de sistemas externos. +module Imports + ## + # Coordena pela interface administrativa a sincronização de turmas e participantes + # a partir dos arquivos exportados pelo SIGAA. + class SigaaController < ApplicationController + before_action :require_admin + + ## + # Exibe o formulário de sincronização e os semestres disponíveis nos arquivos de origem. + # + # Não recebe argumentos explícitos; pode utilizar +params[:semester]+. + # + # Retorna a resposta HTML construída pelo Rails. + # + # Efeitos colaterais: lê os arquivos JSON e define +@semesters+ e +@semester+ para a view. + def new + @semesters = Sigaa::DataSynchronizer.available_semesters + @semester = params[:semester].presence || @semesters.first + end + + ## + # Executa a sincronização dos dados do SIGAA para o semestre selecionado. + # + # Não recebe argumentos explícitos; utiliza os parâmetros permitidos da requisição. + # + # Retorna uma resposta de redirecionamento para a tela de importação. + # + # Efeitos colaterais: cria ou atualiza registros, pode enviar e-mails e define uma + # mensagem com o resultado da sincronização. + def create + result = Sigaa::DataSynchronizer.new(semester: sync_params[:semester], imported_by: current_user).call + + if result.already_up_to_date? + redirect_to imports_sigaa_path, notice: "A base de dados já está atualizada com o SIGAA para este período." + else + redirect_to imports_sigaa_path, + notice: "Sincronização concluída. #{result.created_count} novos registros adicionados e #{result.updated_count} registros atualizados." + end + end + + private + + ## + # Filtra o período letivo aceito pela ação de sincronização. + # + # Não recebe argumentos explícitos; lê +params[:sigaa_sync]+. + # + # Retorna ActionController::Parameters contendo apenas +semester+. + # + # Efeitos colaterais: pode lançar ActionController::ParameterMissing quando a chave + # obrigatória não estiver presente. + def sync_params + params.require(:sigaa_sync).permit(:semester) + end + end +end diff --git a/project/app/controllers/password_resets_controller.rb b/project/app/controllers/password_resets_controller.rb new file mode 100644 index 0000000000..b4776ed6a7 --- /dev/null +++ b/project/app/controllers/password_resets_controller.rb @@ -0,0 +1,130 @@ +## +# Conduz a solicitação e a aplicação de uma nova senha por meio de token temporário. +class PasswordResetsController < ApplicationController + layout "auth" + + # Mensagem neutra que evita revelar se um endereço está cadastrado. + GENERIC_MESSAGE = "Se o email estiver cadastrado, você receberá instruções para redefinir sua senha" + + ## + # Exibe o formulário de solicitação de redefinição de senha. + # + # Não recebe argumentos. + # + # Retorna a resposta HTML construída pelo Rails. + # + # Efeitos colaterais: renderiza a view de solicitação. + def new + end + + ## + # Solicita a redefinição para o e-mail informado. + # + # Não recebe argumentos explícitos; utiliza +params[:password_reset]+. + # + # Retorna uma resposta de redirecionamento para o formulário de solicitação. + # + # Efeitos colaterais: consulta o usuário, grava um token, envia e-mail quando o + # endereço existe e define uma mensagem neutra de confirmação. + def create + user = User.find_by(email: reset_request_params[:email].to_s.strip.downcase) + UserMailer.password_reset(user, user.generate_password_reset_token!).deliver_now if user + + redirect_to new_password_reset_path, notice: GENERIC_MESSAGE + end + + ## + # Exibe o formulário de nova senha para um token válido. + # + # Não recebe argumentos explícitos; utiliza +params[:token]+. + # + # Retorna a resposta HTML ou uma resposta +404 Not Found+ para token inválido. + # + # Efeitos colaterais: consulta o usuário e pode renderizar a página de token inválido. + def edit + load_user + render_invalid_token unless @user + end + + ## + # Aplica a nova senha associada ao token de redefinição. + # + # Não recebe argumentos explícitos; utiliza o token da rota e os parâmetros de senha. + # + # Retorna um redirecionamento ao login, uma resposta de validação ou uma resposta +404+. + # + # Efeitos colaterais: atualiza a senha e remove tokens ou renderiza erros de confirmação. + def update + load_user + return render_invalid_token unless @user + return render_password_mismatch unless passwords_match? + + apply_password_reset + end + + private + + ## + # Carrega o usuário associado ao token de redefinição atual. + # + # Não recebe argumentos explícitos; utiliza +params[:token]+. + # + # Retorna o User encontrado ou +nil+ para token inválido ou expirado. + # + # Efeitos colaterais: consulta o banco e define +@token+ e +@user+. + def load_user + @token = params[:token] + @user = User.find_by_reset_token(@token) + end + + ## + # Filtra o e-mail aceito na solicitação de redefinição. + # + # Não recebe argumentos explícitos; lê +params[:password_reset]+. + # + # Retorna ActionController::Parameters contendo apenas +email+. + # + # Efeitos colaterais: pode lançar ActionController::ParameterMissing. + def reset_request_params + params.require(:password_reset).permit(:email) + end + + ## + # Filtra a senha e sua confirmação. + # + # Não recebe argumentos explícitos; lê +params[:user]+. + # + # Retorna ActionController::Parameters com +password+ e +password_confirmation+. + # + # Efeitos colaterais: pode lançar ActionController::ParameterMissing. + def password_params + params.require(:user).permit(:password, :password_confirmation) + end + + ## + # Renderiza a resposta usada para tokens inválidos ou expirados. + # + # Não recebe argumentos. + # + # Retorna a resposta HTML com status +404 Not Found+. + # + # Efeitos colaterais: define uma mensagem de alerta e renderiza a view +invalid+. + def render_invalid_token + flash.now[:alert] = "Link de redefinição de senha inválido ou expirado" + render :invalid, status: :not_found + end + + def passwords_match? + password_params[:password] == password_params[:password_confirmation] + end + + def render_password_mismatch + flash.now[:alert] = "Confirmação de senha não confere" + render :edit, status: :unprocessable_entity + end + + def apply_password_reset + @user.apply_new_password!(password_params[:password]) + redirect_to login_path, notice: "Senha redefinida com sucesso" + end +end diff --git a/project/app/controllers/password_setups_controller.rb b/project/app/controllers/password_setups_controller.rb new file mode 100644 index 0000000000..44ed256051 --- /dev/null +++ b/project/app/controllers/password_setups_controller.rb @@ -0,0 +1,79 @@ +## +# Permite que um usuário importado defina sua primeira senha por token temporário. +class PasswordSetupsController < ApplicationController + layout "auth" + + before_action :load_user + + ## + # Exibe o formulário de definição inicial de senha. + # + # Não recebe argumentos explícitos; utiliza o usuário carregado pelo token da rota. + # + # Retorna a resposta HTML ou uma resposta +404 Not Found+ para token inválido. + # + # Efeitos colaterais: pode renderizar a página de token inválido. + def edit + render_invalid_token unless @user + end + + ## + # Define a primeira senha do usuário associado ao token. + # + # Não recebe argumentos explícitos; utiliza o token da rota e os parâmetros de senha. + # + # Retorna um redirecionamento ao login, uma resposta de validação ou uma resposta +404+. + # + # Efeitos colaterais: atualiza a senha e remove tokens ou renderiza erros de confirmação. + def update + return render_invalid_token unless @user + + if password_params[:password] != password_params[:password_confirmation] + flash.now[:alert] = "Confirmação de senha não confere" + render :edit, status: :unprocessable_entity + else + @user.apply_new_password!(password_params[:password]) + redirect_to login_path, notice: "Senha definida com sucesso" + end + end + + private + + ## + # Carrega o usuário associado ao token de definição inicial. + # + # Não recebe argumentos explícitos; utiliza +params[:token]+. + # + # Retorna o User encontrado ou +nil+ para token inválido ou expirado. + # + # Efeitos colaterais: consulta o banco e define +@token+ e +@user+. + def load_user + @token = params[:token] + @user = User.find_by_setup_token(@token) + end + + ## + # Filtra a senha e sua confirmação. + # + # Não recebe argumentos explícitos; lê +params[:user]+. + # + # Retorna ActionController::Parameters com +password+ e +password_confirmation+. + # + # Efeitos colaterais: pode lançar ActionController::ParameterMissing. + def password_params + params.require(:user).permit(:password, :password_confirmation) + end + + ## + # Renderiza a resposta usada para tokens inválidos ou expirados. + # + # Não recebe argumentos. + # + # Retorna a resposta HTML com status +404 Not Found+. + # + # Efeitos colaterais: define uma mensagem de alerta e renderiza a view +invalid+. + def render_invalid_token + flash.now[:alert] = "Link de definição de senha inválido ou expirado" + render :invalid, status: :not_found + end +end diff --git a/project/app/controllers/resposta_controller.rb b/project/app/controllers/resposta_controller.rb new file mode 100644 index 0000000000..e733ee6d40 --- /dev/null +++ b/project/app/controllers/resposta_controller.rb @@ -0,0 +1,102 @@ +## +# Recebe e persiste as respostas de um participante a um formulário de avaliação. +class RespostaController < ApplicationController + before_action :require_login + + ## + # Registra uma submissão completa para o formulário informado. + # + # Não recebe argumentos explícitos; utiliza +params[:respostum]+. + # + # Retorna uma resposta de redirecionamento ao formulário quando há campos em branco + # ou à listagem após uma submissão válida. + # + # Efeitos colaterais: cria ou atualiza submissão e respostas em transação e define + # mensagens de sucesso ou validação. + def create + carregar_formulario_e_respostas + return redirect_para_formulario_com_alerta if questao_em_branco? + + submissao = Submissao.find_or_create_by!(user: current_user, formulario: @formulario) + salvar_respostas(submissao) + redirect_to formularios_path, notice: "Avaliação submetida com sucesso" + end + + private + def carregar_formulario_e_respostas + @formulario = Formulario.find(params[:respostum][:formulario_id]) + @respostas_params = params[:respostum][:respostas] || {} + end + + def redirect_para_formulario_com_alerta + redirect_to formulario_path(@formulario), alert: "Por favor, preencha todas as questões obrigatórias" + end + + def salvar_respostas(submissao) + Respostum.transaction do + @formulario.questaos.each do |questao| + respostum = Respostum.find_or_initialize_by(submissao: submissao, questao: questao) + atribuir_valor(respostum, questao) + respostum.save! + end + end + end + + ## + # Verifica se alguma questão do formulário deixou de receber valor. + # + # Não recebe argumentos explícitos; utiliza +@formulario+ e +@respostas_params+. + # + # Retorna +true+ quando ao menos uma resposta está em branco e +false+ caso contrário. + # + # Efeitos colaterais: carrega as questões do formulário quando necessário. + def questao_em_branco? + @formulario.questaos.any? { |questao| valor_para(questao).blank? } + end + + ## + # Obtém o valor enviado para uma questão específica. + # + # === Argumentos + # + # +questao+:: Questao cujo identificador será procurado nos parâmetros. + # + # === Retorno + # + # Retorna o valor enviado como String ou +nil+ quando ele não está presente. + # + # === Efeitos colaterais + # + # Não possui efeitos colaterais. + def valor_para(questao) + @respostas_params.dig(questao.id.to_s, "valor") + end + + ## + # Atribui o valor textual ou numérico correto conforme o tipo da questão. + # + # === Argumentos + # + # +respostum+:: Respostum que receberá o valor. + # +questao+:: Questao que define o tipo e a origem do valor. + # + # === Retorno + # + # Retorna +nil+ após concluir as atribuições. + # + # === Efeitos colaterais + # + # Altera em memória +valor_texto+ e +valor_numerico+ da resposta; a persistência + # ocorre posteriormente na ação #create. + def atribuir_valor(respostum, questao) + valor = valor_para(questao) + + if questao.tipo == "text" + respostum.valor_texto = valor + respostum.valor_numerico = nil + else + respostum.valor_numerico = valor + respostum.valor_texto = nil + end + end +end diff --git a/project/app/controllers/sessions_controller.rb b/project/app/controllers/sessions_controller.rb new file mode 100644 index 0000000000..ece3a0c5a6 --- /dev/null +++ b/project/app/controllers/sessions_controller.rb @@ -0,0 +1,77 @@ +## +# Cria e encerra sessões autenticadas por e-mail ou matrícula. +class SessionsController < ApplicationController + layout "auth" + + ## + # Exibe o formulário de autenticação. + # + # Não recebe argumentos. + # + # Retorna a resposta HTML construída pelo Rails. + # + # Efeitos colaterais: renderiza a view de login. + def new + end + + ## + # Autentica as credenciais informadas e inicia a sessão do usuário. + # + # Não recebe argumentos explícitos; utiliza +params[:session]+. + # + # Retorna um redirecionamento ao painel quando autenticado ou uma resposta de + # validação quando a senha é inválida ou ainda não foi definida. + # + # Efeitos colaterais: consulta o usuário, grava +session[:user_id]+ quando válido + # e define mensagens de sucesso ou alerta. + def create + user = User.find_for_login(session_params[:identifier]) + + return handle_pending_setup if user&.pending_password_setup? + return handle_login_success(user) if user&.authenticate(session_params[:password]) + + handle_login_failure + end + + ## + # Encerra a sessão autenticada atual. + # + # Não recebe argumentos. + # + # Retorna uma resposta de redirecionamento para o login. + # + # Efeitos colaterais: limpa todos os dados da sessão e define uma mensagem de sucesso. + def destroy + reset_session + redirect_to login_path, notice: "Logout realizado com sucesso" + end + + private + + ## + # Filtra o identificador e a senha aceitos pelo login. + # + # Não recebe argumentos explícitos; lê +params[:session]+. + # + # Retorna ActionController::Parameters com +identifier+ e +password+. + # + # Efeitos colaterais: pode lançar ActionController::ParameterMissing. + def session_params + params.require(:session).permit(:identifier, :password) + end + + def handle_pending_setup + flash.now[:alert] = "Senha inicial precisa ser definida antes do acesso" + render :new, status: :unprocessable_entity + end + + def handle_login_success(user) + session[:user_id] = user.id + redirect_to root_path, notice: "Login realizado com sucesso" + end + + def handle_login_failure + flash.now[:alert] = "Email, matrícula ou senha inválidos" + render :new, status: :unprocessable_entity + end +end diff --git a/project/app/controllers/templates_controller.rb b/project/app/controllers/templates_controller.rb new file mode 100644 index 0000000000..7a0865b5f8 --- /dev/null +++ b/project/app/controllers/templates_controller.rb @@ -0,0 +1,260 @@ +## +# Gerencia templates de avaliação e suas questões para o administrador autenticado. +class TemplatesController < ApplicationController + before_action :require_admin! + before_action :set_template, only: %i[ show edit update destroy ] + before_action :authorize_template_access!, only: %i[ show edit update destroy ] + + ## + # Lista os templates pertencentes ao administrador atual. + # + # Não recebe argumentos. + # + # Retorna a resposta HTML ou JSON construída pelo Rails. + # + # Efeitos colaterais: consulta o banco e define +@templates+ para a view. + def index + @templates = current_admin.templates + end + + ## + # Exibe os detalhes do template carregado. + # + # Não recebe argumentos explícitos; utiliza +params[:id]+ por meio dos callbacks. + # + # Retorna a resposta HTML ou JSON construída pelo Rails. + # + # Efeitos colaterais: utiliza o template consultado e autorizado pelos callbacks. + def show + end + + ## + # Prepara o formulário de criação de template. + # + # Não recebe argumentos. + # + # Retorna a resposta HTML construída pelo Rails. + # + # Efeitos colaterais: instancia +@template+ e inicializa +@questoes_attrs+. + def new + @template = Template.new + @questoes_attrs = [] + end + + ## + # Prepara a edição do template e de suas questões existentes. + # + # Não recebe argumentos explícitos; utiliza o template carregado pelo callback. + # + # Retorna a resposta HTML construída pelo Rails. + # + # Efeitos colaterais: consulta questões e define +@questoes_attrs+ para a view. + def edit + @questoes_attrs = @template.questaos.map do |questao| + { id: questao.id, enunciado: questao.enunciado, tipo: questao.tipo } + end + end + + ## + # Cria um template e suas questões ou adiciona um campo vazio ao formulário. + # + # Não recebe argumentos explícitos; utiliza os parâmetros da requisição. + # + # Retorna um redirecionamento quando persiste o template ou uma resposta HTML + # com o formulário e o status adequado. + # + # Efeitos colaterais: pode persistir template e questões em transação, acrescentar + # atributos em memória ou renderizar mensagens de validação. + def create + @template = Template.new(template_params) + @questoes_attrs = extract_questoes_attrs + + if params[:add_questao] + @questoes_attrs << blank_questao_attrs + return render :new, status: :ok + end + + return render :new, status: :unprocessable_content unless @template.valid? && questoes_attrs_valid? + + salvar_novo_template + redirect_to templates_path, notice: "Template criado com sucesso." + end + + ## + # Atualiza o template e suas questões ou adiciona um campo vazio ao formulário. + # + # Não recebe argumentos explícitos; utiliza o template carregado e os parâmetros da requisição. + # + # Retorna um redirecionamento quando atualiza os dados ou uma resposta HTML com erros. + # + # Efeitos colaterais: altera template e questões em transação, pode criar novas questões + # e define mensagens de sucesso ou validação. + def update + @template.assign_attributes(template_params) + @questoes_attrs = extract_questoes_attrs + + if params[:add_questao] + @questoes_attrs << blank_questao_attrs + return render :edit, status: :ok + end + + processar_atualizacao_template + end + + ## + # Exclui o template carregado. + # + # Não recebe argumentos explícitos; utiliza o template carregado pelo callback. + # + # Retorna uma resposta de redirecionamento com status +303 See Other+. + # + # Efeitos colaterais: remove o template e suas questões, desvincula formulários + # existentes e define uma mensagem de sucesso. + def destroy + @template.destroy! + redirect_to templates_path, notice: "Template deletado com sucesso.", status: :see_other + end + + private + ## + # Carrega o template indicado na rota. + # + # Não recebe argumentos explícitos; utiliza +params[:id]+. + # + # Retorna o Template encontrado. + # + # Efeitos colaterais: consulta o banco, define +@template+ e pode lançar + # ActiveRecord::RecordNotFound. + def set_template + @template = Template.find(params.expect(:id)) + end + + ## + # Verifica se o template pertence ao administrador atual. + # + # Não recebe argumentos explícitos; utiliza +@template+ e +current_admin+. + # + # Retorna +nil+ quando autorizado ou o resultado do redirecionamento quando negado. + # + # Efeitos colaterais: pode redirecionar para a listagem e definir uma mensagem de alerta. + def authorize_template_access! + redirect_to templates_path, alert: "Você não tem acesso a esse template" unless @template.admin_id == current_admin.id + end + + ## + # Filtra os atributos do template e associa o administrador atual. + # + # Não recebe argumentos explícitos; lê +params[:template]+. + # + # Retorna ActionController::Parameters com +title+, +target_role+ e +admin_id+. + # + # Efeitos colaterais: acrescenta o identificador do administrador aos parâmetros e + # pode lançar ActionController::ParameterMissing. + def template_params + attrs = params.expect(template: [ :title, :target_role ]) + attrs[:admin_id] = current_admin.id + attrs + end + + ## + # Normaliza os atributos de questões enviados pelo formulário. + # + # Não recebe argumentos explícitos; lê +params[:template][:questaos]+. + # + # Retorna um Array de hashes com +id+, +enunciado+ e +tipo+, ou um Array vazio + # quando nenhuma questão foi enviada. + # + # Efeitos colaterais: não possui efeitos colaterais. + def extract_questoes_attrs + raw = params.dig(:template, :questaos) + return [] unless raw + + raw.values.map do |questao| + attrs = { enunciado: questao[:enunciado].to_s, tipo: questao[:tipo].presence || "rating" } + attrs[:id] = questao[:id] if questao[:id].present? + attrs + end + end + + ## + # Constrói os atributos iniciais de uma nova questão vazia. + # + # Não recebe argumentos. + # + # Retorna um Hash com enunciado vazio e tipo +rating+. + # + # Efeitos colaterais: não possui efeitos colaterais. + def blank_questao_attrs + { enunciado: "", tipo: "rating" } + end + + ## + # Valida se todas as questões informadas possuem enunciado. + # + # Não recebe argumentos explícitos; utiliza +@questoes_attrs+ e +@template+. + # + # Retorna +true+ quando todos os enunciados estão preenchidos e +false+ caso contrário. + # + # Efeitos colaterais: adiciona uma mensagem à coleção de erros do template quando inválido. + def questoes_attrs_valid? + if @questoes_attrs.any? { |questao| questao[:enunciado].blank? } + @template.errors.add(:base, "Questão não possui enunciado") + false + else + true + end + end + + ## + # Valida se as novas questões de uma edição possuem enunciado. + # + # === Argumentos + # + # +novas_questoes+:: Array de hashes que representam apenas as questões ainda não persistidas. + # + # === Retorno + # + # Retorna +true+ quando os enunciados estão preenchidos e +false+ caso contrário. + # + # === Efeitos colaterais + # + # Adiciona uma mensagem à coleção de erros do template quando houver campo vazio. + def novas_questoes_validas?(novas_questoes) + if novas_questoes.any? { |questao| questao[:enunciado].blank? } + @template.errors.add(:base, "A nova questão não possui enunciado") + false + else + true + end + end + + def processar_atualizacao_template + novas_questoes = @questoes_attrs.reject { |questao| questao[:id].present? } + return render :edit, status: :unprocessable_content unless @template.valid? && novas_questoes_validas?(novas_questoes) + + atualizar_template + redirect_to templates_path, notice: "Template atualizado com sucesso." + end + + def salvar_novo_template + Template.transaction do + @template.save! + @questoes_attrs.each { |q| @template.questaos.create!(enunciado: q[:enunciado], tipo: q[:tipo]) } + end + end + + def atualizar_template + Template.transaction do + @template.save! + @questoes_attrs.each { |q| persistir_questao(q) } + end + end + + def persistir_questao(questao) + if questao[:id].present? + @template.questaos.find(questao[:id]).update!(enunciado: questao[:enunciado], tipo: questao[:tipo]) + else + @template.questaos.create!(enunciado: questao[:enunciado], tipo: questao[:tipo]) + end + end +end diff --git a/project/app/helpers/application_helper.rb b/project/app/helpers/application_helper.rb new file mode 100644 index 0000000000..a0d5278977 --- /dev/null +++ b/project/app/helpers/application_helper.rb @@ -0,0 +1,4 @@ +## +# Ponto de extensão para helpers compartilhados entre todas as views. +module ApplicationHelper +end diff --git a/project/app/helpers/formularios_helper.rb b/project/app/helpers/formularios_helper.rb new file mode 100644 index 0000000000..be6925687e --- /dev/null +++ b/project/app/helpers/formularios_helper.rb @@ -0,0 +1,33 @@ +## +# Formata valores de respostas para apresentação nas telas e exportações de formulários. +module FormulariosHelper + ## + # Converte uma resposta armazenada para a representação adequada ao tipo da questão. + # + # === Argumentos + # + # +resposta+:: Respostum contendo o valor persistido. + # +tipo+:: String que identifica questões +text+, +boolean+ ou numéricas. + # + # === Retorno + # + # Retorna o texto informado, +Sim+, +Não+ ou a representação textual do número. + # + # === Efeitos colaterais + # + # Não possui efeitos colaterais. + def formatar_resposta(resposta, tipo) + case tipo + when "boolean" + case resposta.valor_numerico + when 1 then "Sim" + when 0 then "Não" + else "—" + end + when "rating" + resposta.valor_numerico.nil? ? "—" : "#{resposta.valor_numerico}/5" + else + resposta.valor_texto.presence || "—" + end + end +end diff --git a/project/app/helpers/resposta_helper.rb b/project/app/helpers/resposta_helper.rb new file mode 100644 index 0000000000..e5fe3baab5 --- /dev/null +++ b/project/app/helpers/resposta_helper.rb @@ -0,0 +1,4 @@ +## +# Ponto de extensão para helpers das views de respostas. +module RespostaHelper +end diff --git a/project/app/helpers/templates_helper.rb b/project/app/helpers/templates_helper.rb new file mode 100644 index 0000000000..aa015bea51 --- /dev/null +++ b/project/app/helpers/templates_helper.rb @@ -0,0 +1,4 @@ +## +# Ponto de extensão para helpers das views de templates. +module TemplatesHelper +end diff --git a/project/app/javascript/application.js b/project/app/javascript/application.js new file mode 100644 index 0000000000..0d7b49404c --- /dev/null +++ b/project/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/project/app/javascript/controllers/application.js b/project/app/javascript/controllers/application.js new file mode 100644 index 0000000000..1213e85c7a --- /dev/null +++ b/project/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/project/app/javascript/controllers/hello_controller.js b/project/app/javascript/controllers/hello_controller.js new file mode 100644 index 0000000000..5975c0789d --- /dev/null +++ b/project/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/project/app/javascript/controllers/index.js b/project/app/javascript/controllers/index.js new file mode 100644 index 0000000000..1156bf8362 --- /dev/null +++ b/project/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/project/app/jobs/application_job.rb b/project/app/jobs/application_job.rb new file mode 100644 index 0000000000..cafa1650ec --- /dev/null +++ b/project/app/jobs/application_job.rb @@ -0,0 +1,9 @@ +## +# Classe base dos jobs executados de forma assíncrona pela aplicação. +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/project/app/mailers/application_mailer.rb b/project/app/mailers/application_mailer.rb new file mode 100644 index 0000000000..b631e36f02 --- /dev/null +++ b/project/app/mailers/application_mailer.rb @@ -0,0 +1,6 @@ +## +# Classe base dos e-mails enviados pela aplicação. +class ApplicationMailer < ActionMailer::Base + default from: "from@example.com" + layout "mailer" +end diff --git a/project/app/mailers/user_mailer.rb b/project/app/mailers/user_mailer.rb new file mode 100644 index 0000000000..d51f8a2362 --- /dev/null +++ b/project/app/mailers/user_mailer.rb @@ -0,0 +1,49 @@ +## +# Monta as mensagens relacionadas à definição e à redefinição de senhas de usuários. +class UserMailer < ApplicationMailer + default from: "noreply@camaar.local" + + ## + # Prepara o e-mail que permite ao usuário definir sua primeira senha. + # + # === Argumentos + # + # +user+:: User destinatário da mensagem. + # +token+:: Token em texto puro usado para construir o link temporário. + # + # === Retorno + # + # Retorna uma ActionMailer::MessageDelivery pronta para envio. + # + # === Efeitos colaterais + # + # Define variáveis usadas pelo template e monta a mensagem; o envio ocorre quando + # o chamador solicita a entrega. + def password_setup(user, token) + @user = user + @url = edit_password_setup_url(token) + mail(to: user.email, subject: "Defina sua senha no CAMAAR") + end + + ## + # Prepara o e-mail que permite ao usuário redefinir sua senha. + # + # === Argumentos + # + # +user+:: User destinatário da mensagem. + # +token+:: Token em texto puro usado para construir o link temporário. + # + # === Retorno + # + # Retorna uma ActionMailer::MessageDelivery pronta para envio. + # + # === Efeitos colaterais + # + # Define variáveis usadas pelo template e monta a mensagem; o envio ocorre quando + # o chamador solicita a entrega. + def password_reset(user, token) + @user = user + @url = edit_password_reset_url(token) + mail(to: user.email, subject: "Redefinicao de senha do CAMAAR") + end +end diff --git a/project/app/models/application_record.rb b/project/app/models/application_record.rb new file mode 100644 index 0000000000..e346fecb44 --- /dev/null +++ b/project/app/models/application_record.rb @@ -0,0 +1,5 @@ +## +# Classe base abstrata dos modelos persistidos pelo Active Record. +class ApplicationRecord < ActiveRecord::Base + primary_abstract_class +end diff --git a/project/app/models/class_membership.rb b/project/app/models/class_membership.rb new file mode 100644 index 0000000000..d9bb96545f --- /dev/null +++ b/project/app/models/class_membership.rb @@ -0,0 +1,11 @@ +## +# Representa o vínculo de um usuário com uma turma como discente ou docente. +class ClassMembership < ApplicationRecord + belongs_to :user + belongs_to :course_class + + enum :role, { discente: 0, docente: 1 } + + validates :role, presence: true + validates :user_id, uniqueness: { scope: [ :course_class_id, :role ] } +end diff --git a/project/app/models/concerns/.keep b/project/app/models/concerns/.keep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/project/app/models/course_class.rb b/project/app/models/course_class.rb new file mode 100644 index 0000000000..529c6203fa --- /dev/null +++ b/project/app/models/course_class.rb @@ -0,0 +1,11 @@ +## +# Representa uma oferta de disciplina identificada por código, turma e semestre. +class CourseClass < ApplicationRecord + belongs_to :department + has_many :class_memberships, dependent: :destroy + has_many :users, through: :class_memberships + has_many :formularios, foreign_key: :course_class_id, dependent: :destroy + + validates :code, :class_code, :semester, presence: true + validates :code, uniqueness: { scope: [ :class_code, :semester ] } +end diff --git a/project/app/models/department.rb b/project/app/models/department.rb new file mode 100644 index 0000000000..108e20beaa --- /dev/null +++ b/project/app/models/department.rb @@ -0,0 +1,9 @@ +## +# Agrupa usuários e turmas pertencentes a uma unidade acadêmica. +class Department < ApplicationRecord + has_many :course_classes, dependent: :restrict_with_exception + has_many :users, dependent: :nullify + + validates :name, :code, presence: true + validates :code, uniqueness: true +end diff --git a/project/app/models/formulario.rb b/project/app/models/formulario.rb new file mode 100644 index 0000000000..24bb60e4bc --- /dev/null +++ b/project/app/models/formulario.rb @@ -0,0 +1,15 @@ +## +# Representa uma avaliação criada a partir de um template para uma turma específica. +class Formulario < ApplicationRecord + belongs_to :template, optional: true + belongs_to :course_class, optional: true + belongs_to :admin, class_name: "User" + has_many :questaos, dependent: :destroy + has_many :submissaos, dependent: :destroy + + validates :title, presence: { message: "O nome do formulário é obrigatório" } + validates :template_id, presence: { message: "É necessário escolher um template base" }, on: :create + validates :course_class_id, presence: { message: "É necessário escolher ao menos uma turma" }, on: :create + + after_initialize { self.status ||= "draft" } +end diff --git a/project/app/models/import_inconsistency.rb b/project/app/models/import_inconsistency.rb new file mode 100644 index 0000000000..da31d7b8ed --- /dev/null +++ b/project/app/models/import_inconsistency.rb @@ -0,0 +1,5 @@ +## +# Registra um dado inválido encontrado durante uma importação do SIGAA. +class ImportInconsistency < ApplicationRecord + validates :source, :message, presence: true +end diff --git a/project/app/models/questao.rb b/project/app/models/questao.rb new file mode 100644 index 0000000000..78e0331909 --- /dev/null +++ b/project/app/models/questao.rb @@ -0,0 +1,10 @@ +## +# Representa uma questão pertencente a um template ou a um formulário clonado. +class Questao < ApplicationRecord + # A chave estrangeira é nula para template se pertencer a formulário, e vice-versa + belongs_to :template, optional: true + belongs_to :formulario, optional: true + has_many :respostas, class_name: "Respostum", dependent: :destroy + + validates :enunciado, presence: true +end diff --git a/project/app/models/respostum.rb b/project/app/models/respostum.rb new file mode 100644 index 0000000000..cfb9704df5 --- /dev/null +++ b/project/app/models/respostum.rb @@ -0,0 +1,6 @@ +## +# Armazena o valor atribuído a uma questão dentro de uma submissão. +class Respostum < ApplicationRecord + belongs_to :submissao + belongs_to :questao +end diff --git a/project/app/models/submissao.rb b/project/app/models/submissao.rb new file mode 100644 index 0000000000..e39ec01691 --- /dev/null +++ b/project/app/models/submissao.rb @@ -0,0 +1,7 @@ +## +# Registra que um usuário submeteu respostas para um formulário. +class Submissao < ApplicationRecord + belongs_to :formulario + belongs_to :user + has_many :respostas, class_name: "Respostum", dependent: :destroy +end diff --git a/project/app/models/template.rb b/project/app/models/template.rb new file mode 100644 index 0000000000..c23ea3e9f3 --- /dev/null +++ b/project/app/models/template.rb @@ -0,0 +1,9 @@ +## +# Representa um modelo reutilizável de formulário criado por um administrador. +class Template < ApplicationRecord + belongs_to :admin, class_name: "User" + has_many :questaos, dependent: :destroy + has_many :formularios, dependent: :nullify + + validates :title, presence: { message: "O nome do template é obrigatório" } +end diff --git a/project/app/models/user.rb b/project/app/models/user.rb new file mode 100644 index 0000000000..29d63b4bdb --- /dev/null +++ b/project/app/models/user.rb @@ -0,0 +1,204 @@ +## +# Representa administradores e participantes autenticáveis do CAMAAR. Também +# administra os tokens temporários usados nos fluxos de senha. +class User < ApplicationRecord + # Período durante o qual o link de definição da primeira senha permanece válido. + PASSWORD_SETUP_TTL = 7.days + # Período durante o qual o link de redefinição de senha permanece válido. + PASSWORD_RESET_TTL = 2.hours + + belongs_to :department, optional: true + has_many :class_memberships, dependent: :destroy + has_many :course_classes, through: :class_memberships + has_many :templates, foreign_key: :admin_id, dependent: :destroy + has_many :formularios, foreign_key: :admin_id, dependent: :destroy + + has_secure_password validations: false + + enum :role, { admin: 0, participant: 1 } + + normalizes :email, with: ->(email) { email.strip.downcase } + normalizes :registration, with: ->(registration) { registration.strip } + + validates :name, :email, :registration, :role, presence: true + validates :email, uniqueness: true + validates :registration, uniqueness: true + + ## + # Localiza um usuário por e-mail ou matrícula para autenticação. + # + # === Argumentos + # + # +identifier+:: E-mail ou matrícula informada no login. + # + # === Retorno + # + # Retorna o User encontrado ou +nil+ quando não há correspondência. + # + # === Efeitos colaterais + # + # Consulta o banco de dados. + def self.find_for_login(identifier) + normalized = identifier.to_s.strip + find_by(email: normalized.downcase) || find_by(registration: normalized) + end + + ## + # Localiza o usuário de um token válido de definição inicial de senha. + # + # === Argumentos + # + # +token+:: Token em texto puro recebido pelo usuário. + # + # === Retorno + # + # Retorna o User correspondente ou +nil+ quando o token é inexistente ou expirou. + # + # === Efeitos colaterais + # + # Consulta o banco de dados. + def self.find_by_setup_token(token) + find_valid_token(:password_setup_token_digest, :password_setup_sent_at, token, PASSWORD_SETUP_TTL) + end + + ## + # Localiza o usuário de um token válido de redefinição de senha. + # + # === Argumentos + # + # +token+:: Token em texto puro recebido pelo usuário. + # + # === Retorno + # + # Retorna o User correspondente ou +nil+ quando o token é inexistente ou expirou. + # + # === Efeitos colaterais + # + # Consulta o banco de dados. + def self.find_by_reset_token(token) + find_valid_token(:password_reset_token_digest, :password_reset_sent_at, token, PASSWORD_RESET_TTL) + end + + ## + # Informa se o usuário já possui uma senha armazenada. + # + # Não recebe argumentos. + # + # Retorna +true+ quando +password_digest+ está preenchido e +false+ caso contrário. + # + # Efeitos colaterais: não possui efeitos colaterais. + def password_defined? + password_digest.present? + end + + ## + # Informa se o usuário ainda precisa definir sua primeira senha. + # + # Não recebe argumentos. + # + # Retorna +true+ quando não há senha definida e +false+ caso contrário. + # + # Efeitos colaterais: não possui efeitos colaterais. + def pending_password_setup? + !password_defined? + end + + ## + # Gera e persiste um token temporário para definição da primeira senha. + # + # Não recebe argumentos. + # + # Retorna o token em texto puro que deve ser enviado ao usuário. + # + # Efeitos colaterais: atualiza o digest e o horário do token no banco e pode lançar + # ActiveRecord::RecordInvalid quando a persistência falha. + def generate_password_setup_token! + token = SecureRandom.urlsafe_base64(32) + update!(password_setup_token_digest: self.class.token_digest(token), password_setup_sent_at: Time.current) + token + end + + ## + # Gera e persiste um token temporário para redefinição de senha. + # + # Não recebe argumentos. + # + # Retorna o token em texto puro que deve ser enviado ao usuário. + # + # Efeitos colaterais: atualiza o digest e o horário do token no banco e pode lançar + # ActiveRecord::RecordInvalid quando a persistência falha. + def generate_password_reset_token! + token = SecureRandom.urlsafe_base64(32) + update!(password_reset_token_digest: self.class.token_digest(token), password_reset_sent_at: Time.current) + token + end + + ## + # Substitui a senha do usuário e invalida todos os tokens de senha existentes. + # + # === Argumentos + # + # +new_password+:: Nova senha em texto puro que será processada por +has_secure_password+. + # + # === Retorno + # + # Retorna +true+ quando a atualização é concluída. + # + # === Efeitos colaterais + # + # Atualiza a senha e limpa os tokens no banco; pode lançar ActiveRecord::RecordInvalid. + def apply_new_password!(new_password) + update!( + password: new_password, + password_setup_token_digest: nil, + password_setup_sent_at: nil, + password_reset_token_digest: nil, + password_reset_sent_at: nil + ) + end + + ## + # Calcula o digest SHA-256 usado para armazenar tokens sem guardar o texto puro. + # + # === Argumentos + # + # +token+:: Valor que será convertido para String e processado. + # + # === Retorno + # + # Retorna uma String hexadecimal com o digest SHA-256. + # + # === Efeitos colaterais + # + # Não possui efeitos colaterais. + def self.token_digest(token) + Digest::SHA256.hexdigest(token.to_s) + end + + ## + # Localiza um usuário por digest e confirma se o token ainda está dentro do prazo. + # + # === Argumentos + # + # +digest_column+:: Nome da coluna que armazena o digest do token. + # +sent_at_column+:: Nome da coluna que registra quando o token foi criado. + # +token+:: Token em texto puro usado na busca. + # +ttl+:: Duração máxima de validade do token. + # + # === Retorno + # + # Retorna o User associado ou +nil+ quando não existe registro, data de envio ou validade. + # + # === Efeitos colaterais + # + # Consulta o banco de dados. + def self.find_valid_token(digest_column, sent_at_column, token, ttl) + user = find_by(digest_column => token_digest(token)) + return unless user + return if user.public_send(sent_at_column).blank? + return if user.public_send(sent_at_column) < ttl.ago + + user + end + private_class_method :find_valid_token +end diff --git a/project/app/services/formularios/create_from_template.rb b/project/app/services/formularios/create_from_template.rb new file mode 100644 index 0000000000..be1c4907c8 --- /dev/null +++ b/project/app/services/formularios/create_from_template.rb @@ -0,0 +1,88 @@ +## +# Serviços relacionados à criação e ao gerenciamento de formulários. +module Formularios + ## + # Cria um formulário e copia as questões do template selecionado para manter + # uma versão independente da avaliação. + class CreateFromTemplate + # Resultado imutável contendo o formulário processado e o estado da operação. + Result = Data.define(:formulario, :success?) + + ## + # Inicializa o serviço com o administrador e os atributos do formulário. + # + # === Argumentos + # + # +admin:+:: User administrador responsável pelo novo formulário. + # +params:+:: Hash ou ActionController::Parameters com os atributos permitidos. + # + # === Retorno + # + # Retorna uma nova instância de Formularios::CreateFromTemplate. + # + # === Efeitos colaterais + # + # Armazena os argumentos em memória; não persiste registros. + def initialize(admin:, params:) + @admin = admin + @params = params + end + + ## + # Persiste o formulário e clona as questões quando os dados são válidos. + # + # Não recebe argumentos. + # + # Retorna Result com o Formulario processado e +success?+ igual a +true+ ou +false+. + # + # Efeitos colaterais: cria o formulário e suas questões no banco quando as + # validações são satisfeitas. + def call + formulario = Formulario.new(@params) + formulario.admin = @admin + formulario.target_role = template&.target_role + + if formulario.save + clone_questoes(formulario) + Result.new(formulario:, success?: true) + else + Result.new(formulario:, success?: false) + end + end + + private + + ## + # Localiza o template selecionado nos parâmetros do serviço. + # + # Não recebe argumentos. + # + # Retorna o Template encontrado ou +nil+ quando o identificador é inválido. + # + # Efeitos colaterais: consulta o banco de dados. + def template + Template.find_by(id: @params[:template_id]) + end + + ## + # Copia para o formulário todas as questões do template selecionado. + # + # === Argumentos + # + # +formulario+:: Formulario que receberá as novas questões. + # + # === Retorno + # + # Retorna a coleção de questões percorrida ou +nil+ quando não existe template. + # + # === Efeitos colaterais + # + # Cria uma Questao no banco para cada questão do template e pode lançar uma + # exceção de persistência. + def clone_questoes(formulario) + template&.questaos&.each do |questao| + formulario.questaos.create!(enunciado: questao.enunciado, tipo: questao.tipo) + end + end + end +end diff --git a/project/app/services/sigaa/class_members_importer.rb b/project/app/services/sigaa/class_members_importer.rb new file mode 100644 index 0000000000..e75eda3843 --- /dev/null +++ b/project/app/services/sigaa/class_members_importer.rb @@ -0,0 +1,348 @@ +module Sigaa + ## + # Importa participantes e vínculos de turma, registrando inconsistências e + # enviando instruções de senha para usuários recém-criados. + class ClassMembersImporter + # Resultado imutável com quantidades de criações, atualizações e inconsistências. + Result = Data.define(:created_count, :updated_count, :inconsistencies) + + # Caminho padrão do arquivo JSON de participantes na raiz do repositório. + DEFAULT_PATH = Rails.root.join("..", "class_members.json") + + ## + # Inicializa o importador de participantes. + # + # === Argumentos + # + # +path:DEFAULT_PATH+:: Caminho do arquivo JSON; utiliza DEFAULT_PATH por padrão. + # +semester:nil+:: Período letivo que será filtrado ou +nil+ para importar todos. + # +imported_by:nil+:: User administrador que iniciou a importação ou +nil+. + # + # === Retorno + # + # Retorna uma nova instância de Sigaa::ClassMembersImporter. + # + # === Efeitos colaterais + # + # Armazena os argumentos em memória; não lê o arquivo nem persiste registros. + def initialize(path: DEFAULT_PATH, semester: nil, imported_by: nil) + @path = path + @semester = semester + @imported_by = imported_by + end + + ## + # Sincroniza usuários e vínculos encontrados no arquivo de participantes. + # + # Não recebe argumentos. + # + # Retorna Result com quantidades de criações, atualizações e inconsistências. + # + # Efeitos colaterais: lê o JSON, cria ou atualiza turmas, departamentos, usuários, + # vínculos e inconsistências e pode enviar e-mails de definição de senha. + def call + counts = { created: 0, updated: 0, inconsistencies: 0 } + + parsed_data.each do |class_payload| + course_class = find_or_create_course_class(class_payload) + members_for(class_payload).each do |member_payload, membership_role| + process_member(member_payload, membership_role, course_class, counts) + end + end + + Result.new(created_count: counts[:created], updated_count: counts[:updated], inconsistencies: counts[:inconsistencies]) + end + + private + + # Caminho de origem, semestre selecionado e administrador que iniciou a importação. + attr_reader :path, :semester, :imported_by + + ## + # Lê o arquivo de participantes e aplica o filtro opcional de semestre. + # + # Não recebe argumentos. + # + # Retorna um Array de hashes do JSON, completo ou filtrado. + # + # Efeitos colaterais: lê o arquivo e pode lançar erros de leitura ou JSON::ParserError. + def parsed_data + data = JSON.parse(File.read(path)) + return data if semester.blank? + + data.select { |entry| entry["semester"] == semester } + end + + ## + # Localiza ou cria a turma descrita por um bloco do arquivo de participantes. + # + # === Argumentos + # + # +class_payload+:: Hash com código, turma, semestre e nome da disciplina. + # + # === Retorno + # + # Retorna a CourseClass encontrada ou criada. + # + # === Efeitos colaterais + # + # Pode criar departamento e turma no banco ou lançar erros por dados obrigatórios ausentes. + def find_or_create_course_class(class_payload) + code = class_payload.fetch("code") + department = department_for(code) + + CourseClass.find_or_create_by!( + code:, + class_code: class_payload.fetch("classCode"), + semester: class_payload.fetch("semester") + ) do |course_class| + course_class.department = department + course_class.name = class_payload["name"] + end + end + + ## + # Obtém ou cria o departamento ao qual um código de disciplina pertence. + # + # === Argumentos + # + # +code+:: Código da disciplina usado como alternativa para inferir o departamento. + # + # === Retorno + # + # Retorna o Department encontrado ou criado. + # + # === Efeitos colaterais + # + # Pode criar um departamento e associá-lo ao administrador no banco de dados. + def department_for(code) + department_code = resolve_department_code(code) + department = Department.find_or_create_by!(code: department_code) { |dept| dept.name = department_code } + imported_by.update!(department:) if imported_by && imported_by.department_id.nil? + department + end + + def resolve_department_code(code) + imported_by&.department&.code || code.to_s[/\A[A-Za-z]+/]&.upcase || "GERAL" + end + + ## + # Normaliza docentes e discentes de uma turma em pares de participante e papel. + # + # === Argumentos + # + # +class_payload+:: Hash da turma contendo coleções de docentes e discentes. + # + # === Retorno + # + # Retorna um Array de pares +[member_payload, role]+. + # + # === Efeitos colaterais + # + # Não possui efeitos colaterais. + def members_for(class_payload) + class_payload.flat_map do |key, value| + next [] unless %w[dicente docente].include?(key) + + role = key == "docente" ? :docente : :discente + members = value.is_a?(Array) ? value : (value.is_a?(Hash) ? [ value ] : []) + members.map { |member_payload| [ member_payload, role ] } + end + end + + ## + # Verifica se um participante não possui e-mail ou matrícula. + # + # === Argumentos + # + # +member_payload+:: Hash com os dados do participante. + # + # === Retorno + # + # Retorna +true+ quando falta um dado obrigatório e +false+ caso contrário. + # + # === Efeitos colaterais + # + # Não possui efeitos colaterais. + def invalid_member?(member_payload) + member_payload["email"].blank? || member_registration(member_payload).blank? + end + + ## + # Extrai a matrícula acadêmica ou o identificador alternativo do participante. + # + # === Argumentos + # + # +member_payload+:: Hash com os dados do participante. + # + # === Retorno + # + # Retorna o valor de +matricula+, o valor de +usuario+ ou +nil+. + # + # === Efeitos colaterais + # + # Não possui efeitos colaterais. + def member_registration(member_payload) + member_payload["matricula"].presence || member_payload["usuario"].presence + end + + ## + # Registra um participante inválido para análise posterior. + # + # === Argumentos + # + # +member_payload+:: Hash original que apresentou inconsistência. + # +message+:: Descrição legível do problema encontrado. + # + # === Retorno + # + # Retorna o ImportInconsistency criado. + # + # === Efeitos colaterais + # + # Insere uma inconsistência no banco e pode lançar ActiveRecord::RecordInvalid. + def register_inconsistency(member_payload, message) + ImportInconsistency.create!(source: "class_members.json", message:, payload: member_payload) + end + + ## + # Cria ou atualiza o usuário correspondente ao participante importado. + # + # === Argumentos + # + # +member_payload+:: Hash com nome, e-mail e matrícula do participante. + # +department+:: Department que será associado ao usuário. + # + # === Retorno + # + # Retorna +:created+, +:updated+ ou +:unchanged+ conforme a operação realizada. + # + # === Efeitos colaterais + # + # Cria ou atualiza o usuário no banco e envia e-mail de definição de senha para contas novas. + def sync_user(member_payload, department) + registration = member_registration(member_payload).to_s.strip + email = normalized_email(member_payload) + name = member_payload["nome"] + user = User.find_by(email:) || User.find_by(registration:) + + return create_user(name, email, registration, department) if user.nil? + + apply_user_updates(user, name, email, registration, department) + end + + def create_user(name, email, registration, department) + user = User.create!(name:, email:, registration:, role: :participant, department:) + send_password_setup(user) if user.pending_password_setup? + :created + end + + def apply_user_updates(user, name, email, registration, department) + updates = build_user_updates(user, name, email, registration, department) + return :unchanged unless updates.any? + + user.update!(updates) + :updated + end + + def build_user_updates(user, name, email, registration, department) + {}.tap do |u| + u[:name] = name if user.name != name + u[:email] = email if user.email != email + u[:registration] = registration if user.registration != registration + u[:department] = department if user.department_id != department.id + end + end + + def process_member(member_payload, membership_role, course_class, counts) + if invalid_member?(member_payload) + register_inconsistency(member_payload, "Participante sem email ou matricula") + counts[:inconsistencies] += 1 + return + end + + user_status = sync_user(member_payload, course_class.department) + counts[:created] += 1 if user_status == :created + counts[:updated] += 1 if user_status == :updated + + membership_status = ensure_membership(user_for(member_payload), course_class, membership_role) + counts[:created] += 1 if membership_status == :created + end + + ## + # Localiza o usuário já sincronizado a partir do e-mail ou da matrícula importada. + # + # === Argumentos + # + # +member_payload+:: Hash com os dados usados na busca. + # + # === Retorno + # + # Retorna o User encontrado. + # + # === Efeitos colaterais + # + # Consulta o banco e pode lançar ActiveRecord::RecordNotFound quando não há correspondência. + def user_for(member_payload) + registration = member_registration(member_payload).to_s.strip + User.find_by(email: normalized_email(member_payload)) || User.find_by!(registration:) + end + + ## + # Normaliza o e-mail importado removendo espaços e convertendo-o para minúsculas. + # + # === Argumentos + # + # +member_payload+:: Hash contendo o campo +email+. + # + # === Retorno + # + # Retorna uma String normalizada, inclusive vazia quando o campo não existe. + # + # === Efeitos colaterais + # + # Não possui efeitos colaterais. + def normalized_email(member_payload) + member_payload["email"].to_s.strip.downcase + end + + ## + # Gera o token inicial e entrega o e-mail de definição de senha. + # + # === Argumentos + # + # +user+:: User recém-criado que receberá as instruções. + # + # === Retorno + # + # Retorna a mensagem entregue pelo Action Mailer. + # + # === Efeitos colaterais + # + # Atualiza o token do usuário no banco e envia um e-mail de forma síncrona. + def send_password_setup(user) + UserMailer.password_setup(user, user.generate_password_setup_token!).deliver_now + end + + ## + # Garante a existência do vínculo do usuário com a turma no papel informado. + # + # === Argumentos + # + # +user+:: User que participa da turma. + # +course_class+:: CourseClass à qual o usuário será vinculado. + # +role+:: Papel +:discente+ ou +:docente+ do vínculo. + # + # === Retorno + # + # Retorna +:created+ quando cria o vínculo e +:unchanged+ quando ele já existia. + # + # === Efeitos colaterais + # + # Pode inserir uma ClassMembership no banco e lançar erros de persistência. + def ensure_membership(user, course_class, role) + membership = ClassMembership.find_or_create_by!(user:, course_class:, role:) + membership.previously_new_record? ? :created : :unchanged + end + end +end diff --git a/project/app/services/sigaa/classes_importer.rb b/project/app/services/sigaa/classes_importer.rb new file mode 100644 index 0000000000..a2b86cacea --- /dev/null +++ b/project/app/services/sigaa/classes_importer.rb @@ -0,0 +1,160 @@ +## +# Serviços responsáveis por importar e sincronizar os dados exportados pelo SIGAA. +module Sigaa + ## + # Importa as turmas de um semestre, criando registros novos e atualizando nome + # ou horário de turmas existentes. + class ClassesImporter + # Resultado imutável com as quantidades de turmas criadas e atualizadas. + Result = Data.define(:created_count, :updated_count) + + # Caminho padrão do arquivo JSON de turmas na raiz do repositório. + DEFAULT_PATH = Rails.root.join("..", "classes.json") + + ## + # Inicializa o importador de turmas. + # + # === Argumentos + # + # +path:DEFAULT_PATH+:: Caminho do arquivo JSON; utiliza DEFAULT_PATH por padrão. + # +semester:+:: Período letivo que será importado. + # +imported_by:nil+:: User administrador que iniciou a importação ou +nil+. + # + # === Retorno + # + # Retorna uma nova instância de Sigaa::ClassesImporter. + # + # === Efeitos colaterais + # + # Armazena os argumentos em memória; não lê o arquivo nem persiste registros. + def initialize(path: DEFAULT_PATH, semester:, imported_by: nil) + @path = path + @semester = semester + @imported_by = imported_by + end + + ## + # Sincroniza as turmas do semestre configurado. + # + # Não recebe argumentos. + # + # Retorna Result com as quantidades de registros criados e atualizados. + # + # Efeitos colaterais: lê o JSON e cria ou atualiza departamentos, turmas e, + # quando necessário, o departamento do administrador. + def call + counts = { created: 0, updated: 0 } + + entries_for_semester.each do |entry| + attrs = extract_class_attrs(entry) + course_class = find_course_class(attrs) + sync_course_class(course_class, attrs, counts) + end + + Result.new(created_count: counts[:created], updated_count: counts[:updated]) + end + + private + + # Caminho de origem, semestre selecionado e administrador que iniciou a importação. + attr_reader :path, :semester, :imported_by + + ## + # Seleciona do arquivo apenas as entradas do semestre configurado. + # + # Não recebe argumentos. + # + # Retorna um Array de hashes provenientes do JSON. + # + # Efeitos colaterais: lê o arquivo indicado por +path+ e pode lançar erros de + # leitura ou JSON::ParserError. + def entries_for_semester + JSON.parse(File.read(path)).select do |entry| + entry.dig("class", "semester") == semester + end + end + + ## + # Converte uma entrada do JSON nos atributos aceitos por CourseClass. + # + # === Argumentos + # + # +entry+:: Hash de uma disciplina e sua turma no formato exportado pelo SIGAA. + # + # === Retorno + # + # Retorna um Hash com +code+, +name+, +class_code+, +semester+ e +time+. + # + # === Efeitos colaterais + # + # Não altera dados; pode lançar KeyError quando faltam chaves obrigatórias. + def extract_class_attrs(entry) + class_info = entry.fetch("class") + { + code: entry.fetch("code"), + name: entry.fetch("name"), + class_code: class_info.fetch("classCode"), + semester: class_info.fetch("semester"), + time: class_info["time"] + } + end + + ## + # Obtém ou cria o departamento ao qual um código de disciplina pertence. + # + # === Argumentos + # + # +code+:: Código da disciplina usado como alternativa para inferir o departamento. + # + # === Retorno + # + # Retorna o Department encontrado ou criado. + # + # === Efeitos colaterais + # + # Pode criar um departamento e associá-lo ao administrador no banco de dados. + def department_for(code) + department_code = resolve_department_code(code) + department = Department.find_or_create_by!(code: department_code) { |dept| dept.name = department_code } + imported_by.update!(department:) if imported_by && imported_by.department_id.nil? + department + end + + def resolve_department_code(code) + imported_by&.department&.code || code.to_s[/\A[A-Za-z]+/]&.upcase || "GERAL" + end + + def find_course_class(attrs) + CourseClass.find_by(code: attrs[:code], class_code: attrs[:class_code], semester: attrs[:semester]) + end + + def sync_course_class(course_class, attrs, counts) + if course_class.nil? + CourseClass.create!(attrs.merge(department: department_for(attrs[:code]))) + counts[:created] += 1 + elsif course_class_attributes_changed?(course_class, attrs) + course_class.update!(attrs.slice(:name, :time)) + counts[:updated] += 1 + end + end + + ## + # Verifica se nome ou horário de uma turma existente precisam ser atualizados. + # + # === Argumentos + # + # +course_class+:: CourseClass atualmente persistida. + # +attrs+:: Hash com os atributos importados. + # + # === Retorno + # + # Retorna +true+ quando nome ou horário diferem e +false+ quando são iguais. + # + # === Efeitos colaterais + # + # Não possui efeitos colaterais. + def course_class_attributes_changed?(course_class, attrs) + course_class.name != attrs[:name] || course_class.time != attrs[:time] + end + end +end diff --git a/project/app/services/sigaa/data_synchronizer.rb b/project/app/services/sigaa/data_synchronizer.rb new file mode 100644 index 0000000000..abd940c595 --- /dev/null +++ b/project/app/services/sigaa/data_synchronizer.rb @@ -0,0 +1,87 @@ +module Sigaa + ## + # Coordena os importadores de turmas e participantes e consolida suas contagens. + class DataSynchronizer + # Resultado imutável da sincronização completa, incluindo o estado de atualização. + Result = Data.define(:created_count, :updated_count, :inconsistencies, :already_up_to_date?) + + ## + # Inicializa a sincronização de um semestre. + # + # === Argumentos + # + # +semester:+:: Período letivo que será sincronizado. + # +imported_by:+:: User administrador que iniciou a operação. + # +classes_path:ClassesImporter::DEFAULT_PATH+:: Caminho do JSON de turmas. + # +members_path:ClassMembersImporter::DEFAULT_PATH+:: Caminho do JSON de participantes. + # + # === Retorno + # + # Retorna uma nova instância de Sigaa::DataSynchronizer. + # + # === Efeitos colaterais + # + # Armazena os argumentos em memória; não lê arquivos nem persiste registros. + def initialize(semester:, imported_by:, classes_path: ClassesImporter::DEFAULT_PATH, members_path: ClassMembersImporter::DEFAULT_PATH) + @semester = semester + @imported_by = imported_by + @classes_path = classes_path + @members_path = members_path + end + + ## + # Executa sequencialmente a importação de turmas e participantes. + # + # Não recebe argumentos. + # + # Retorna Result com contagens consolidadas, inconsistências e indicação de que + # a base já estava atualizada. + # + # Efeitos colaterais: lê os arquivos de origem e pode criar ou atualizar todos os + # registros e e-mails tratados pelos dois importadores. + def call + classes_result = ClassesImporter.new(path: classes_path, semester:, imported_by:).call + members_result = ClassMembersImporter.new(path: members_path, semester:, imported_by:).call + + build_result(classes_result, members_result) + end + + ## + # Descobre os semestres disponíveis nos dois arquivos de importação. + # + # === Argumentos + # + # +classes_path:ClassesImporter::DEFAULT_PATH+:: Caminho do arquivo JSON de turmas. + # +members_path:ClassMembersImporter::DEFAULT_PATH+:: Caminho do arquivo JSON de participantes. + # + # === Retorno + # + # Retorna um Array sem duplicatas, ordenado do semestre mais recente para o mais antigo. + # + # === Efeitos colaterais + # + # Lê ambos os arquivos e pode lançar erros de leitura ou JSON::ParserError. + def self.available_semesters(classes_path: ClassesImporter::DEFAULT_PATH, members_path: ClassMembersImporter::DEFAULT_PATH) + from_classes = JSON.parse(File.read(classes_path)).filter_map { |entry| entry.dig("class", "semester") } + from_members = JSON.parse(File.read(members_path)).filter_map { |entry| entry["semester"] } + + (from_classes + from_members).uniq.sort.reverse + end + + private + + # Semestre, administrador e caminhos de origem usados pela sincronização. + attr_reader :semester, :imported_by, :classes_path, :members_path + + def build_result(classes_result, members_result) + created_count = classes_result.created_count + members_result.created_count + updated_count = classes_result.updated_count + members_result.updated_count + Result.new( + created_count:, + updated_count:, + inconsistencies: members_result.inconsistencies, + already_up_to_date?: created_count.zero? && updated_count.zero? + ) + end + end +end diff --git a/project/app/views/course_classes/index.html.erb b/project/app/views/course_classes/index.html.erb new file mode 100644 index 0000000000..6afe35c28c --- /dev/null +++ b/project/app/views/course_classes/index.html.erb @@ -0,0 +1,21 @@ +<% content_for :title, "Gerenciamento de Turmas" %> + +

Gerenciamento de Turmas

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

Nenhuma turma encontrada para o seu departamento no semestre atual.

+<% else %> +
+ <% @course_classes.each do |course_class| %> +
+

<%= course_class.name %>

+

Departamento: <%= course_class.department.name %>

+

Semestre: <%= course_class.semester %>

+ + <%= link_to "Avaliar desempenho", course_class_path(course_class), class: "btn btn-secondary" %> +
+ <% end %> +
+<% end %> + +<%= link_to "Voltar ao painel", root_path, class: "btn-link" %> diff --git a/project/app/views/course_classes/show.html.erb b/project/app/views/course_classes/show.html.erb new file mode 100644 index 0000000000..14948359ea --- /dev/null +++ b/project/app/views/course_classes/show.html.erb @@ -0,0 +1,24 @@ +<% content_for :title, @course_class.name %> + +

<%= @course_class.name %>

+

Departamento: <%= @course_class.department.name %>

+

Semestre: <%= @course_class.semester %>

+ +

Avaliações da turma

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

Nenhum formulário de avaliação foi criado para esta turma ainda.

+<% else %> +
+ <% @formularios.each do |formulario| %> +
+

<%= formulario.title %>

+

Status: <%= formulario.status %>

+

<%= pluralize(Submissao.where(formulario: formulario).count, "resposta recebida") %>

+ <%= link_to "Ver Resultados", formulario_path(formulario), class: "btn btn-secondary" %> +
+ <% end %> +
+<% end %> + +<%= link_to "Voltar para Gerenciamento de Turmas", course_classes_path, class: "btn-link" %> diff --git a/project/app/views/dashboard/show.html.erb b/project/app/views/dashboard/show.html.erb new file mode 100644 index 0000000000..0903279a09 --- /dev/null +++ b/project/app/views/dashboard/show.html.erb @@ -0,0 +1,42 @@ +<% content_for :title, "Avaliações" %> + +

Bem-vindo(a), <%= current_user.name %>

+ +<% if current_admin %> +
+
+

Importação de Dados do SIGAA

+

Sincronize turmas, matérias e participantes

+ <%= link_to "Acessar", imports_sigaa_path, class: "btn btn-secondary" %> +
+
+

Templates

+

Gerencie os modelos de avaliação

+ <%= link_to "Acessar", templates_path, class: "btn btn-secondary" %> +
+
+

Formulários

+

Gerencie os formulários criados

+ <%= link_to "Acessar", formularios_path, class: "btn btn-secondary" %> +
+
+

Turmas

+

Gerencie as turmas do seu departamento

+ <%= link_to "Acessar", course_classes_path, class: "btn btn-secondary" %> +
+
+<% else %> + <% if current_user.course_classes.empty? %> +

Você ainda não está matriculado em nenhuma turma.

+ <% else %> +
+ <% current_user.course_classes.each do |course_class| %> +
+

<%= course_class.name %>

+

Semestre <%= course_class.semester %>

+ +
+ <% end %> +
+ <% end %> +<% end %> diff --git a/project/app/views/formularios/_form.html.erb b/project/app/views/formularios/_form.html.erb new file mode 100644 index 0000000000..fb286272b6 --- /dev/null +++ b/project/app/views/formularios/_form.html.erb @@ -0,0 +1,31 @@ +<%= form_with(model: formulario) do |form| %> + <% if formulario.errors.any? %> +
+

<%= pluralize(formulario.errors.count, "erro") %> impediu(ram) este formulário de ser salvo:

+ +
+ <% end %> + +
+ <%= form.label :title, "Nome do formulário:" %> + <%= form.text_field :title %> +
+ +
+ <%= form.label :template_id, "Template:" %> + <%= form.collection_select :template_id, @templates, :id, :title, include_blank: "Selecione um template" %> +
+ +
+ <%= form.label :course_class_id, "Turma:" %> + <%= form.collection_select :course_class_id, @course_classes, :id, :name, include_blank: "Selecione uma turma" %> +
+ +
+ <%= form.submit "Enviar", class: "btn btn-primary" %> +
+<% end %> diff --git a/project/app/views/formularios/_formulario.html.erb b/project/app/views/formularios/_formulario.html.erb new file mode 100644 index 0000000000..aa0adb521c --- /dev/null +++ b/project/app/views/formularios/_formulario.html.erb @@ -0,0 +1,31 @@ +
+
+ Title: + <%= formulario.title %> +
+ +
+ Target role: + <%= formulario.target_role %> +
+ +
+ Status: + <%= formulario.status %> +
+ +
+ Template: + <%= formulario.template_id %> +
+ +
+ Turma: + <%= formulario.course_class_id %> +
+ +
+ Admin: + <%= formulario.admin_id %> +
+
diff --git a/project/app/views/formularios/_formulario.json.jbuilder b/project/app/views/formularios/_formulario.json.jbuilder new file mode 100644 index 0000000000..7572ef155d --- /dev/null +++ b/project/app/views/formularios/_formulario.json.jbuilder @@ -0,0 +1,2 @@ +json.extract! formulario, :id, :title, :target_role, :status, :template_id, :turma_id, :admin_id, :created_at, :updated_at +json.url formulario_url(formulario, format: :json) diff --git a/project/app/views/formularios/index.html.erb b/project/app/views/formularios/index.html.erb new file mode 100644 index 0000000000..70c70ce244 --- /dev/null +++ b/project/app/views/formularios/index.html.erb @@ -0,0 +1,39 @@ +<% content_for :title, "Formulários" %> + +

Formulários

+ +<% if current_user.admin? %> + <% if @formularios.empty? %> +

Você ainda não possui formulários criados para as suas turmas.

+ <% else %> +
+ <% @formularios.each do |formulario| %> +
+

<%= formulario.title %>

+

Turma: <%= formulario.course_class&.name %>

+

Status: <%= formulario.status %>

+ <%= link_to "Ver Detalhes", formulario, class: "btn btn-secondary" %> +
+ <% end %> +
+ <% end %> + +
+ <%= link_to "Novo formulário", new_formulario_path, class: "btn btn-primary" %> + +<% else %> +

Avaliações Pendentes

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

Você não possui nenhuma avaliação pendente no momento.

+ <% else %> +
+ <% @formularios_pendentes.each do |formulario| %> +
+

<%= formulario.title %>

+ <%= link_to "Responder Avaliação", formulario, class: "btn btn-primary" %> +
+ <% end %> +
+ <% end %> +<% end %> diff --git a/project/app/views/formularios/index.json.jbuilder b/project/app/views/formularios/index.json.jbuilder new file mode 100644 index 0000000000..70770973a8 --- /dev/null +++ b/project/app/views/formularios/index.json.jbuilder @@ -0,0 +1 @@ +json.array! @formularios, partial: "formularios/formulario", as: :formulario diff --git a/project/app/views/formularios/new.html.erb b/project/app/views/formularios/new.html.erb new file mode 100644 index 0000000000..342c1c14f7 --- /dev/null +++ b/project/app/views/formularios/new.html.erb @@ -0,0 +1,11 @@ +<% content_for :title, "Novo formulário" %> + +

Novo formulário

+ +<%= render "form", formulario: @formulario %> + +
+ +
+ <%= link_to "Voltar para formulários", formularios_path, class: "btn-link" %> +
diff --git a/project/app/views/formularios/show.html.erb b/project/app/views/formularios/show.html.erb new file mode 100644 index 0000000000..0433660828 --- /dev/null +++ b/project/app/views/formularios/show.html.erb @@ -0,0 +1,74 @@ +<% content_for :title, @formulario.title %> + +

<%= @formulario.title %>

+

Turma: <%= @formulario.course_class&.name %>

+ +
+ +<% if current_admin %> + <%# -- VISÃO DO ADMINISTRADOR -- %> +

Resultados

+ + <% if @tem_submissoes %> + <%= link_to "Exportar Resultados (CSV)", exportar_csv_formulario_path(@formulario, format: :csv), class: "btn btn-primary" %> + +
+ <% @resultados.each do |questao| %> +
+

<%= questao.enunciado %>

+ + <% if questao.respostas.empty? %> +

Nenhuma resposta registrada para esta questão.

+ <% else %> +
    + <% questao.respostas.each do |resposta| %> +
  • <%= resposta.submissao.user.name %>: <%= formatar_resposta(resposta, questao.tipo) %>
  • + <% end %> +
+ <% end %> +
+ <% end %> +
+ <% else %> +

Não é possível exportar o relatório pois nenhuma resposta foi registrada. Aguarde os participantes responderem.

+ <% end %> + +
+ <%= link_to "Voltar para listagem", formularios_path, class: "btn-link" %> + +
+

Tem certeza que deseja deletar este formulário?

+ <%= button_to "Deletar formulário", formulario_path(@formulario), method: :delete, class: "btn btn-secondary" %> +
+ +<% else %> + <%# -- VISÃO DO PARTICIPANTE RESPONDENDO -- %> +

Responda o Questionário

+ + <%= form_with scope: :respostum, url: resposta_path, method: :post do |f| %> + <%= f.hidden_field :formulario_id, value: @formulario.id %> + + <% @formulario.questaos.each do |questao| %> +
+ + + <% case questao.tipo %> + <% when "rating" %> + <%= number_field_tag "respostum[respostas][#{questao.id}][valor]", nil, in: 1..5, required: true %> + <% when "boolean" %> +
+ + +
+ <% else %> + <%= text_area_tag "respostum[respostas][#{questao.id}][valor]", nil %> + <% end %> +
+ <% end %> + + <%= f.submit "Enviar Resposta", class: "btn btn-primary" %> + <% end %> + +
+ <%= link_to "Voltar", formularios_path, class: "btn-link" %> +<% end %> diff --git a/project/app/views/formularios/show.json.jbuilder b/project/app/views/formularios/show.json.jbuilder new file mode 100644 index 0000000000..476f64d624 --- /dev/null +++ b/project/app/views/formularios/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! "formularios/formulario", formulario: @formulario diff --git a/project/app/views/imports/sigaa/new.html.erb b/project/app/views/imports/sigaa/new.html.erb new file mode 100644 index 0000000000..3b6260d70a --- /dev/null +++ b/project/app/views/imports/sigaa/new.html.erb @@ -0,0 +1,19 @@ +<% content_for :title, "Importação de Dados do SIGAA" %> + +

Importação de Dados do SIGAA

+ +<%= form_with scope: :sigaa_sync, url: imports_sigaa_path, method: :post do |form| %> +
+ <%= form.label :semester, "Período letivo" %> + <%= form.select :semester, options_for_select(@semesters, @semester) %> +
+ +
+ <%= form.label :scope, "Dados para sincronizar" %> +

Turmas, Matérias e Participantes

+
+ + <%= form.submit "Sincronizar turmas, matérias e participantes", class: "btn btn-primary" %> +<% end %> + +

<%= link_to "Voltar ao painel", root_path, class: "btn-link" %>

diff --git a/project/app/views/layouts/application.html.erb b/project/app/views/layouts/application.html.erb new file mode 100644 index 0000000000..37abc903ac --- /dev/null +++ b/project/app/views/layouts/application.html.erb @@ -0,0 +1,40 @@ + + + + <%= content_for(:title) || "CAMAAR" %> + + + + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + <%= yield :head %> + + <%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %> + <%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %> + + + + + + <%# Includes all stylesheet files in app/assets/stylesheets %> + <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> + <%= javascript_importmap_tags %> + + + +
+ <%= render "shared/header" %> + +
+ <%= render "shared/sidebar" %> + +
+ <%= render "shared/flash" %> + <%= yield %> +
+
+
+ + diff --git a/project/app/views/layouts/auth.html.erb b/project/app/views/layouts/auth.html.erb new file mode 100644 index 0000000000..406c4d488b --- /dev/null +++ b/project/app/views/layouts/auth.html.erb @@ -0,0 +1,35 @@ + + + + <%= content_for(:title) || "CAMAAR" %> + + + + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + <%= yield :head %> + + + + + + <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> + <%= javascript_importmap_tags %> + + + +
+
+
+ <%= render "shared/flash" %> + <%= yield %> +
+
+ Bem-vindo(a) ao CAMAAR +
+
+
+ + diff --git a/project/app/views/layouts/mailer.html.erb b/project/app/views/layouts/mailer.html.erb new file mode 100644 index 0000000000..3aac9002ed --- /dev/null +++ b/project/app/views/layouts/mailer.html.erb @@ -0,0 +1,13 @@ + + + + + + + + + <%= yield %> + + diff --git a/project/app/views/layouts/mailer.text.erb b/project/app/views/layouts/mailer.text.erb new file mode 100644 index 0000000000..37f0bddbd7 --- /dev/null +++ b/project/app/views/layouts/mailer.text.erb @@ -0,0 +1 @@ +<%= yield %> diff --git a/project/app/views/password_resets/edit.html.erb b/project/app/views/password_resets/edit.html.erb new file mode 100644 index 0000000000..e9f1d65070 --- /dev/null +++ b/project/app/views/password_resets/edit.html.erb @@ -0,0 +1,15 @@ +

Redefinir senha

+ +<%= form_with model: @user, url: password_reset_path(@token), method: :patch do |form| %> +
+ <%= form.label :password, "Nova senha" %> + <%= form.password_field :password %> +
+ +
+ <%= form.label :password_confirmation, "Confirmacao de senha" %> + <%= form.password_field :password_confirmation %> +
+ + <%= form.submit "Redefinir senha", class: "btn btn-primary" %> +<% end %> diff --git a/project/app/views/password_resets/invalid.html.erb b/project/app/views/password_resets/invalid.html.erb new file mode 100644 index 0000000000..8173805167 --- /dev/null +++ b/project/app/views/password_resets/invalid.html.erb @@ -0,0 +1 @@ +

Link inválido

diff --git a/project/app/views/password_resets/new.html.erb b/project/app/views/password_resets/new.html.erb new file mode 100644 index 0000000000..7ed6867d89 --- /dev/null +++ b/project/app/views/password_resets/new.html.erb @@ -0,0 +1,10 @@ +

Esqueci minha senha

+ +<%= form_with scope: :password_reset, url: password_resets_path do |form| %> +
+ <%= form.label :email, "Email" %> + <%= form.email_field :email %> +
+ + <%= form.submit "Solicitar redefinicao de senha", class: "btn btn-primary" %> +<% end %> diff --git a/project/app/views/password_setups/edit.html.erb b/project/app/views/password_setups/edit.html.erb new file mode 100644 index 0000000000..db4f2b038b --- /dev/null +++ b/project/app/views/password_setups/edit.html.erb @@ -0,0 +1,15 @@ +

Definir senha

+ +<%= form_with model: @user, url: password_setup_path(@token), method: :patch do |form| %> +
+ <%= form.label :password, "Nova senha" %> + <%= form.password_field :password %> +
+ +
+ <%= form.label :password_confirmation, "Confirmacao de senha" %> + <%= form.password_field :password_confirmation %> +
+ + <%= form.submit "Definir senha", class: "btn btn-primary" %> +<% end %> diff --git a/project/app/views/password_setups/invalid.html.erb b/project/app/views/password_setups/invalid.html.erb new file mode 100644 index 0000000000..8173805167 --- /dev/null +++ b/project/app/views/password_setups/invalid.html.erb @@ -0,0 +1 @@ +

Link inválido

diff --git a/project/app/views/pwa/manifest.json.erb b/project/app/views/pwa/manifest.json.erb new file mode 100644 index 0000000000..5cd24347d3 --- /dev/null +++ b/project/app/views/pwa/manifest.json.erb @@ -0,0 +1,22 @@ +{ + "name": "Project", + "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": "Project.", + "theme_color": "red", + "background_color": "red" +} diff --git a/project/app/views/pwa/service-worker.js b/project/app/views/pwa/service-worker.js new file mode 100644 index 0000000000..b3a13fb7bb --- /dev/null +++ b/project/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/project/app/views/sessions/new.html.erb b/project/app/views/sessions/new.html.erb new file mode 100644 index 0000000000..914886d756 --- /dev/null +++ b/project/app/views/sessions/new.html.erb @@ -0,0 +1,17 @@ +

Login

+ +<%= form_with scope: :session, url: login_path do |form| %> +
+ <%= form.label :identifier, "Email ou matricula" %> + <%= form.text_field :identifier %> +
+ +
+ <%= form.label :password, "Senha" %> + <%= form.password_field :password %> +
+ + <%= form.submit "Entrar", class: "btn btn-primary" %> +<% end %> + +

<%= link_to "Esqueci minha senha", new_password_reset_path, class: "btn-link" %>

diff --git a/project/app/views/shared/_flash.html.erb b/project/app/views/shared/_flash.html.erb new file mode 100644 index 0000000000..4b6a0a8f37 --- /dev/null +++ b/project/app/views/shared/_flash.html.erb @@ -0,0 +1,3 @@ +<% flash.each do |type, message| %> +

<%= message %>

+<% end %> diff --git a/project/app/views/shared/_header.html.erb b/project/app/views/shared/_header.html.erb new file mode 100644 index 0000000000..6f5c34436a --- /dev/null +++ b/project/app/views/shared/_header.html.erb @@ -0,0 +1,19 @@ +
+
+ + <%= content_for(:title) || "CAMAAR" %> +
+ + + +
+ <%= current_user.name.first.upcase %> +
+ <%= button_to "Logout", logout_path, method: :delete %> +
+
+
diff --git a/project/app/views/shared/_sidebar.html.erb b/project/app/views/shared/_sidebar.html.erb new file mode 100644 index 0000000000..c100acb7f3 --- /dev/null +++ b/project/app/views/shared/_sidebar.html.erb @@ -0,0 +1,10 @@ + diff --git a/project/app/views/templates/_form.html.erb b/project/app/views/templates/_form.html.erb new file mode 100644 index 0000000000..0df0d721ee --- /dev/null +++ b/project/app/views/templates/_form.html.erb @@ -0,0 +1,51 @@ +<%= form_with(model: template) do |form| %> + <% if template.errors.any? %> +
+

<%= pluralize(template.errors.count, "erro") %> impediu(ram) este template de ser salvo:

+ + +
+ <% end %> + +
+ <%= form.label :title, "Nome do template:" %> + <%= form.text_field :title %> +
+ +
+ <%= form.label :target_role, "Público-alvo:" %> + <%= form.select :target_role, [ [ "Discentes", "discente" ], [ "Docentes", "docente" ] ], include_blank: "Selecione" %> +
+ +

Questões

+ +
+ <% questoes_attrs.each_with_index do |questao, index| %> +
+ <% if questao[:id] %> + <%= hidden_field_tag "template[questaos][#{index}][id]", questao[:id] %> + <% end %> +
+ <%= label_tag "template_questaos_#{index}_enunciado", "Enunciado da questão:" %> + <%= text_area_tag "template[questaos][#{index}][enunciado]", questao[:enunciado], id: "template_questaos_#{index}_enunciado" %> +
+
+ <%= label_tag "template_questaos_#{index}_tipo", "Tipo:" %> + <%= select_tag "template[questaos][#{index}][tipo]", + options_for_select([ [ "Nota (1-5)", "rating" ], [ "Texto", "text" ], [ "Sim/Não", "boolean" ] ], questao[:tipo]), + id: "template_questaos_#{index}_tipo" %> +
+
+ <% end %> +
+ + <%= submit_tag "+", name: "add_questao", data: { turbo: false }, class: "btn btn-secondary" %> + +
+ <%= form.submit(template.persisted? ? "Confirmar edição" : "Criar", class: "btn btn-primary") %> +
+<% end %> diff --git a/project/app/views/templates/_template.json.jbuilder b/project/app/views/templates/_template.json.jbuilder new file mode 100644 index 0000000000..aad7a56810 --- /dev/null +++ b/project/app/views/templates/_template.json.jbuilder @@ -0,0 +1,2 @@ +json.extract! template, :id, :title, :target_role, :admin_id, :created_at, :updated_at +json.url template_url(template, format: :json) diff --git a/project/app/views/templates/edit.html.erb b/project/app/views/templates/edit.html.erb new file mode 100644 index 0000000000..6bb58675d8 --- /dev/null +++ b/project/app/views/templates/edit.html.erb @@ -0,0 +1,11 @@ +<% content_for :title, "Editar template" %> + +

Editar template

+ +<%= render "form", template: @template, questoes_attrs: @questoes_attrs %> + +
+ +
+ <%= link_to "Voltar para templates", templates_path, class: "btn-link" %> +
diff --git a/project/app/views/templates/index.html.erb b/project/app/views/templates/index.html.erb new file mode 100644 index 0000000000..b4bff60223 --- /dev/null +++ b/project/app/views/templates/index.html.erb @@ -0,0 +1,21 @@ +<% content_for :title, "Meus Templates" %> + +

Meus Templates

+ +
+ <% @templates.each do |template| %> +
+

<%= template.title %>

+

Público-alvo: <%= template.target_role %>

+

Questões: <%= template.questaos.count %>

+ +
+ <%= link_to "Editar", edit_template_path(template), class: "btn btn-secondary" %> + <%= link_to "Deletar", template_path(template), class: "btn btn-secondary" %> +
+
+ <% end %> +
+ +
+<%= link_to "Novo template", new_template_path, class: "btn btn-primary" %> diff --git a/project/app/views/templates/index.json.jbuilder b/project/app/views/templates/index.json.jbuilder new file mode 100644 index 0000000000..5163fa0e1e --- /dev/null +++ b/project/app/views/templates/index.json.jbuilder @@ -0,0 +1 @@ +json.array! @templates, partial: "templates/template", as: :template diff --git a/project/app/views/templates/new.html.erb b/project/app/views/templates/new.html.erb new file mode 100644 index 0000000000..b8778971b7 --- /dev/null +++ b/project/app/views/templates/new.html.erb @@ -0,0 +1,11 @@ +<% content_for :title, "Novo template" %> + +

Novo template

+ +<%= render "form", template: @template, questoes_attrs: @questoes_attrs %> + +
+ +
+ <%= link_to "Voltar para templates", templates_path, class: "btn-link" %> +
diff --git a/project/app/views/templates/show.html.erb b/project/app/views/templates/show.html.erb new file mode 100644 index 0000000000..69d4a5cc85 --- /dev/null +++ b/project/app/views/templates/show.html.erb @@ -0,0 +1,26 @@ +<% content_for :title, @template.title %> + +

<%= @template.title %>

+ +

Público-alvo: <%= @template.target_role %>

+ +

Questões

+ + + +
+ <%= link_to "Editar", edit_template_path(@template), class: "btn btn-secondary" %> +
+ +
+

Tem certeza que deseja deletar este template?

+ <%= button_to "Confirmar", template_path(@template), method: :delete, class: "btn btn-primary" %> +
+ +
+ <%= link_to "Voltar para templates", templates_path, class: "btn-link" %> +
diff --git a/project/app/views/templates/show.json.jbuilder b/project/app/views/templates/show.json.jbuilder new file mode 100644 index 0000000000..33110ec93b --- /dev/null +++ b/project/app/views/templates/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! "templates/template", template: @template diff --git a/project/app/views/user_mailer/password_reset.html.erb b/project/app/views/user_mailer/password_reset.html.erb new file mode 100644 index 0000000000..e72841469d --- /dev/null +++ b/project/app/views/user_mailer/password_reset.html.erb @@ -0,0 +1,5 @@ +

Ola, <%= @user.name %>.

+ +

Use o link abaixo para redefinir sua senha no CAMAAR.

+ +

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

diff --git a/project/app/views/user_mailer/password_reset.text.erb b/project/app/views/user_mailer/password_reset.text.erb new file mode 100644 index 0000000000..c5d1f99044 --- /dev/null +++ b/project/app/views/user_mailer/password_reset.text.erb @@ -0,0 +1,5 @@ +Ola, <%= @user.name %>. + +Use o link abaixo para redefinir sua senha no CAMAAR. + +<%= @url %> diff --git a/project/app/views/user_mailer/password_setup.html.erb b/project/app/views/user_mailer/password_setup.html.erb new file mode 100644 index 0000000000..cc45bf0a47 --- /dev/null +++ b/project/app/views/user_mailer/password_setup.html.erb @@ -0,0 +1,5 @@ +

Ola, <%= @user.name %>.

+ +

Use o link abaixo para definir sua primeira senha no CAMAAR.

+ +

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

diff --git a/project/app/views/user_mailer/password_setup.text.erb b/project/app/views/user_mailer/password_setup.text.erb new file mode 100644 index 0000000000..5db9bce1f9 --- /dev/null +++ b/project/app/views/user_mailer/password_setup.text.erb @@ -0,0 +1,5 @@ +Ola, <%= @user.name %>. + +Use o link abaixo para definir sua primeira senha no CAMAAR. + +<%= @url %> diff --git a/project/bin/brakeman b/project/bin/brakeman new file mode 100755 index 0000000000..ace1c9ba08 --- /dev/null +++ b/project/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/project/bin/bundler-audit b/project/bin/bundler-audit new file mode 100755 index 0000000000..e2ef22690c --- /dev/null +++ b/project/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/project/bin/ci b/project/bin/ci new file mode 100755 index 0000000000..4137ad5bb0 --- /dev/null +++ b/project/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/project/bin/cucumber b/project/bin/cucumber new file mode 100755 index 0000000000..eb5e962e86 --- /dev/null +++ b/project/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/project/bin/dev b/project/bin/dev new file mode 100755 index 0000000000..5f91c20545 --- /dev/null +++ b/project/bin/dev @@ -0,0 +1,2 @@ +#!/usr/bin/env ruby +exec "./bin/rails", "server", *ARGV diff --git a/project/bin/docker-entrypoint b/project/bin/docker-entrypoint new file mode 100755 index 0000000000..ed31659f40 --- /dev/null +++ b/project/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/project/bin/importmap b/project/bin/importmap new file mode 100755 index 0000000000..36502ab16c --- /dev/null +++ b/project/bin/importmap @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby + +require_relative "../config/application" +require "importmap/commands" diff --git a/project/bin/jobs b/project/bin/jobs new file mode 100755 index 0000000000..dcf59f309a --- /dev/null +++ b/project/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/project/bin/kamal b/project/bin/kamal new file mode 100755 index 0000000000..d9ba276702 --- /dev/null +++ b/project/bin/kamal @@ -0,0 +1,16 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'kamal' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("kamal", "kamal") diff --git a/project/bin/rails b/project/bin/rails new file mode 100755 index 0000000000..a40bd5e4f6 --- /dev/null +++ b/project/bin/rails @@ -0,0 +1,12 @@ +#!/usr/bin/env ruby + +if ENV["RAILS_ENV"] == "test" + ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + require "bundler/setup" + require "simplecov" + SimpleCov.start +end + +APP_PATH = File.expand_path("../config/application", __dir__) +require_relative "../config/boot" +require "rails/commands" diff --git a/project/bin/rake b/project/bin/rake new file mode 100755 index 0000000000..4fbf10b960 --- /dev/null +++ b/project/bin/rake @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "rake" +Rake.application.run diff --git a/project/bin/rubocop b/project/bin/rubocop new file mode 100755 index 0000000000..5a20504716 --- /dev/null +++ b/project/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/project/bin/setup b/project/bin/setup new file mode 100755 index 0000000000..81be011e87 --- /dev/null +++ b/project/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/project/bin/thrust b/project/bin/thrust new file mode 100755 index 0000000000..36bde2d832 --- /dev/null +++ b/project/bin/thrust @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("thruster", "thrust") diff --git a/project/config.ru b/project/config.ru new file mode 100644 index 0000000000..4a3c09a688 --- /dev/null +++ b/project/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/project/config/application.rb b/project/config/application.rb new file mode 100644 index 0000000000..a69c974960 --- /dev/null +++ b/project/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 Project + 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/project/config/boot.rb b/project/config/boot.rb new file mode 100644 index 0000000000..988a5ddc46 --- /dev/null +++ b/project/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/project/config/bundler-audit.yml b/project/config/bundler-audit.yml new file mode 100644 index 0000000000..e74b3af949 --- /dev/null +++ b/project/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/project/config/cable.yml b/project/config/cable.yml new file mode 100644 index 0000000000..b9adc5aa3a --- /dev/null +++ b/project/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/project/config/cache.yml b/project/config/cache.yml new file mode 100644 index 0000000000..19d490843b --- /dev/null +++ b/project/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/project/config/ci.rb b/project/config/ci.rb new file mode 100644 index 0000000000..1712cc1127 --- /dev/null +++ b/project/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/project/config/credentials.yml.enc b/project/config/credentials.yml.enc new file mode 100644 index 0000000000..6fa056b6fe --- /dev/null +++ b/project/config/credentials.yml.enc @@ -0,0 +1 @@ +zAhTSDhDMGxkP/oq8ep7nzeYW8usCy5ZAxebSamtvX+ePfQSIR7IsVz+7jOMqX2UgLTnGu3zEiPjQ+DESz7Q5J8l71GqGwKXG3WxBmaUAmrY6D3QnMONjJZFKbe5i51SRFyKchJ0h3h1FboH4hYg1D1fsJluy1uFLRlA91YZ70uKFXm9DMIfFysymte1KNx762s/S//88EuH0ov5vkeiuAn/FPS4VmcDuKaKQ63tysC36Vqlyv/N4ZwQ2DD6xbzQZgEFfO5VBDmXeZYc0HqwcfOksHC5FbwsPDzRokwwYOKlbrRV85LOzjNQcJQzaXhcc1r20FzksWEbH7q2Q3vQT90yD/Hx83dmoj5jbgbGJpzxjiwykpvU8upKMjVhrxMV4wrOiAQMtP/2uyLORHXZ7ptQ2XwPsUkIPuzMpCaLxGMpWYujblmkUq/FVNGfZHjIZ2icWU4hKve5LqC2TkJV5NY0qPrgQh1cIPSskSOunisOOu9N+NOkrVm6--EAc8LSksNFtj+8n3--qryypbJabJv0tzjhOgJVkQ== \ No newline at end of file diff --git a/project/config/cucumber.yml b/project/config/cucumber.yml new file mode 100644 index 0000000000..47a4663ae2 --- /dev/null +++ b/project/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/project/config/database.yml b/project/config/database.yml new file mode 100644 index 0000000000..302d638c96 --- /dev/null +++ b/project/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/project/config/deploy.yml b/project/config/deploy.yml new file mode 100644 index 0000000000..167f14168a --- /dev/null +++ b/project/config/deploy.yml @@ -0,0 +1,119 @@ +# Name of your application. Used to uniquely configure containers. +service: project + +# Name of the container image (use your-user/app-name on external registries). +image: project + +# 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 project-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: + - "project_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/project/config/environment.rb b/project/config/environment.rb new file mode 100644 index 0000000000..cac5315775 --- /dev/null +++ b/project/config/environment.rb @@ -0,0 +1,5 @@ +# Load the Rails application. +require_relative "application" + +# Initialize the Rails application. +Rails.application.initialize! diff --git a/project/config/environments/development.rb b/project/config/environments/development.rb new file mode 100644 index 0000000000..4a6ea19c9c --- /dev/null +++ b/project/config/environments/development.rb @@ -0,0 +1,82 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Configure 'rails notes' to inspect Cucumber files + config.annotations.register_directories("features") + config.annotations.register_extensions("feature") { |tag| /#\s*(#{tag}):?\s*(.*)$/ } + + # Settings specified here will take precedence over those in config/application.rb. + + # Make code changes take effect immediately without server restart. + config.enable_reloading = true + + # Do not eager load code on boot. + config.eager_load = false + + # Show full error reports. + config.consider_all_requests_local = true + + # Enable server timing. + config.server_timing = true + + # Enable/disable Action Controller caching. By default Action Controller caching is disabled. + # Run rails dev:cache to toggle Action Controller caching. + if Rails.root.join("tmp/caching-dev.txt").exist? + config.action_controller.perform_caching = true + config.action_controller.enable_fragment_cache_logging = true + config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" } + else + config.action_controller.perform_caching = false + end + + # Change to :null_store to avoid any caching. + config.cache_store = :memory_store + + # Store uploaded files on the local file system (see config/storage.yml for options). + config.active_storage.service = :local + + # Don't care if the mailer can't send. + config.action_mailer.raise_delivery_errors = false + + # Make template changes take effect immediately. + config.action_mailer.perform_caching = false + + # Set localhost to be used by links generated in mailer templates. + config.action_mailer.default_url_options = { host: "localhost", port: 3000 } + + # Print deprecation notices to the Rails logger. + config.active_support.deprecation = :log + + # Raise an error on page load if there are pending migrations. + config.active_record.migration_error = :page_load + + # Highlight code that triggered database queries in logs. + config.active_record.verbose_query_logs = true + + # Append comments with runtime information tags to SQL queries in logs. + config.active_record.query_log_tags_enabled = true + + # Highlight code that enqueued background job in logs. + config.active_job.verbose_enqueue_logs = true + + # Highlight code that triggered redirect in logs. + config.action_dispatch.verbose_redirect_logs = true + + # Suppress logger output for asset requests. + config.assets.quiet = true + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + config.action_view.annotate_rendered_view_with_filenames = true + + # Uncomment if you wish to allow Action Cable access from any origin. + # config.action_cable.disable_request_forgery_protection = true + + # Raise error when a before_action's only/except options reference missing actions. + config.action_controller.raise_on_missing_callback_actions = true + + # Apply autocorrection by RuboCop to files generated by `bin/rails generate`. + # config.generators.apply_rubocop_autocorrect_after_generate! +end diff --git a/project/config/environments/production.rb b/project/config/environments/production.rb new file mode 100644 index 0000000000..f5763e04e5 --- /dev/null +++ b/project/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/project/config/environments/test.rb b/project/config/environments/test.rb new file mode 100644 index 0000000000..29d195b837 --- /dev/null +++ b/project/config/environments/test.rb @@ -0,0 +1,57 @@ +# The test environment is used exclusively to run your application's +# test suite. You never need to work with it otherwise. Remember that +# your test database is "scratch space" for the test suite and is wiped +# and recreated between test runs. Don't rely on the data there! + +Rails.application.configure do + # Configure 'rails notes' to inspect Cucumber files + config.annotations.register_directories("features") + config.annotations.register_extensions("feature") { |tag| /#\s*(#{tag}):?\s*(.*)$/ } + + # Settings specified here will take precedence over those in config/application.rb. + + # While tests run files are not watched, reloading is not necessary. + config.enable_reloading = false + + # Eager loading loads your entire application. When running a single test locally, + # this is usually not necessary, and can slow down your test suite. However, it's + # recommended that you enable it in continuous integration systems to ensure eager + # loading is working properly before deploying your code. + config.eager_load = ENV["CI"].present? + + # Configure public file server for tests with cache-control for performance. + config.public_file_server.headers = { "cache-control" => "public, max-age=3600" } + + # Show full error reports. + config.consider_all_requests_local = true + config.cache_store = :null_store + + # Render exception templates for rescuable exceptions and raise for other exceptions. + config.action_dispatch.show_exceptions = :rescuable + + # Disable request forgery protection in test environment. + config.action_controller.allow_forgery_protection = false + + # Store uploaded files on the local file system in a temporary directory. + config.active_storage.service = :test + + # Tell Action Mailer not to deliver emails to the real world. + # The :test delivery method accumulates sent emails in the + # ActionMailer::Base.deliveries array. + config.action_mailer.delivery_method = :test + + # Set host to be used by links generated in mailer templates. + config.action_mailer.default_url_options = { host: "example.com" } + + # Print deprecation notices to the stderr. + config.active_support.deprecation = :stderr + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + # config.action_view.annotate_rendered_view_with_filenames = true + + # Raise error when a before_action's only/except options reference missing actions. + config.action_controller.raise_on_missing_callback_actions = true +end diff --git a/project/config/importmap.rb b/project/config/importmap.rb new file mode 100644 index 0000000000..909dfc542d --- /dev/null +++ b/project/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/project/config/initializers/assets.rb b/project/config/initializers/assets.rb new file mode 100644 index 0000000000..487324424f --- /dev/null +++ b/project/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/project/config/initializers/content_security_policy.rb b/project/config/initializers/content_security_policy.rb new file mode 100644 index 0000000000..d51d713979 --- /dev/null +++ b/project/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/project/config/initializers/filter_parameter_logging.rb b/project/config/initializers/filter_parameter_logging.rb new file mode 100644 index 0000000000..c0b717f7ec --- /dev/null +++ b/project/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/project/config/initializers/inflections.rb b/project/config/initializers/inflections.rb new file mode 100644 index 0000000000..3860f659ea --- /dev/null +++ b/project/config/initializers/inflections.rb @@ -0,0 +1,16 @@ +# Be sure to restart your server when you modify this file. + +# Add new inflection rules using the following format. Inflections +# are locale specific, and you may define rules for as many different +# locales as you wish. All of these examples are active by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.plural /^(ox)$/i, "\\1en" +# inflect.singular /^(ox)en/i, "\\1" +# inflect.irregular "person", "people" +# inflect.uncountable %w( fish sheep ) +# end + +# These inflection rules are supported but not enabled by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.acronym "RESTful" +# end diff --git a/project/config/locales/en.yml b/project/config/locales/en.yml new file mode 100644 index 0000000000..6c349ae5e3 --- /dev/null +++ b/project/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/project/config/puma.rb b/project/config/puma.rb new file mode 100644 index 0000000000..38c4b86596 --- /dev/null +++ b/project/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/project/config/queue.yml b/project/config/queue.yml new file mode 100644 index 0000000000..9eace59c41 --- /dev/null +++ b/project/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/project/config/recurring.yml b/project/config/recurring.yml new file mode 100644 index 0000000000..b4207f9b07 --- /dev/null +++ b/project/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/project/config/routes.rb b/project/config/routes.rb new file mode 100644 index 0000000000..0d74c598be --- /dev/null +++ b/project/config/routes.rb @@ -0,0 +1,34 @@ +Rails.application.routes.draw do + resources :resposta, only: %i[ create ] + + resources :formularios, only: %i[ index show new create destroy ] do + member do + get :exportar_csv + end + end + + resources :templates + + resources :course_classes, only: %i[ index show ], path: "turmas" + + get "up" => "rails/health#show", as: :rails_health_check + + root "dashboard#show" + + get "login", to: "sessions#new", as: :login + post "login", to: "sessions#create" + delete "logout", to: "sessions#destroy" + + get "senha/definir/:token", to: "password_setups#edit", as: :edit_password_setup + patch "senha/definir/:token", to: "password_setups#update", as: :password_setup + + get "senha/esqueci", to: "password_resets#new", as: :new_password_reset + post "senha/esqueci", to: "password_resets#create", as: :password_resets + get "senha/redefinir/:token", to: "password_resets#edit", as: :edit_password_reset + patch "senha/redefinir/:token", to: "password_resets#update", as: :password_reset + + namespace :imports do + get "sigaa", to: "sigaa#new" + post "sigaa", to: "sigaa#create" + end +end diff --git a/project/config/storage.yml b/project/config/storage.yml new file mode 100644 index 0000000000..927dc537c8 --- /dev/null +++ b/project/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/project/db/cable_schema.rb b/project/db/cable_schema.rb new file mode 100644 index 0000000000..23666604a5 --- /dev/null +++ b/project/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/project/db/cache_schema.rb b/project/db/cache_schema.rb new file mode 100644 index 0000000000..81a410d188 --- /dev/null +++ b/project/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/project/db/migrate/20260611134150_create_departamentos.rb b/project/db/migrate/20260611134150_create_departamentos.rb new file mode 100644 index 0000000000..486d3458cf --- /dev/null +++ b/project/db/migrate/20260611134150_create_departamentos.rb @@ -0,0 +1,10 @@ +class CreateDepartamentos < ActiveRecord::Migration[8.1] + def change + create_table :departamentos do |t| + t.string :name + t.string :code + + t.timestamps + end + end +end diff --git a/project/db/migrate/20260611134210_create_alunos.rb b/project/db/migrate/20260611134210_create_alunos.rb new file mode 100644 index 0000000000..3975496e21 --- /dev/null +++ b/project/db/migrate/20260611134210_create_alunos.rb @@ -0,0 +1,13 @@ +class CreateAlunos < ActiveRecord::Migration[8.1] + def change + create_table :alunos do |t| + t.string :matricula + t.string :name + t.string :email + t.string :password_digest + t.string :course + + t.timestamps + end + end +end diff --git a/project/db/migrate/20260611134235_create_admins.rb b/project/db/migrate/20260611134235_create_admins.rb new file mode 100644 index 0000000000..08f8aa503f --- /dev/null +++ b/project/db/migrate/20260611134235_create_admins.rb @@ -0,0 +1,13 @@ +class CreateAdmins < ActiveRecord::Migration[8.1] + def change + create_table :admins do |t| + t.string :username + t.string :name + t.string :email + t.string :password_digest + t.references :departamento, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/project/db/migrate/20260611134249_create_professors.rb b/project/db/migrate/20260611134249_create_professors.rb new file mode 100644 index 0000000000..1fe1fec171 --- /dev/null +++ b/project/db/migrate/20260611134249_create_professors.rb @@ -0,0 +1,14 @@ +class CreateProfessors < ActiveRecord::Migration[8.1] + def change + create_table :professors do |t| + t.string :matricula + t.string :name + t.string :email + t.string :password_digest + t.string :formation + t.references :departamento, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/project/db/migrate/20260611134304_create_turmas.rb b/project/db/migrate/20260611134304_create_turmas.rb new file mode 100644 index 0000000000..9c584091c2 --- /dev/null +++ b/project/db/migrate/20260611134304_create_turmas.rb @@ -0,0 +1,15 @@ +class CreateTurmas < ActiveRecord::Migration[8.1] + def change + create_table :turmas do |t| + t.string :subject_code + t.string :subject_name + t.string :class_code + t.string :semester + t.string :time + t.references :departamento, null: false, foreign_key: true + t.references :professor, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/project/db/migrate/20260611134355_create_turma_alunos.rb b/project/db/migrate/20260611134355_create_turma_alunos.rb new file mode 100644 index 0000000000..f5a2c39e31 --- /dev/null +++ b/project/db/migrate/20260611134355_create_turma_alunos.rb @@ -0,0 +1,10 @@ +class CreateTurmaAlunos < ActiveRecord::Migration[8.1] + def change + create_table :turma_alunos do |t| + t.references :aluno, null: false, foreign_key: true + t.references :turma, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/project/db/migrate/20260611134403_create_templates.rb b/project/db/migrate/20260611134403_create_templates.rb new file mode 100644 index 0000000000..e56ae85339 --- /dev/null +++ b/project/db/migrate/20260611134403_create_templates.rb @@ -0,0 +1,11 @@ +class CreateTemplates < ActiveRecord::Migration[8.1] + def change + create_table :templates do |t| + t.string :title + t.string :target_role + t.references :admin, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/project/db/migrate/20260611134426_create_formularios.rb b/project/db/migrate/20260611134426_create_formularios.rb new file mode 100644 index 0000000000..8a2684cf87 --- /dev/null +++ b/project/db/migrate/20260611134426_create_formularios.rb @@ -0,0 +1,14 @@ +class CreateFormularios < ActiveRecord::Migration[8.1] + def change + create_table :formularios do |t| + t.string :title + t.string :target_role + t.string :status + t.references :template, null: false, foreign_key: true + t.references :turma, null: false, foreign_key: true + t.references :admin, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/project/db/migrate/20260611134433_create_questaos.rb b/project/db/migrate/20260611134433_create_questaos.rb new file mode 100644 index 0000000000..5bd2781a26 --- /dev/null +++ b/project/db/migrate/20260611134433_create_questaos.rb @@ -0,0 +1,12 @@ +class CreateQuestaos < ActiveRecord::Migration[8.1] + def change + create_table :questaos do |t| + t.text :enunciado + t.string :tipo + t.references :template, null: true, foreign_key: true + t.references :formulario, null: true, foreign_key: true + + t.timestamps + end + end +end diff --git a/project/db/migrate/20260611134446_create_submissaos.rb b/project/db/migrate/20260611134446_create_submissaos.rb new file mode 100644 index 0000000000..d59930feb7 --- /dev/null +++ b/project/db/migrate/20260611134446_create_submissaos.rb @@ -0,0 +1,10 @@ +class CreateSubmissaos < ActiveRecord::Migration[8.1] + def change + create_table :submissaos do |t| + t.references :formulario, null: false, foreign_key: true + t.references :participant, polymorphic: true, null: false + + t.timestamps + end + end +end diff --git a/project/db/migrate/20260611134500_create_resposta.rb b/project/db/migrate/20260611134500_create_resposta.rb new file mode 100644 index 0000000000..c40ef181da --- /dev/null +++ b/project/db/migrate/20260611134500_create_resposta.rb @@ -0,0 +1,12 @@ +class CreateResposta < ActiveRecord::Migration[8.1] + def change + create_table :resposta do |t| + t.text :valor_texto + t.integer :valor_numerico + t.references :submissao, null: false, foreign_key: true + t.references :questao, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/project/db/migrate/20260614234457_allow_null_template_id_on_formularios.rb b/project/db/migrate/20260614234457_allow_null_template_id_on_formularios.rb new file mode 100644 index 0000000000..f3ede1c71e --- /dev/null +++ b/project/db/migrate/20260614234457_allow_null_template_id_on_formularios.rb @@ -0,0 +1,5 @@ +class AllowNullTemplateIdOnFormularios < ActiveRecord::Migration[8.1] + def change + change_column_null :formularios, :template_id, true + end +end diff --git a/project/db/migrate/20260615000100_create_departments.rb b/project/db/migrate/20260615000100_create_departments.rb new file mode 100644 index 0000000000..98ffe30b3a --- /dev/null +++ b/project/db/migrate/20260615000100_create_departments.rb @@ -0,0 +1,12 @@ +class CreateDepartments < ActiveRecord::Migration[8.1] + def change + create_table :departments do |t| + t.string :name, null: false + t.string :code, null: false + + t.timestamps + end + + add_index :departments, :code, unique: true + end +end diff --git a/project/db/migrate/20260615000200_create_course_classes.rb b/project/db/migrate/20260615000200_create_course_classes.rb new file mode 100644 index 0000000000..a5eebe34d7 --- /dev/null +++ b/project/db/migrate/20260615000200_create_course_classes.rb @@ -0,0 +1,15 @@ +class CreateCourseClasses < ActiveRecord::Migration[8.1] + def change + create_table :course_classes do |t| + t.references :department, null: false, foreign_key: true + t.string :code, null: false + t.string :name + t.string :class_code, null: false + t.string :semester, null: false + + t.timestamps + end + + add_index :course_classes, [ :code, :class_code, :semester ], unique: true + end +end diff --git a/project/db/migrate/20260615000300_create_users.rb b/project/db/migrate/20260615000300_create_users.rb new file mode 100644 index 0000000000..fc7714916b --- /dev/null +++ b/project/db/migrate/20260615000300_create_users.rb @@ -0,0 +1,23 @@ +class CreateUsers < ActiveRecord::Migration[8.1] + def change # rubocop:disable Metrics/AbcSize + create_table :users do |t| + t.references :department, foreign_key: true + t.string :name, null: false + t.string :email, null: false + t.string :registration, null: false + t.integer :role, null: false, default: 1 + t.string :password_digest + t.string :password_setup_token_digest + t.datetime :password_setup_sent_at + t.string :password_reset_token_digest + t.datetime :password_reset_sent_at + + t.timestamps + end + + add_index :users, :email, unique: true + add_index :users, :registration, unique: true + add_index :users, :password_setup_token_digest, unique: true + add_index :users, :password_reset_token_digest, unique: true + end +end diff --git a/project/db/migrate/20260615000400_create_class_memberships.rb b/project/db/migrate/20260615000400_create_class_memberships.rb new file mode 100644 index 0000000000..aadc8a7cd2 --- /dev/null +++ b/project/db/migrate/20260615000400_create_class_memberships.rb @@ -0,0 +1,13 @@ +class CreateClassMemberships < ActiveRecord::Migration[8.1] + def change + create_table :class_memberships do |t| + t.references :user, null: false, foreign_key: true + t.references :course_class, null: false, foreign_key: true + t.integer :role, null: false, default: 0 + + t.timestamps + end + + add_index :class_memberships, [ :user_id, :course_class_id, :role ], unique: true + end +end diff --git a/project/db/migrate/20260615000500_create_import_inconsistencies.rb b/project/db/migrate/20260615000500_create_import_inconsistencies.rb new file mode 100644 index 0000000000..821c18b1ad --- /dev/null +++ b/project/db/migrate/20260615000500_create_import_inconsistencies.rb @@ -0,0 +1,11 @@ +class CreateImportInconsistencies < ActiveRecord::Migration[8.1] + def change + create_table :import_inconsistencies do |t| + t.string :source, null: false + t.string :message, null: false + t.json :payload + + t.timestamps + end + end +end diff --git a/project/db/migrate/20260616000001_change_admin_id_fk_to_users.rb b/project/db/migrate/20260616000001_change_admin_id_fk_to_users.rb new file mode 100644 index 0000000000..464c411bda --- /dev/null +++ b/project/db/migrate/20260616000001_change_admin_id_fk_to_users.rb @@ -0,0 +1,9 @@ +class ChangeAdminIdFkToUsers < ActiveRecord::Migration[8.1] + def change + remove_foreign_key :templates, :admins + remove_foreign_key :formularios, :admins + + add_foreign_key :templates, :users, column: :admin_id + add_foreign_key :formularios, :users, column: :admin_id + end +end diff --git a/project/db/migrate/20260616000002_consolidate_to_user_model.rb b/project/db/migrate/20260616000002_consolidate_to_user_model.rb new file mode 100644 index 0000000000..5fb6c44b5d --- /dev/null +++ b/project/db/migrate/20260616000002_consolidate_to_user_model.rb @@ -0,0 +1,28 @@ +class ConsolidateToUserModel < ActiveRecord::Migration[8.1] + def up + # --- course_classes: horário para exibição --- + add_column :course_classes, :time, :string + + # --- formularios: turma_id → course_class_id --- + remove_foreign_key :formularios, column: :turma_id + rename_column :formularios, :turma_id, :course_class_id + add_foreign_key :formularios, :course_classes, column: :course_class_id + + # --- submissaos: substituir polimórfico por user_id --- + remove_column :submissaos, :participant_id + remove_column :submissaos, :participant_type + add_reference :submissaos, :user, null: false, foreign_key: true + + # --- remover tabelas legadas (ordem respeita FKs) --- + drop_table :turma_alunos + drop_table :turmas + drop_table :professors + drop_table :admins + drop_table :alunos + drop_table :departamentos + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/project/db/queue_schema.rb b/project/db/queue_schema.rb new file mode 100644 index 0000000000..85194b6a88 --- /dev/null +++ b/project/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/project/db/schema.rb b/project/db/schema.rb new file mode 100644 index 0000000000..d4f420563d --- /dev/null +++ b/project/db/schema.rb @@ -0,0 +1,142 @@ +# 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_16_000002) do + create_table "class_memberships", force: :cascade do |t| + t.integer "course_class_id", null: false + t.datetime "created_at", null: false + t.integer "role", default: 0, null: false + t.datetime "updated_at", null: false + t.integer "user_id", null: false + t.index ["course_class_id"], name: "index_class_memberships_on_course_class_id" + t.index ["user_id", "course_class_id", "role"], name: "idx_on_user_id_course_class_id_role_c0460d53c1", unique: true + t.index ["user_id"], name: "index_class_memberships_on_user_id" + end + + create_table "course_classes", force: :cascade do |t| + t.string "class_code", null: false + t.string "code", null: false + t.datetime "created_at", null: false + t.integer "department_id", null: false + t.string "name" + t.string "semester", null: false + t.string "time" + t.datetime "updated_at", null: false + t.index ["code", "class_code", "semester"], name: "index_course_classes_on_code_and_class_code_and_semester", unique: true + t.index ["department_id"], name: "index_course_classes_on_department_id" + end + + create_table "departments", force: :cascade do |t| + t.string "code", null: false + t.datetime "created_at", null: false + t.string "name", null: false + t.datetime "updated_at", null: false + t.index ["code"], name: "index_departments_on_code", unique: true + end + + create_table "formularios", force: :cascade do |t| + t.integer "admin_id", null: false + t.integer "course_class_id", null: false + t.datetime "created_at", null: false + t.string "status" + t.string "target_role" + t.integer "template_id" + t.string "title" + t.datetime "updated_at", null: false + t.index ["admin_id"], name: "index_formularios_on_admin_id" + t.index ["course_class_id"], name: "index_formularios_on_course_class_id" + t.index ["template_id"], name: "index_formularios_on_template_id" + end + + create_table "import_inconsistencies", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "message", null: false + t.json "payload" + t.string "source", null: false + t.datetime "updated_at", null: false + end + + create_table "questaos", force: :cascade do |t| + t.datetime "created_at", null: false + t.text "enunciado" + t.integer "formulario_id" + t.integer "template_id" + t.string "tipo" + t.datetime "updated_at", null: false + t.index ["formulario_id"], name: "index_questaos_on_formulario_id" + t.index ["template_id"], name: "index_questaos_on_template_id" + end + + create_table "resposta", force: :cascade do |t| + t.datetime "created_at", null: false + t.integer "questao_id", null: false + t.integer "submissao_id", null: false + t.datetime "updated_at", null: false + t.integer "valor_numerico" + t.text "valor_texto" + t.index ["questao_id"], name: "index_resposta_on_questao_id" + t.index ["submissao_id"], name: "index_resposta_on_submissao_id" + end + + create_table "submissaos", force: :cascade do |t| + t.datetime "created_at", null: false + t.integer "formulario_id", null: false + t.datetime "updated_at", null: false + t.integer "user_id", null: false + t.index ["formulario_id"], name: "index_submissaos_on_formulario_id" + t.index ["user_id"], name: "index_submissaos_on_user_id" + end + + create_table "templates", force: :cascade do |t| + t.integer "admin_id", null: false + t.datetime "created_at", null: false + t.string "target_role" + t.string "title" + t.datetime "updated_at", null: false + t.index ["admin_id"], name: "index_templates_on_admin_id" + end + + create_table "users", force: :cascade do |t| + t.datetime "created_at", null: false + t.integer "department_id" + t.string "email", null: false + t.string "name", null: false + t.string "password_digest" + t.datetime "password_reset_sent_at" + t.string "password_reset_token_digest" + t.datetime "password_setup_sent_at" + t.string "password_setup_token_digest" + t.string "registration", null: false + t.integer "role", default: 1, null: false + t.datetime "updated_at", null: false + t.index ["department_id"], name: "index_users_on_department_id" + t.index ["email"], name: "index_users_on_email", unique: true + t.index ["password_reset_token_digest"], name: "index_users_on_password_reset_token_digest", unique: true + t.index ["password_setup_token_digest"], name: "index_users_on_password_setup_token_digest", unique: true + t.index ["registration"], name: "index_users_on_registration", unique: true + end + + add_foreign_key "class_memberships", "course_classes" + add_foreign_key "class_memberships", "users" + add_foreign_key "course_classes", "departments" + add_foreign_key "formularios", "course_classes" + add_foreign_key "formularios", "templates" + add_foreign_key "formularios", "users", column: "admin_id" + add_foreign_key "questaos", "formularios" + add_foreign_key "questaos", "templates" + add_foreign_key "resposta", "questaos" + add_foreign_key "resposta", "submissaos" + add_foreign_key "submissaos", "formularios" + add_foreign_key "submissaos", "users" + add_foreign_key "templates", "users", column: "admin_id" + add_foreign_key "users", "departments" +end diff --git a/project/db/seeds.rb b/project/db/seeds.rb new file mode 100644 index 0000000000..474a2afd27 --- /dev/null +++ b/project/db/seeds.rb @@ -0,0 +1,17 @@ +# Seed de desenvolvimento — pode ser executado múltiplas vezes (idempotente). +# +# O sistema é populado com turmas, docentes e discentes através do botão de +# "Importar dados do SIGAA" (tela de administração), não pelo seed. Aqui só +# criamos a conta de administrador necessária para fazer login e disparar essa +# importação. + +admin = User.find_or_create_by!(email: "admin.cic@unb.br") do |u| + u.name = "Admin CAMAAR" + u.registration = "000000001" + u.role = :admin + u.password = "Senha@123" +end + +puts "Seed concluída:" +puts " Admin: #{admin.email} / Senha@123" +puts " Use a tela 'Importar dados do SIGAA' para popular turmas, docentes e discentes." diff --git a/project/doc/ApplicationController.html b/project/doc/ApplicationController.html new file mode 100644 index 0000000000..80f94fdb66 --- /dev/null +++ b/project/doc/ApplicationController.html @@ -0,0 +1,815 @@ + + + + + + + +class ApplicationController - CAMAAR - Documentação do Código + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + C + + + + + + + + + + I + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + CAMAAR - Documentação do Código + + + + + + + + + + +
+ + + + + + + + + +
+ + + + +

+ class ApplicationController +

+ +
+ +

Controller base da aplicação. Centraliza a identificação do usuário da sessão e as verificações de autenticação e autorização usadas pelos demais controllers.

+ +
+ + + + + +
+ + + + + + + + diff --git a/project/doc/ApplicationHelper.html b/project/doc/ApplicationHelper.html new file mode 100644 index 0000000000..bbc384023a --- /dev/null +++ b/project/doc/ApplicationHelper.html @@ -0,0 +1,473 @@ + + + + + + + +module ApplicationHelper - CAMAAR - Documentação do Código + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + C + + + + + + + + + + I + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + CAMAAR - Documentação do Código + + + + + + + + + + +
+ + + + + + + + + +
+ + + + +

+ module ApplicationHelper +

+ +
+ +

Ponto de extensão para helpers compartilhados entre todas as views.

+ +
+ + + + + +
+ + + + + + + + diff --git a/project/doc/ApplicationJob.html b/project/doc/ApplicationJob.html new file mode 100644 index 0000000000..145bf317b8 --- /dev/null +++ b/project/doc/ApplicationJob.html @@ -0,0 +1,488 @@ + + + + + + + +class ApplicationJob - CAMAAR - Documentação do Código + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + C + + + + + + + + + + I + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + CAMAAR - Documentação do Código + + + + + + + + + + +
+ + + + + + + + + +
+ + + + +

+ class ApplicationJob +

+ +
+ +

Classe base dos jobs executados de forma assíncrona pela aplicação.

+ +
+ + + + + +
+ + + + + + + + diff --git a/project/doc/ApplicationMailer.html b/project/doc/ApplicationMailer.html new file mode 100644 index 0000000000..c858473741 --- /dev/null +++ b/project/doc/ApplicationMailer.html @@ -0,0 +1,488 @@ + + + + + + + +class ApplicationMailer - CAMAAR - Documentação do Código + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + C + + + + + + + + + + I + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + CAMAAR - Documentação do Código + + + + + + + + + + +
+ + + + + + + + + +
+ + + + +

+ class ApplicationMailer +

+ +
+ +

Classe base dos e-mails enviados pela aplicação.

+ +
+ + + + + +
+ + + + + + + + diff --git a/project/doc/ApplicationRecord.html b/project/doc/ApplicationRecord.html new file mode 100644 index 0000000000..0cb8604693 --- /dev/null +++ b/project/doc/ApplicationRecord.html @@ -0,0 +1,488 @@ + + + + + + + +class ApplicationRecord - CAMAAR - Documentação do Código + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + C + + + + + + + + + + I + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + CAMAAR - Documentação do Código + + + + + + + + + + +
+ + + + + + + + + +
+ + + + +

+ class ApplicationRecord +

+ +
+ +

Classe base abstrata dos modelos persistidos pelo Active Record.

+ +
+ + + + + +
+ + + + + + + + diff --git a/project/doc/ClassMembership.html b/project/doc/ClassMembership.html new file mode 100644 index 0000000000..0ca341ff52 --- /dev/null +++ b/project/doc/ClassMembership.html @@ -0,0 +1,488 @@ + + + + + + + +class ClassMembership - CAMAAR - Documentação do Código + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + C + + + + + + + + + + I + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + CAMAAR - Documentação do Código + + + + + + + + + + +
+ + + + + + + + + +
+ + + + +

+ class ClassMembership +

+ +
+ +

Representa o vínculo de um usuário com uma turma como discente ou docente.

+ +
+ + + + + +
+ + + + + + + + diff --git a/project/doc/CourseClass.html b/project/doc/CourseClass.html new file mode 100644 index 0000000000..5088a266be --- /dev/null +++ b/project/doc/CourseClass.html @@ -0,0 +1,488 @@ + + + + + + + +class CourseClass - CAMAAR - Documentação do Código + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + C + + + + + + + + + + I + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + CAMAAR - Documentação do Código + + + + + + + + + + +
+ + + + + + + + + +
+ + + + +

+ class CourseClass +

+ +
+ +

Representa uma oferta de disciplina identificada por código, turma e semestre.

+ +
+ + + + + +
+ + + + + + + + diff --git a/project/doc/CourseClassesController.html b/project/doc/CourseClassesController.html new file mode 100644 index 0000000000..9ebd4d3003 --- /dev/null +++ b/project/doc/CourseClassesController.html @@ -0,0 +1,678 @@ + + + + + + + +class CourseClassesController - CAMAAR - Documentação do Código + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + C + + + + + + + + + + I + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + CAMAAR - Documentação do Código + + + + + + + + + + +
+ + + + + + + + + +
+ + + + +

+ class CourseClassesController +

+ +
+ +

Apresenta ao administrador as turmas pertencentes ao seu departamento e os formulários de avaliação vinculados a cada turma.

+ +
+ + + + + +
+ + + + + + + + diff --git a/project/doc/DashboardController.html b/project/doc/DashboardController.html new file mode 100644 index 0000000000..943af583c7 --- /dev/null +++ b/project/doc/DashboardController.html @@ -0,0 +1,562 @@ + + + + + + + +class DashboardController - CAMAAR - Documentação do Código + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + C + + + + + + + + + + I + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + CAMAAR - Documentação do Código + + + + + + + + + + +
+ + + + + + + + + +
+ + + + +

+ class DashboardController +

+ +
+ +

Exibe o painel inicial adequado ao papel do usuário autenticado.

+ +
+ + + + + +
+ + + + + + + + diff --git a/project/doc/Department.html b/project/doc/Department.html new file mode 100644 index 0000000000..5f34f4375d --- /dev/null +++ b/project/doc/Department.html @@ -0,0 +1,488 @@ + + + + + + + +class Department - CAMAAR - Documentação do Código + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + C + + + + + + + + + + I + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + CAMAAR - Documentação do Código + + + + + + + + + + +
+ + + + + + + + + +
+ + + + +

+ class Department +

+ +
+ +

Agrupa usuários e turmas pertencentes a uma unidade acadêmica.

+ +
+ + + + + +
+ + + + + + + + diff --git a/project/doc/Formulario.html b/project/doc/Formulario.html new file mode 100644 index 0000000000..996be71f7a --- /dev/null +++ b/project/doc/Formulario.html @@ -0,0 +1,488 @@ + + + + + + + +class Formulario - CAMAAR - Documentação do Código + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + C + + + + + + + + + + I + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + CAMAAR - Documentação do Código + + + + + + + + + + +
+ + + + + + + + + +
+ + + + +

+ class Formulario +

+ +
+ +

Representa uma avaliação criada a partir de um template para uma turma específica.

+ +
+ + + + + +
+ + + + + + + + diff --git a/project/doc/Formularios.html b/project/doc/Formularios.html new file mode 100644 index 0000000000..c2f4a42584 --- /dev/null +++ b/project/doc/Formularios.html @@ -0,0 +1,473 @@ + + + + + + + +module Formularios - CAMAAR - Documentação do Código + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + C + + + + + + + + + + I + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + CAMAAR - Documentação do Código + + + + + + + + + + +
+ + + + + + + + + +
+ + + + +

+ module Formularios +

+ +
+ +

Serviços relacionados à criação e ao gerenciamento de formulários.

+ +
+ + + + + +
+ + + + + + + + diff --git a/project/doc/Formularios/CreateFromTemplate.html b/project/doc/Formularios/CreateFromTemplate.html new file mode 100644 index 0000000000..810cea0e15 --- /dev/null +++ b/project/doc/Formularios/CreateFromTemplate.html @@ -0,0 +1,816 @@ + + + + + + + +class Formularios::CreateFromTemplate - CAMAAR - Documentação do Código + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + C + + + + + + + + + + I + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + CAMAAR - Documentação do Código + + + + + + + + + + +
+ + + + + + + + + +
+ + + + + + +

+ class Formularios::CreateFromTemplate +

+ +
+ +

Cria um formulário e copia as questões do template selecionado para manter uma versão independente da avaliação.

+ +
+ + + + + +
+ + + + + + + + diff --git a/project/doc/FormulariosController.html b/project/doc/FormulariosController.html new file mode 100644 index 0000000000..bbccaf18d1 --- /dev/null +++ b/project/doc/FormulariosController.html @@ -0,0 +1,1331 @@ + + + + + + + +class FormulariosController - CAMAAR - Documentação do Código + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + C + + + + + + + + + + I + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + CAMAAR - Documentação do Código + + + + + + + + + + +
+ + + + + + + + + +
+ + + + +

+ class FormulariosController +

+ +
+ +

Gerencia formulários de avaliação, apresenta pendências aos participantes e disponibiliza resultados e exportação aos administradores.

+ +
+ + + + + +
+ + + + + + + + diff --git a/project/doc/FormulariosHelper.html b/project/doc/FormulariosHelper.html new file mode 100644 index 0000000000..6e8308b5ee --- /dev/null +++ b/project/doc/FormulariosHelper.html @@ -0,0 +1,573 @@ + + + + + + + +module FormulariosHelper - CAMAAR - Documentação do Código + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + C + + + + + + + + + + I + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + CAMAAR - Documentação do Código + + + + + + + + + + +
+ + + + + + + + + +
+ + + + +

+ module FormulariosHelper +

+ +
+ +

Formata valores de respostas para apresentação nas telas e exportações de formulários.

+ +
+ + + + + +
+ + + + + + + + diff --git a/project/doc/ImportInconsistency.html b/project/doc/ImportInconsistency.html new file mode 100644 index 0000000000..214baf62a1 --- /dev/null +++ b/project/doc/ImportInconsistency.html @@ -0,0 +1,488 @@ + + + + + + + +class ImportInconsistency - CAMAAR - Documentação do Código + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + C + + + + + + + + + + I + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + CAMAAR - Documentação do Código + + + + + + + + + + +
+ + + + + + + + + +
+ + + + +

+ class ImportInconsistency +

+ +
+ +

Registra um dado inválido encontrado durante uma importação do SIGAA.

+ +
+ + + + + +
+ + + + + + + + diff --git a/project/doc/Imports.html b/project/doc/Imports.html new file mode 100644 index 0000000000..ed1268e979 --- /dev/null +++ b/project/doc/Imports.html @@ -0,0 +1,473 @@ + + + + + + + +module Imports - CAMAAR - Documentação do Código + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + C + + + + + + + + + + I + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + CAMAAR - Documentação do Código + + + + + + + + + + +
+ + + + + + + + + +
+ + + + +

+ module Imports +

+ +
+ +

Agrupa os controllers responsáveis por importar dados de sistemas externos.

+ +
+ + + + + +
+ + + + + + + + diff --git a/project/doc/Imports/SigaaController.html b/project/doc/Imports/SigaaController.html new file mode 100644 index 0000000000..c10ba550b1 --- /dev/null +++ b/project/doc/Imports/SigaaController.html @@ -0,0 +1,695 @@ + + + + + + + +class Imports::SigaaController - CAMAAR - Documentação do Código + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + C + + + + + + + + + + I + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + CAMAAR - Documentação do Código + + + + + + + + + + +
+ + + + + + + + + +
+ + + + + + +

+ class Imports::SigaaController +

+ +
+ +

Coordena pela interface administrativa a sincronização de turmas e participantes a partir dos arquivos exportados pelo SIGAA.

+ +
+ + + + + +
+ + + + + + + + diff --git a/project/doc/PasswordResetsController.html b/project/doc/PasswordResetsController.html new file mode 100644 index 0000000000..7857b2b203 --- /dev/null +++ b/project/doc/PasswordResetsController.html @@ -0,0 +1,1081 @@ + + + + + + + +class PasswordResetsController - CAMAAR - Documentação do Código + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + C + + + + + + + + + + I + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + CAMAAR - Documentação do Código + + + + + + + + + + +
+ + + + + + + + + +
+ + + + +

+ class PasswordResetsController +

+ +
+ +

Conduz a solicitação e a aplicação de uma nova senha por meio de token temporário.

+ +
+ + + + + +
+ + + + + + + + diff --git a/project/doc/PasswordSetupsController.html b/project/doc/PasswordSetupsController.html new file mode 100644 index 0000000000..dc014b51c4 --- /dev/null +++ b/project/doc/PasswordSetupsController.html @@ -0,0 +1,781 @@ + + + + + + + +class PasswordSetupsController - CAMAAR - Documentação do Código + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + C + + + + + + + + + + I + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + CAMAAR - Documentação do Código + + + + + + + + + + +
+ + + + + + + + + +
+ + + + +

+ class PasswordSetupsController +

+ +
+ +

Permite que um usuário importado defina sua primeira senha por token temporário.

+ +
+ + + + + +
+ + + + + + + + diff --git a/project/doc/Questao.html b/project/doc/Questao.html new file mode 100644 index 0000000000..598895b370 --- /dev/null +++ b/project/doc/Questao.html @@ -0,0 +1,488 @@ + + + + + + + +class Questao - CAMAAR - Documentação do Código + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + C + + + + + + + + + + I + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + CAMAAR - Documentação do Código + + + + + + + + + + +
+ + + + + + + + + +
+ + + + +

+ class Questao +

+ +
+ +

Representa uma questão pertencente a um template ou a um formulário clonado.

+ +
+ + + + + +
+ + + + + + + + diff --git a/project/doc/RespostaController.html b/project/doc/RespostaController.html new file mode 100644 index 0000000000..4a498b5667 --- /dev/null +++ b/project/doc/RespostaController.html @@ -0,0 +1,898 @@ + + + + + + + +class RespostaController - CAMAAR - Documentação do Código + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + C + + + + + + + + + + I + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + CAMAAR - Documentação do Código + + + + + + + + + + +
+ + + + + + + + + +
+ + + + +

+ class RespostaController +

+ +
+ +

Recebe e persiste as respostas de um participante a um formulário de avaliação.

+ +
+ + + + + +
+ + + + + + + + diff --git a/project/doc/RespostaHelper.html b/project/doc/RespostaHelper.html new file mode 100644 index 0000000000..f5bbc3539d --- /dev/null +++ b/project/doc/RespostaHelper.html @@ -0,0 +1,473 @@ + + + + + + + +module RespostaHelper - CAMAAR - Documentação do Código + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + C + + + + + + + + + + I + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + CAMAAR - Documentação do Código + + + + + + + + + + +
+ + + + + + + + + +
+ + + + +

+ module RespostaHelper +

+ +
+ +

Ponto de extensão para helpers das views de respostas.

+ +
+ + + + + +
+ + + + + + + + diff --git a/project/doc/Respostum.html b/project/doc/Respostum.html new file mode 100644 index 0000000000..09f5405c61 --- /dev/null +++ b/project/doc/Respostum.html @@ -0,0 +1,488 @@ + + + + + + + +class Respostum - CAMAAR - Documentação do Código + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + C + + + + + + + + + + I + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + CAMAAR - Documentação do Código + + + + + + + + + + +
+ + + + + + + + + +
+ + + + +

+ class Respostum +

+ +
+ +

Armazena o valor atribuído a uma questão dentro de uma submissão.

+ +
+ + + + + +
+ + + + + + + + diff --git a/project/doc/SessionsController.html b/project/doc/SessionsController.html new file mode 100644 index 0000000000..5106d624e4 --- /dev/null +++ b/project/doc/SessionsController.html @@ -0,0 +1,861 @@ + + + + + + + +class SessionsController - CAMAAR - Documentação do Código + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + C + + + + + + + + + + I + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + CAMAAR - Documentação do Código + + + + + + + + + + +
+ + + + + + + + + +
+ + + + +

+ class SessionsController +

+ +
+ +

Cria e encerra sessões autenticadas por e-mail ou matrícula.

+ +
+ + + + + +
+ + + + + + + + diff --git a/project/doc/Sigaa.html b/project/doc/Sigaa.html new file mode 100644 index 0000000000..3721713507 --- /dev/null +++ b/project/doc/Sigaa.html @@ -0,0 +1,473 @@ + + + + + + + +module Sigaa - CAMAAR - Documentação do Código + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + C + + + + + + + + + + I + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + CAMAAR - Documentação do Código + + + + + + + + + + +
+ + + + + + + + + +
+ + + + +

+ module Sigaa +

+ +
+ +

Serviços responsáveis por importar e sincronizar os dados exportados pelo SIGAA.

+ +
+ + + + + +
+ + + + + + + + diff --git a/project/doc/Sigaa/ClassMembersImporter.html b/project/doc/Sigaa/ClassMembersImporter.html new file mode 100644 index 0000000000..fe6d2286d1 --- /dev/null +++ b/project/doc/Sigaa/ClassMembersImporter.html @@ -0,0 +1,1777 @@ + + + + + + + +class Sigaa::ClassMembersImporter - CAMAAR - Documentação do Código + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + C + + + + + + + + + + I + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + CAMAAR - Documentação do Código + + + + + + + + + + +
+ + + + + + + + + +
+ + + + + + +

+ class Sigaa::ClassMembersImporter +

+ +
+ +

Importa participantes e vínculos de turma, registrando inconsistências e enviando instruções de senha para usuários recém-criados.

+ +
+ + + + + +
+ + + + + + + + diff --git a/project/doc/Sigaa/ClassesImporter.html b/project/doc/Sigaa/ClassesImporter.html new file mode 100644 index 0000000000..3f39c3aa03 --- /dev/null +++ b/project/doc/Sigaa/ClassesImporter.html @@ -0,0 +1,1156 @@ + + + + + + + +class Sigaa::ClassesImporter - CAMAAR - Documentação do Código + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + C + + + + + + + + + + I + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + CAMAAR - Documentação do Código + + + + + + + + + + +
+ + + + + + + + + +
+ + + + + + +

+ class Sigaa::ClassesImporter +

+ +
+ +

Importa as turmas de um semestre, criando registros novos e atualizando nome ou horário de turmas existentes.

+ +
+ + + + + +
+ + + + + + + + diff --git a/project/doc/Sigaa/DataSynchronizer.html b/project/doc/Sigaa/DataSynchronizer.html new file mode 100644 index 0000000000..522a63b788 --- /dev/null +++ b/project/doc/Sigaa/DataSynchronizer.html @@ -0,0 +1,895 @@ + + + + + + + +class Sigaa::DataSynchronizer - CAMAAR - Documentação do Código + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + C + + + + + + + + + + I + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + CAMAAR - Documentação do Código + + + + + + + + + + +
+ + + + + + + + + +
+ + + + + + +

+ class Sigaa::DataSynchronizer +

+ +
+ +

Coordena os importadores de turmas e participantes e consolida suas contagens.

+ +
+ + + + + +
+ + + + + + + + diff --git a/project/doc/Submissao.html b/project/doc/Submissao.html new file mode 100644 index 0000000000..5c51181f1d --- /dev/null +++ b/project/doc/Submissao.html @@ -0,0 +1,488 @@ + + + + + + + +class Submissao - CAMAAR - Documentação do Código + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + C + + + + + + + + + + I + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + CAMAAR - Documentação do Código + + + + + + + + + + +
+ + + + + + + + + +
+ + + + +

+ class Submissao +

+ +
+ +

Registra que um usuário submeteu respostas para um formulário.

+ +
+ + + + + +
+ + + + + + + + diff --git a/project/doc/Template.html b/project/doc/Template.html new file mode 100644 index 0000000000..6f9ec1f19a --- /dev/null +++ b/project/doc/Template.html @@ -0,0 +1,488 @@ + + + + + + + +class Template - CAMAAR - Documentação do Código + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + C + + + + + + + + + + I + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + CAMAAR - Documentação do Código + + + + + + + + + + +
+ + + + + + + + + +
+ + + + +

+ class Template +

+ +
+ +

Representa um modelo reutilizável de formulário criado por um administrador.

+ +
+ + + + + +
+ + + + + + + + diff --git a/project/doc/TemplatesController.html b/project/doc/TemplatesController.html new file mode 100644 index 0000000000..cab0d2afb3 --- /dev/null +++ b/project/doc/TemplatesController.html @@ -0,0 +1,1463 @@ + + + + + + + +class TemplatesController - CAMAAR - Documentação do Código + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + C + + + + + + + + + + I + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + CAMAAR - Documentação do Código + + + + + + + + + + +
+ + + + + + + + + +
+ + + + +

+ class TemplatesController +

+ +
+ +

Gerencia templates de avaliação e suas questões para o administrador autenticado.

+ +
+ + + + + +
+ + + + + + + + diff --git a/project/doc/TemplatesHelper.html b/project/doc/TemplatesHelper.html new file mode 100644 index 0000000000..829324d51e --- /dev/null +++ b/project/doc/TemplatesHelper.html @@ -0,0 +1,473 @@ + + + + + + + +module TemplatesHelper - CAMAAR - Documentação do Código + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + C + + + + + + + + + + I + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + CAMAAR - Documentação do Código + + + + + + + + + + +
+ + + + + + + + + +
+ + + + +

+ module TemplatesHelper +

+ +
+ +

Ponto de extensão para helpers das views de templates.

+ +
+ + + + + +
+ + + + + + + + diff --git a/project/doc/User.html b/project/doc/User.html new file mode 100644 index 0000000000..06cb69e9d0 --- /dev/null +++ b/project/doc/User.html @@ -0,0 +1,1162 @@ + + + + + + + +class User - CAMAAR - Documentação do Código + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + C + + + + + + + + + + I + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + CAMAAR - Documentação do Código + + + + + + + + + + +
+ + + + + + + + + +
+ + + + +

+ class User +

+ +
+ +

Representa administradores e participantes autenticáveis do CAMAAR. Também administra os tokens temporários usados nos fluxos de senha.

+ +
+ + + + + +
+ + + + + + + + diff --git a/project/doc/UserMailer.html b/project/doc/UserMailer.html new file mode 100644 index 0000000000..5df100a264 --- /dev/null +++ b/project/doc/UserMailer.html @@ -0,0 +1,645 @@ + + + + + + + +class UserMailer - CAMAAR - Documentação do Código + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + C + + + + + + + + + + I + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + CAMAAR - Documentação do Código + + + + + + + + + + +
+ + + + + + + + + +
+ + + + +

+ class UserMailer +

+ +
+ +

Monta as mensagens relacionadas à definição e à redefinição de senhas de usuários.

+ +
+ + + + + +
+ + + + + + + + diff --git a/project/doc/created.rid b/project/doc/created.rid new file mode 100644 index 0000000000..071fabea57 --- /dev/null +++ b/project/doc/created.rid @@ -0,0 +1,33 @@ +Tue, 30 Jun 2026 21:16:24 -0300 +/home/dias/CAMAAR/project/app/controllers/application_controller.rb Tue, 30 Jun 2026 19:26:11 -0300 +/home/dias/CAMAAR/project/app/controllers/course_classes_controller.rb Tue, 30 Jun 2026 19:26:11 -0300 +/home/dias/CAMAAR/project/app/controllers/dashboard_controller.rb Tue, 30 Jun 2026 19:26:11 -0300 +/home/dias/CAMAAR/project/app/controllers/formularios_controller.rb Tue, 30 Jun 2026 19:26:11 -0300 +/home/dias/CAMAAR/project/app/controllers/imports/sigaa_controller.rb Tue, 30 Jun 2026 19:26:11 -0300 +/home/dias/CAMAAR/project/app/controllers/password_resets_controller.rb Tue, 30 Jun 2026 19:26:11 -0300 +/home/dias/CAMAAR/project/app/controllers/password_setups_controller.rb Tue, 30 Jun 2026 19:26:11 -0300 +/home/dias/CAMAAR/project/app/controllers/resposta_controller.rb Tue, 30 Jun 2026 19:27:32 -0300 +/home/dias/CAMAAR/project/app/controllers/sessions_controller.rb Tue, 30 Jun 2026 19:26:11 -0300 +/home/dias/CAMAAR/project/app/controllers/templates_controller.rb Tue, 30 Jun 2026 19:26:11 -0300 +/home/dias/CAMAAR/project/app/helpers/application_helper.rb Tue, 30 Jun 2026 19:26:11 -0300 +/home/dias/CAMAAR/project/app/helpers/formularios_helper.rb Tue, 30 Jun 2026 19:26:11 -0300 +/home/dias/CAMAAR/project/app/helpers/resposta_helper.rb Tue, 30 Jun 2026 19:26:11 -0300 +/home/dias/CAMAAR/project/app/helpers/templates_helper.rb Tue, 30 Jun 2026 19:26:11 -0300 +/home/dias/CAMAAR/project/app/jobs/application_job.rb Tue, 30 Jun 2026 19:26:11 -0300 +/home/dias/CAMAAR/project/app/mailers/application_mailer.rb Tue, 30 Jun 2026 19:26:11 -0300 +/home/dias/CAMAAR/project/app/mailers/user_mailer.rb Tue, 30 Jun 2026 19:26:11 -0300 +/home/dias/CAMAAR/project/app/models/application_record.rb Tue, 30 Jun 2026 19:26:11 -0300 +/home/dias/CAMAAR/project/app/models/class_membership.rb Tue, 30 Jun 2026 19:26:11 -0300 +/home/dias/CAMAAR/project/app/models/course_class.rb Tue, 30 Jun 2026 19:26:11 -0300 +/home/dias/CAMAAR/project/app/models/department.rb Tue, 30 Jun 2026 19:26:11 -0300 +/home/dias/CAMAAR/project/app/models/formulario.rb Tue, 30 Jun 2026 19:26:11 -0300 +/home/dias/CAMAAR/project/app/models/import_inconsistency.rb Tue, 30 Jun 2026 19:26:11 -0300 +/home/dias/CAMAAR/project/app/models/questao.rb Tue, 30 Jun 2026 19:26:11 -0300 +/home/dias/CAMAAR/project/app/models/respostum.rb Tue, 30 Jun 2026 19:26:11 -0300 +/home/dias/CAMAAR/project/app/models/submissao.rb Tue, 30 Jun 2026 19:26:11 -0300 +/home/dias/CAMAAR/project/app/models/template.rb Tue, 30 Jun 2026 19:26:11 -0300 +/home/dias/CAMAAR/project/app/models/user.rb Tue, 30 Jun 2026 19:26:11 -0300 +/home/dias/CAMAAR/project/app/services/formularios/create_from_template.rb Tue, 30 Jun 2026 19:26:11 -0300 +/home/dias/CAMAAR/project/app/services/sigaa/class_members_importer.rb Tue, 30 Jun 2026 19:26:53 -0300 +/home/dias/CAMAAR/project/app/services/sigaa/classes_importer.rb Tue, 30 Jun 2026 19:27:01 -0300 +/home/dias/CAMAAR/project/app/services/sigaa/data_synchronizer.rb Tue, 30 Jun 2026 19:26:11 -0300 diff --git a/project/doc/css/rdoc.css b/project/doc/css/rdoc.css new file mode 100644 index 0000000000..25a3f43076 --- /dev/null +++ b/project/doc/css/rdoc.css @@ -0,0 +1,1955 @@ +/* + * Aliki Theme Stylesheet + * Modern RDoc theme by Stan Lo + * + * Features: + * - Three-column responsive layout (navigation, content, table of contents) + * - Dark mode support with localStorage persistence + * - Auto-generated right sidebar TOC with scroll spy + * - Mobile-optimized search modal + * - Enhanced syntax highlighting for both light and dark themes + * - Code-copying functionality + */ + +/* 1. Design System - CSS Variables and Tokens */ + +/* Light Theme (Default) */ +:root { + /* Color Palette - Primary */ + --color-primary-50: #fdeae9; + --color-primary-100: #fadad3; + --color-primary-200: #f8bfbd; + --color-primary-300: #f5a9a7; + --color-primary-400: #f07f7b; + --color-primary-500: #eb544f; + --color-primary-600: #e62923; + --color-primary-700: #b8211c; + --color-primary-800: #8a1915; + --color-primary-900: #5c100e; + + /* Color Palette - Neutral */ + --color-neutral-50: #fafaf9; + --color-neutral-100: #f5f5f4; + --color-neutral-200: #e7e5e4; + --color-neutral-300: #d6d3d1; + --color-neutral-400: #a8a29e; + --color-neutral-500: #78716c; + --color-neutral-600: #57534e; + --color-neutral-700: #44403c; + --color-neutral-800: #292524; + --color-neutral-900: #1c1917; + + /* Code highlighting colors - neutral palette for all syntax highlighters */ + --code-blue: #1d4ed8; + --code-green: #047857; + --code-orange: #d97706; + --code-purple: #7e22ce; + --code-red: #dc2626; + --code-cyan: #0891b2; + --code-gray: #78716c; + + /* Color Palette - Green (for success states) */ + --color-green-400: #4ade80; + --color-green-500: #22c55e; + --color-green-600: #16a34a; + + /* Semantic Colors - Light Theme */ + --color-text-primary: var(--color-neutral-900); + --color-text-secondary: var(--color-neutral-600); + --color-text-tertiary: var(--color-neutral-500); + --color-background-primary: #fff; + --color-background-secondary: var(--color-neutral-50); + --color-background-tertiary: var(--color-neutral-100); + --color-border-default: var(--color-neutral-300); + --color-border-subtle: var(--color-neutral-200); + --color-border-emphasis: var(--color-neutral-400); + --color-link-default: var(--color-text-primary); + --color-link-hover: var(--color-primary-600); + --color-accent-primary: var(--color-primary-600); + --color-accent-hover: var(--color-primary-700); + --color-accent-subtle: var(--color-primary-50); + --color-code-bg: #f6f8fa; + --color-code-border: var(--color-neutral-300); + --color-nav-bg: #fff; + --color-nav-text: var(--color-neutral-700); + --color-th-background: var(--color-neutral-100); + --color-td-background: var(--color-neutral-50); + + /* Search Type Badge Colors */ + --color-search-type-class-bg: #e6f0ff; + --color-search-type-class-text: #0050a0; + --color-search-type-module-bg: #e6ffe6; + --color-search-type-module-text: #060; + --color-search-type-constant-bg: #fff0e6; + --color-search-type-constant-text: #995200; + --color-search-type-method-bg: #f0e6ff; + --color-search-type-method-text: #5200a0; + + /* RGBA Colors (theme-agnostic) */ + --color-overlay: rgb(0 0 0 / 50%); + --color-emphasis-bg: rgb(255 111 97 / 10%); + --color-emphasis-decoration: rgb(52 48 64 / 25%); + --color-search-highlight-bg: rgb(224 108 117 / 10%); + --color-success-bg: rgb(34 197 94 / 10%); + + /* Typography Scale */ + --font-size-xs: 0.75rem; /* 12px */ + --font-size-sm: 0.875rem; /* 14px */ + --font-size-base: 1rem; /* 16px */ + --font-size-lg: 1.125rem; /* 18px */ + --font-size-xl: 1.25rem; /* 20px */ + --font-size-2xl: 1.5rem; /* 24px */ + --font-size-3xl: 1.875rem; /* 30px */ + --font-size-4xl: 2.25rem; /* 36px */ + --font-size-5xl: 3rem; /* 48px */ + + /* Font Families */ + --font-family-base: -apple-system, blinkmacsystemfont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Helvetica Neue', sans-serif; + --font-family-heading: var(--font-family-base); + --font-family-mono: ui-monospace, 'SFMono-Regular', 'SF Mono', 'Menlo', 'Consolas', 'Liberation Mono', monospace; + + /* Font Weights */ + --font-weight-normal: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + + /* Font Shortcuts */ + --font-primary: var(--font-family-base); + --font-heading: var(--font-family-heading); + --font-code: var(--font-family-mono); + + /* Line Heights */ + --line-height-tight: 1.25; + --line-height-normal: 1.5; + --line-height-relaxed: 1.625; + + /* Spacing Scale */ + --space-1: 0.25rem; /* 4px */ + --space-2: 0.5rem; /* 8px */ + --space-3: 0.75rem; /* 12px */ + --space-4: 1rem; /* 16px */ + --space-5: 1.25rem; /* 20px */ + --space-6: 1.5rem; /* 24px */ + --space-8: 2rem; /* 32px */ + --space-12: 3rem; /* 48px */ + --space-16: 4rem; /* 64px */ + + /* Border Radius */ + --radius-sm: 0.25rem; /* 4px */ + --radius-md: 0.375rem; /* 6px */ + --radius-lg: 0.5rem; /* 8px */ + + /* Shadows */ + --shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 10%), 0 1px 2px -1px rgb(0 0 0 / 10%); + --shadow-md: 0 2px 8px rgb(0 0 0 / 10%); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 10%), 0 4px 6px -4px rgb(0 0 0 / 10%); + --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 10%), 0 8px 10px -6px rgb(0 0 0 / 10%); + + /* Layout Dimensions */ + --layout-sidebar-width: 300px; + --layout-sidebar-width-min: 300px; + --layout-sidebar-width-max: 15%; + --layout-toc-width-min: 240px; + --layout-toc-width-max: 18%; + --layout-content-max-width: 800px; + --layout-header-height: 64px; + --layout-search-width: 400px; + + /* Transitions */ + --transition-fast: 150ms ease-in-out; + --transition-base: 200ms ease-in-out; + --transition-slow: 350ms ease-in-out; + --ease-out-smooth: cubic-bezier(0.4, 0, 0.2, 1); + + /* Animation Durations */ + --duration-fast: 250ms; + --duration-base: 300ms; + --duration-medium: 350ms; + + /* Z-Index Scale */ + --z-fixed: 300; + --z-modal: 400; + --z-popover: 500; + +} + +/* Dark Theme */ +[data-theme="dark"] { + /* Code highlighting colors - neutral palette for all syntax highlighters */ + --code-blue: #93c5fd; + --code-green: #34d399; + --code-orange: #fbbf24; + --code-purple: #c084fc; + --code-red: #f87171; + --code-cyan: #22d3ee; + --code-gray: #a8a29e; + + /* Semantic Colors - Dark Theme */ + --color-text-primary: var(--color-neutral-50); + --color-text-secondary: var(--color-neutral-200); + --color-text-tertiary: var(--color-neutral-400); + --color-background-primary: var(--color-neutral-900); + --color-background-secondary: var(--color-neutral-800); + --color-background-tertiary: var(--color-neutral-700); + --color-border-default: var(--color-neutral-600); + --color-border-subtle: var(--color-neutral-700); + --color-border-emphasis: var(--color-neutral-300); + --color-link-default: var(--color-neutral-50); + --color-link-hover: var(--color-primary-500); + --color-accent-primary: var(--color-primary-500); + --color-accent-hover: var(--color-primary-400); + --color-accent-subtle: rgb(235 84 79 / 10%); + --color-accent-subtle-hover: rgb(235 84 79 / 20%); + --color-code-bg: var(--color-neutral-800); + --color-code-border: var(--color-neutral-700); + --color-nav-bg: var(--color-neutral-900); + --color-nav-text: var(--color-neutral-50); + --color-th-background: var(--color-background-tertiary); + --color-td-background: var(--color-background-secondary); + + /* Search Type Badge Colors - Dark Theme */ + --color-search-type-class-bg: #1e3a5f; + --color-search-type-class-text: #93c5fd; + --color-search-type-module-bg: #14532d; + --color-search-type-module-text: #86efac; + --color-search-type-constant-bg: #451a03; + --color-search-type-constant-text: #fcd34d; + --color-search-type-method-bg: #3b0764; + --color-search-type-method-text: #d8b4fe; + + /* Dark theme shadows (slightly more subtle) */ + --shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 40%), 0 1px 2px -1px rgb(0 0 0 / 40%); + --shadow-md: 0 2px 8px rgb(0 0 0 / 40%); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 40%), 0 4px 6px -4px rgb(0 0 0 / 40%); + --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 40%), 0 8px 10px -6px rgb(0 0 0 / 40%); + +} + +/* 2. Global Styles & Layout */ +body { + background: var(--color-background-primary); + font-family: var(--font-primary); + font-weight: 400; + color: var(--color-text-primary); + line-height: var(--line-height-relaxed); + margin: 0; + overflow-wrap: break-word; /* Avoid overflow on mobile */ + + /* Grid layout with header, sidebar, main, and footer */ + display: grid; + grid-template: "header header" var(--layout-header-height) "nav main" 1fr "nav footer" auto / var(--layout-sidebar-width) 1fr; + min-height: 100vh; +} + +/* Three-column layout when TOC is present */ +body.has-toc { + grid-template: "header header header" var(--layout-header-height) "nav main toc" 1fr "nav footer toc" auto / var(--layout-sidebar-width) 1fr minmax(var(--layout-toc-width-min), var(--layout-toc-width-max)); + min-height: 100vh; +} + +/* Mobile: stack everything vertically */ +@media (width <= 1023px) { + body, + body.has-toc { + display: flex; + flex-direction: column; + grid-template: none; + } +} + +/* 3. Typography */ + +/* 4. Links */ +a { + color: var(--color-link-default); + transition: color var(--transition-base); + text-decoration: underline; + text-underline-offset: 0.2em; /* Make sure it doesn't overlap with underscores in a method name. */ +} + +/* 5. Code and Pre */ + +/* Code blocks */ +pre { + font-family: var(--font-code); + background-color: var(--color-code-bg); + border: 1px solid var(--color-code-border); + border-radius: var(--radius-md); + padding: var(--space-4); + overflow-x: auto; + font-size: var(--font-size-sm); + line-height: var(--line-height-normal); + margin: var(--space-4) 0; + position: relative; +} + +/* Code block wrapper for copy button */ +.code-block-wrapper { + position: relative; + margin: var(--space-4) 0; +} + +/* Copy button styling */ +.copy-code-button { + position: absolute; + top: var(--space-2); + right: var(--space-2); + padding: var(--space-2); + background: var(--color-background-secondary); + border: 1px solid var(--color-border-default); + border-radius: var(--radius-sm); + cursor: pointer; + opacity: 0.6; + transition: + opacity var(--transition-fast), + background var(--transition-fast), + border-color var(--transition-fast), + transform var(--transition-fast), + box-shadow var(--transition-fast); + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + z-index: 10; +} + +.copy-code-button:hover, +.copy-code-button:focus { + opacity: 1; + background: var(--color-background-tertiary); + border-color: var(--color-border-emphasis); + transform: translateY(-1px); + box-shadow: var(--shadow-md); +} + +.copy-code-button:focus { + outline: none; + box-shadow: 0 0 0 3px var(--color-accent-subtle); +} + +.copy-code-button:active { + transform: scale(0.92); + box-shadow: none; +} + +.copy-code-button svg { + width: 1rem; + height: 1rem; + fill: none; + stroke: currentcolor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; + color: var(--color-text-secondary); + transition: color var(--transition-fast), transform var(--transition-base); +} + +.copy-code-button:hover svg { + color: var(--color-text-primary); +} + +/* Copied state - subtle green checkmark */ +.copy-code-button.copied { + background: var(--color-success-bg); + border-color: var(--color-green-500); + opacity: 1; +} + +.copy-code-button.copied svg { + color: var(--color-green-600); +} + +[data-theme="dark"] .copy-code-button.copied { + border-color: var(--color-green-400); +} + +[data-theme="dark"] .copy-code-button.copied svg { + color: var(--color-green-400); +} + +/* Mobile adjustments */ +@media (hover: none) { + .copy-code-button { + opacity: 0.7; + } + + .copy-code-button:active { + opacity: 1; + } +} + +/* Inline code */ +code { + font-family: var(--font-code); + background-color: var(--color-code-bg); + border: 1px solid var(--color-border-subtle); + padding: 0.125rem 0.375rem; + border-radius: var(--radius-sm); + font-size: 0.9em; +} + +pre code { + background: none; + border: none; + padding: 0; + font-size: inherit; +} + +a code:hover { + color: var(--color-link-hover); +} + +/* Tables */ +table { + margin: 0; + border-spacing: 0; + border-collapse: collapse; +} + +table tr th, table tr td { + padding: 0.2em 0.4em; + border: 1px solid var(--color-border-default); +} + +table tr th { + background-color: var(--color-th-background); +} + +table tr:nth-child(even) td { + background-color: var(--color-td-background); +} + +/* 6. Header (Top Navbar) */ +header.top-navbar { + grid-area: header; + position: sticky; + top: 0; + z-index: var(--z-fixed); + background: var(--color-background-primary); + border-bottom: 1px solid var(--color-border-default); + display: flex; + align-items: center; + justify-content: flex-start; + padding: 0 var(--space-6); + gap: var(--space-8); + height: var(--layout-header-height); + box-shadow: var(--shadow-sm); +} + +header.top-navbar .navbar-brand { + font-size: var(--font-size-xl); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + text-decoration: none; + white-space: nowrap; +} + +header.top-navbar .navbar-brand:hover { + color: var(--color-accent-primary); +} + +header.top-navbar .navbar-search { + position: relative; + flex: 0 1 auto; + width: var(--layout-search-width); +} + +header.top-navbar .navbar-search form { + margin: 0; + padding: 0; +} + + + +/* Mobile search icon button (hidden on desktop) */ +.navbar-search-mobile { + display: none; +} + +/* Theme toggle button */ +.theme-toggle { + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-2); + margin-left: auto; + background: transparent; + border: 1px solid var(--color-border-default); + border-radius: var(--radius-md); + color: var(--color-text-primary); + cursor: pointer; + transition: + background var(--transition-fast), + border-color var(--transition-fast), + color var(--transition-fast), + transform var(--transition-fast); + font-size: var(--font-size-lg); + line-height: 1; + width: 2.5rem; + height: 2.5rem; +} + +.theme-toggle:hover { + background: var(--color-background-secondary); + border-color: var(--color-accent-primary); + color: var(--color-accent-primary); + transform: scale(1.05); +} + +.theme-toggle:focus { + outline: none; + border-color: var(--color-accent-primary); + box-shadow: 0 0 0 3px var(--color-accent-subtle); +} + +.theme-toggle:active { + transform: scale(0.95); +} + +.theme-toggle-icon { + display: inline-block; + transition: transform var(--duration-base) var(--ease-out-smooth); +} + +.theme-toggle:hover .theme-toggle-icon { + transform: rotate(15deg) scale(1.1); +} + +/* Mobile navbar */ +@media (width <= 1023px) { + header.top-navbar { + display: flex; + align-items: center; + padding: 0 var(--space-4); + gap: var(--space-4); + } + + /* Hide desktop search bar on mobile */ + header.top-navbar .navbar-search-desktop { + display: none; + } + + /* Show mobile search icon */ + .navbar-search-mobile { + display: flex; + align-items: center; + justify-content: center; + padding: 0.5rem; + background: transparent; + border: none; + font-size: 1.25rem; + color: var(--color-text-primary); + cursor: pointer; + transition: color var(--transition-fast); + } + + .navbar-search-mobile:hover { + color: var(--color-accent-primary); + } + + /* Brand needs left margin for hamburger button */ + header.top-navbar .navbar-brand { + margin-left: 2.5rem; + flex: 1; + font-size: var(--font-size-lg); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +/* 7. Navigation (Left Sidebar) */ +nav { + grid-area: nav; + font-family: var(--font-heading); + font-size: var(--font-size-base); + border-right: 1px solid var(--color-border-default); + background: var(--color-nav-bg); + color: var(--color-nav-text); + overflow: hidden auto; + overscroll-behavior: contain; + display: flex; + flex-direction: column; + position: sticky; + top: var(--layout-header-height); + height: calc(100vh - var(--layout-header-height)); + scrollbar-width: thin; + scrollbar-color: var(--color-border-default) transparent; +} + +/* Custom scrollbar for WebKit browsers */ +nav::-webkit-scrollbar { + width: 6px; +} + +nav::-webkit-scrollbar-track { + background: transparent; +} + +nav::-webkit-scrollbar-thumb { + background: var(--color-border-default); + border-radius: var(--radius-sm); + transition: background var(--transition-fast); +} + +nav::-webkit-scrollbar-thumb:hover { + background: var(--color-border-emphasis); +} + +/* Mobile navigation */ +@media (width <= 1023px) { + nav { + position: fixed; + top: var(--layout-header-height); + bottom: 0; + left: 0; + width: var(--layout-sidebar-width); + z-index: calc(var(--z-fixed) - 10); /* Below header */ + box-shadow: var(--shadow-lg); + + /* Don't set height - let top/bottom define it */ + } + + nav[hidden] { + display: none; + } + + /* Backdrop for mobile nav */ + body::before { + content: ''; + position: fixed; + inset: var(--layout-header-height) 0 0 0; + background: var(--color-overlay); + z-index: calc(var(--z-fixed) - 20); + opacity: 0; + pointer-events: none; + transition: opacity var(--transition-base); + } + + /* Show backdrop when nav is open */ + body.nav-open::before { + opacity: 1; + pointer-events: auto; + } +} + +/* Desktop: hide nav when [hidden] attribute is set */ +@media (width >= 1024px) { + nav[hidden] { + display: none; + } +} + +nav .nav-section { + margin-top: var(--space-6); + padding: 0 var(--space-6); +} + +nav h2, nav h3 { + font-size: var(--font-size-lg); + font-weight: var(--font-weight-semibold); + margin: 0 0 var(--space-4); + padding: var(--space-2) 0; + color: var(--color-accent-primary); + border-bottom: 1px solid var(--color-border-default); +} + +nav ul, +nav dl, +nav p { + padding: 0; + list-style: none; + margin: var(--space-3) 0; +} + +nav ul li { + margin-bottom: var(--space-2); + line-height: var(--line-height-relaxed); +} + +nav ul li a { + transition: + color var(--transition-fast), + transform var(--transition-fast), + padding var(--transition-fast); +} + +nav ul li a:hover { + padding-left: var(--space-1); +} + +nav ul ul { + padding-left: var(--space-5); + margin-top: var(--space-2); +} + +nav ul ul ul { + padding-left: var(--space-5); +} + +nav ul ul ul ul { + padding-left: var(--space-5); +} + +nav a { + text-decoration: none; +} + +/* Truncation for direct nav links (not links inside code tags) */ +nav .nav-list > li > a, +nav .nav-section > ul > li > a, +nav .nav-section > dl > dd > a { + display: block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +nav footer { + padding: var(--space-4); + border-top: 1px solid var(--color-border-default); +} + +nav footer a { + color: var(--color-accent-hover); +} + +#navigation-toggle { + display: none; /* Hidden by default, shown on mobile */ +} + +/* Mobile toggle button */ +@media (width <= 1023px) { + #navigation-toggle { + display: flex; + align-items: center; + justify-content: center; + position: fixed; + top: calc(var(--layout-header-height) / 2); + transform: translateY(-50%); + left: 1rem; + z-index: var(--z-fixed); + font-size: 1.5rem; + background: transparent; + border: none; + color: var(--color-text-primary); + cursor: pointer; + transition: color var(--transition-fast); + line-height: 1; + user-select: none; + -webkit-user-select: none; + } + + #navigation-toggle:hover { + color: var(--color-accent-primary); + } +} + +/* + * Shared Collapsible Animation using ::details-content pseudo-element. + * This is the modern CSS approach for animating
elements. + * Uses block-size animation with interpolate-size for smooth height transitions. + * Both nav-section-collapsible and nested link-list details share this pattern. + */ +nav details { + interpolate-size: allow-keywords; +} + +nav details::details-content { + overflow: hidden; + block-size: 0; + transition: block-size 200ms ease, content-visibility 200ms ease allow-discrete; +} + +nav details[open]::details-content { + block-size: auto; +} + +/* Collapsible Navigation Section Headers */ + +nav .nav-section-header { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) 0; + cursor: pointer; + list-style: none; + user-select: none; + -webkit-user-select: none; + border-bottom: 1px solid var(--color-border-default); + margin-bottom: var(--space-3); + transition: color var(--transition-fast); +} + +nav .nav-section-header::-webkit-details-marker { + display: none; +} + +nav .nav-section-header:hover { + color: var(--color-accent-primary); +} + +nav .nav-section-icon { + display: flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + flex-shrink: 0; + color: var(--color-accent-primary); +} + +nav .nav-section-icon svg { + width: 100%; + height: 100%; +} + +nav .nav-section-title { + font-size: var(--font-size-base); + font-weight: var(--font-weight-semibold); + color: inherit; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +nav .nav-section-chevron { + display: flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + flex-shrink: 0; + color: var(--color-text-tertiary); + transition: transform var(--transition-base); +} + +nav .nav-section-chevron svg { + width: 100%; + height: 100%; +} + +/* Rotate chevron when open */ +nav .nav-section-collapsible[open] > .nav-section-header .nav-section-chevron { + transform: rotate(90deg); +} + +nav .nav-section-collapsible > ul, +nav .nav-section-collapsible > dl, +nav .nav-section-collapsible > p { + margin-top: 0; +} + +nav .nav-section-collapsible > .nav-list { + padding-left: var(--space-5); + border-left: 1px solid var(--color-border-subtle); + margin-left: 9px; /* Align with the section icon center */ +} + +nav .nav-section-collapsible .nav-list .link-list { + border-left: none; + margin-left: 0; + padding-left: var(--space-5); +} + +/* + Improve chevron styling for details under link-list, using SVG chevron that matches nav-section-chevron + We need to avoid adding the element in class content generation so it doesn't break darkfish styles +*/ +nav li details:has(.link-list) > summary { + display: inline-flex; + align-items: center; + gap: var(--space-2); + cursor: pointer; +} + +nav li details:has(.link-list) > summary::after { + content: ''; + position: static; + display: inline-block; + width: 1rem; + height: 1rem; + flex-shrink: 0; + margin-left: 0; + background-color: var(--color-text-secondary); + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='9 18 15 12 9 6'%3E%3C/polyline%3E%3C/svg%3E"); + mask-size: contain; + mask-repeat: no-repeat; + mask-position: center; + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='9 18 15 12 9 6'%3E%3C/polyline%3E%3C/svg%3E"); + -webkit-mask-size: contain; + -webkit-mask-repeat: no-repeat; + -webkit-mask-position: center; + transition: transform var(--transition-base), background-color var(--transition-fast); +} + +nav li details:has(.link-list) > summary:hover::after { + background-color: var(--color-accent-primary); +} + +nav li details:has(.link-list)[open] > summary::after { + transform: rotate(90deg); + background-color: var(--color-accent-primary); +} + +/* 8. Main Content (Center Column) */ +main { + grid-area: main; + width: 100%; + max-width: var(--layout-content-max-width); + margin: 0 auto; + padding: var(--space-12) var(--space-8); + font-size: var(--font-size-base); + line-height: var(--line-height-relaxed); + color: var(--color-text-primary); + box-sizing: border-box; +} + +/* Desktop: hide hamburger */ +@media (width >= 1024px) { + #navigation-toggle { + display: none; + } +} + +/* Mobile: full width with padding */ +@media (width <= 1023px) { + main { + padding: var(--space-6) var(--space-4); + padding-top: var(--space-8); + width: 100%; + } + + footer.site-footer { + padding: var(--space-8) var(--space-4); + } + + footer.site-footer .footer-content { + grid-template-columns: 1fr; + gap: var(--space-6); + } +} + +main h1[class] { + margin-top: 0; + margin-bottom: 1em; + font-size: 2.5em; + color: var(--color-accent-primary); +} + +main h1, +main h2, +main h3, +main h4, +main h5, +main h6 { + font-family: var(--font-heading); + color: var(--color-accent-primary); + scroll-margin-top: calc(var(--layout-header-height) + 2rem); +} + +/* Heading size hierarchy */ +main h1 { + font-size: var(--font-size-3xl); + font-weight: var(--font-weight-bold); + margin-bottom: var(--space-4); + line-height: var(--line-height-tight); +} + +main h2 { + font-size: var(--font-size-2xl); + font-weight: var(--font-weight-semibold); + margin-top: var(--space-8); + margin-bottom: var(--space-4); + line-height: var(--line-height-tight); +} + +main h3 { + font-size: var(--font-size-xl); + font-weight: var(--font-weight-semibold); + margin-top: var(--space-6); + margin-bottom: var(--space-3); + line-height: var(--line-height-tight); +} + +main h4 { + font-size: var(--font-size-lg); + font-weight: var(--font-weight-medium); + margin-top: var(--space-4); + margin-bottom: var(--space-2); +} + +main h5, main h6 { + font-size: var(--font-size-base); + font-weight: var(--font-weight-medium); + margin-top: var(--space-3); + margin-bottom: var(--space-2); +} + +/* Heading links */ +main h1 a, +main h2 a, +main h3 a, +main h4 a, +main h5 a, +main h6 a { + color: inherit; + text-decoration: none; +} + +main h1 a:hover, +main h2 a:hover, +main h3 a:hover, +main h4 a:hover, +main h5 a:hover, +main h6 a:hover { + text-decoration: underline; +} + +/* Syntax Highlighting - Light Theme */ +.ruby-constant { color: var(--code-orange); } +.ruby-keyword { color: var(--code-red); } +.ruby-ivar { color: var(--code-orange); } +.ruby-operator { color: var(--code-green); } +.ruby-identifier { color: var(--code-blue); } +.ruby-node { color: var(--code-purple); } + +.ruby-comment { + color: var(--color-neutral-500); + font-style: italic; +} +.ruby-regexp { color: var(--code-purple); } +.ruby-value { color: var(--code-orange); } +.ruby-string { color: var(--code-green); } + +/* Syntax Highlighting - Dark Theme */ +[data-theme="dark"] .ruby-constant { color: var(--code-orange); } +[data-theme="dark"] .ruby-keyword { color: var(--code-red); } +[data-theme="dark"] .ruby-ivar { color: var(--code-orange); } +[data-theme="dark"] .ruby-operator { color: var(--code-green); } +[data-theme="dark"] .ruby-identifier { color: var(--code-blue); } +[data-theme="dark"] .ruby-node { color: var(--code-purple); } + +[data-theme="dark"] .ruby-comment { + color: var(--color-neutral-400); + font-style: italic; +} +[data-theme="dark"] .ruby-regexp { color: var(--code-purple); } +[data-theme="dark"] .ruby-value { color: var(--code-orange); } +[data-theme="dark"] .ruby-string { color: var(--code-green); } + +/* C Syntax Highlighting */ +.c-keyword { color: var(--code-red); } +.c-type { color: var(--code-cyan); } +.c-macro { color: var(--code-orange); } +.c-function { color: var(--code-purple); } +.c-identifier { color: var(--color-text-secondary); } +.c-operator { color: var(--code-green); } +.c-preprocessor { color: var(--code-purple); } +.c-value { color: var(--code-orange); } +.c-string { color: var(--code-green); } + +.c-comment { + color: var(--code-gray); + font-style: italic; +} + +/* Shell Syntax Highlighting */ +.sh-prompt { color: var(--code-gray); } +.sh-command { color: var(--code-blue); } +.sh-option { color: var(--code-cyan); } +.sh-string { color: var(--code-green); } +.sh-envvar { color: var(--code-purple); } + +.sh-comment { + color: var(--code-gray); + font-style: italic; +} + +/* Emphasis */ +em { + text-decoration-color: var(--color-emphasis-decoration); + text-decoration-line: underline; + text-decoration-style: dotted; +} + +strong, +em { + color: var(--color-accent-primary); + background-color: var(--color-emphasis-bg); +} + +/* Paragraphs */ +main p { + line-height: var(--line-height-relaxed); + font-weight: 400; + margin-bottom: var(--space-4); +} + +/* Preformatted Text */ +main pre { + margin: 1.2em 0.5em; + padding: 1em; + font-size: 0.8em; +} + +.code-block-wrapper pre { + margin: 0; +} + +/* Horizontal Rules */ +main hr { + margin: 1.5em 1em; + border: 2px solid var(--color-border-default); +} + +/* Blockquotes */ +main blockquote { + margin: 0 2em 1.2em 1.2em; + padding-left: 0.5em; + border-left: 2px solid var(--color-border-default); +} + +/* Lists */ +main li > p { + margin: 0.5em; +} + +/* Definition Lists */ +main dl { + margin: 1em 0.5em; +} + +main dt { + line-height: 1.5; + font-weight: bold; +} + +main dl.note-list dt { + margin-right: 1em; + float: left; +} + +main dl.note-list dt:has(+ dt) { + margin-right: 0.25em; +} + +main dl.note-list dt:has(+ dt)::after { + content: ', '; + font-weight: normal; +} + +main dd { + margin: 0 0 1em 1em; +} + +main dd p:first-child { + margin-top: 0; +} + +/* Headers within Main */ +main header h2 { + margin-top: 2em; + border-width: 0; + border-top: 4px solid var(--color-border-default); + font-size: 130%; +} + +main header h3 { + margin: 2em 0 1.5em; + border-width: 0; + border-top: 3px solid var(--color-border-default); + font-size: 120%; +} + +h1:target, +h2:target, +h3:target, +h4:target, +h5:target, +h6:target { + margin-left: calc(-1 * var(--space-5)); + padding-left: calc(var(--space-5) / 2); + border-left: calc(var(--space-5) / 2) solid var(--color-border-default); +} + +main .anchor-link:target { + scroll-margin-top: calc(var(--layout-header-height) + 2rem); +} + +/* Legacy anchor for backward compatibility with old label- prefix links */ +.legacy-anchor { + display: block; + position: relative; + visibility: hidden; + scroll-margin-top: calc(var(--layout-header-height) + 2rem); +} + +/* When a legacy anchor is targeted, highlight the next heading sibling */ +.legacy-anchor:target + h1, +.legacy-anchor:target + h2, +.legacy-anchor:target + h3, +.legacy-anchor:target + h4, +.legacy-anchor:target + h5, +.legacy-anchor:target + h6 { + margin-left: calc(-1 * var(--space-5)); + padding-left: calc(var(--space-5) / 2); + border-left: calc(var(--space-5) / 2) solid var(--color-border-default); +} + + +/* Utility Classes */ +.hide { display: none !important; } +.initially-hidden { display: none; } + +/* Screen reader only */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border-width: 0; +} + + + +/* Method Details */ +main .method-source-code { + visibility: hidden; + max-height: 0; + overflow: hidden; + opacity: 0; + transform: translateY(-8px); + transition: + max-height var(--duration-medium) var(--ease-out-smooth), + visibility var(--duration-medium), + opacity var(--duration-fast) ease-out, + transform var(--duration-fast) ease-out; +} + +main .method-source-code pre { + border-color: var(--color-accent-hover); + border-left: 3px solid var(--color-accent-primary); + width: 100%; + box-sizing: border-box; + transition: border-color var(--transition-fast); + scrollbar-width: thin; + scrollbar-color: var(--color-border-default) transparent; +} + +main .method-source-code pre::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +main .method-source-code pre::-webkit-scrollbar-track { + background: transparent; +} + +main .method-source-code pre::-webkit-scrollbar-thumb { + background: var(--color-border-default); + border-radius: var(--radius-sm); +} + +main .method-source-code pre::-webkit-scrollbar-thumb:hover { + background: var(--color-border-emphasis); +} + +main .method-source-code pre::-webkit-scrollbar-corner { + background: transparent; +} + +main .method-source-code.active-menu { + visibility: visible; + max-height: 100vh; + overflow: auto; + opacity: 1; + transform: translateY(0); +} + +main .method-description .method-calls-super { + color: var(--color-text-primary); + font-weight: bold; +} + +main .method-detail { + margin-bottom: 2.5em; +} + +main .method-detail:target { + margin-left: calc(-1 * var(--space-5)); + padding-left: calc(var(--space-5) / 2); + border-left: calc(var(--space-5) / 2) solid var(--color-border-default); +} + +main .method-header { + display: inline-block; + max-width: calc(100% - 6em); /* 6em is the width of the source code toggle */ +} + +main .method-heading { + position: relative; + font-family: var(--font-code); + font-size: var(--font-size-lg); + font-weight: var(--font-weight-semibold); + margin-bottom: var(--space-3); +} + +main .method-heading .method-name { + font-weight: var(--font-weight-semibold); +} + +main .method-heading .method-args { + font-weight: var(--font-weight-normal); +} + +main .method-controls { + float: right; +} + +main .method-controls summary { + display: inline-block; + line-height: 20px; + color: var(--color-accent-primary); + cursor: pointer; + padding: var(--space-1) var(--space-3); + border-radius: var(--radius-sm); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + background: var(--color-accent-subtle); + border: 1px solid transparent; + transition: + color var(--transition-fast), + background var(--transition-fast), + border-color var(--transition-fast), + transform var(--transition-fast); + user-select: none; + -webkit-user-select: none; + list-style: none; +} + +main .method-controls summary::-webkit-details-marker { + display: none; +} + +main .method-controls summary:hover { + background: var(--color-primary-100); + border-color: var(--color-primary-300); + transform: translateY(-1px); +} + +main .method-controls summary:active { + transform: scale(0.96); +} + +[data-theme="dark"] main .method-controls summary:hover { + background: var(--color-accent-subtle-hover); + border-color: var(--color-primary-500); +} + +main .method-description, +main .aliases { + margin-top: 0.75em; + color: var(--color-text-primary); +} + +main .aliases { + padding-top: 4px; + font-style: italic; + cursor: default; +} + +main .aliases a { + color: var(--color-accent-hover); +} + +main .mixin-from { + font-size: 80%; + font-style: italic; + margin-bottom: 0.75em; +} + +main .method-description ul { + margin-left: 1.5em; +} + +main #attribute-method-details .method-detail:hover { + background-color: transparent; + cursor: default; +} + +main .attribute-access-type { + text-transform: uppercase; +} + +/* Small screen adjustments */ +@media (width <= 480px) { + nav { + width: 85%; + max-width: 320px; + } + + main { + margin: 0; + padding: var(--space-4); + max-width: 100%; + } + + table { + display: block; + overflow-x: auto; + white-space: nowrap; + } + + main .method-controls { + margin-top: 10px; + float: none; + } +} + + + +/* 9. Search Modal (Mobile) */ +.search-modal { + position: fixed; + inset: 0; + z-index: var(--z-modal); + display: none; +} + +.search-modal:not([hidden]) { + display: flex; + align-items: flex-start; + justify-content: center; + padding: var(--space-16) var(--space-4); +} + +/* Reduce padding on very small screens */ +@media (width <= 420px) { + .search-modal:not([hidden]) { + padding: var(--space-4) var(--space-3); + } + + .search-modal-content { + border-radius: var(--radius-md); + } + + .search-modal-header { + padding: var(--space-3); + } + + .search-modal-body { + padding: var(--space-3); + } + + .search-modal-form input { + font-size: var(--font-size-base); + min-width: 0; /* Allow input to shrink */ + } + + .search-modal-form { + gap: var(--space-2); + } + + .search-modal-close { + padding: var(--space-1) var(--space-3); + font-size: 0.75rem; + } +} + +.search-modal-backdrop { + position: absolute; + inset: 0; + background: var(--color-overlay); + z-index: 1; +} + +.search-modal-content { + position: relative; + z-index: 2; + background: var(--color-background-primary); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-xl); + width: 100%; + max-width: 600px; + max-height: 80vh; + display: flex; + flex-direction: column; +} + +.search-modal-header { + padding: var(--space-6); + border-bottom: 1px solid var(--color-border-default); +} + +.search-modal-form { + display: flex; + align-items: center; + gap: var(--space-3); +} + +.search-modal-icon { + font-size: 1.5rem; + color: var(--color-text-secondary); + flex-shrink: 0; +} + +.search-modal-form input { + flex: 1; + border: none; + outline: none; + background: transparent; + font-size: var(--font-size-lg); + color: var(--color-text-primary); + padding: 0; +} + +.search-modal-form input::placeholder { + color: var(--color-text-tertiary); +} + +.search-modal-close { + padding: var(--space-2) var(--space-4); + background: var(--color-background-secondary); + border: 1px solid var(--color-border-default); + border-radius: var(--radius-md); + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + cursor: pointer; + transition: background var(--transition-fast), border-color var(--transition-fast); + flex-shrink: 0; +} + +.search-modal-close:hover { + background: var(--color-background-tertiary); + border-color: var(--color-border-default); +} + +.search-modal-body { + padding: var(--space-6); + overflow-y: auto; + flex: 1; +} + +.search-modal-empty { + text-align: center; + color: var(--color-text-tertiary); + padding: var(--space-12) 0; +} + +.search-modal-results { + list-style: none; + margin: 0; + padding: 0; +} + +.search-modal-results.initially-hidden { + display: block !important; /* Override initially-hidden */ +} + +.search-modal-results li { + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-md); + cursor: pointer; + transition: background var(--transition-fast); + margin-bottom: var(--space-2); +} + +.search-modal-results li:hover { + background: var(--color-background-secondary); +} + +.search-modal-results a { + color: var(--color-text-primary); +} + +.search-modal-results .search-match { + margin: 0; + font-size: var(--font-size-base); +} + +.search-modal-results .search-match a { + text-decoration: none; +} + +.search-modal-results .search-namespace { + margin: var(--space-1) 0 0 0; + font-size: var(--font-size-sm); + color: var(--color-text-secondary); +} + +.search-modal-results .search-snippet { + margin: var(--space-1) 0 0 0; + font-size: var(--font-size-sm); + color: var(--color-text-tertiary); +} + +/* 10. Right Sidebar - Table of Contents */ +aside.table-of-contents { + grid-area: toc; + align-self: start; + position: sticky; + top: var(--layout-header-height); + padding: var(--space-8) var(--space-6); + border: none; + border-left: 1px solid var(--color-border-default); + font-size: var(--font-size-base); +} + +aside.table-of-contents * { + border-right: none !important; + outline: none !important; +} + +aside.table-of-contents .toc-sticky { + display: flex; + flex-direction: column; + + /* Exclude header height and top/bottom padding of aside.table-of-contents */ + height: calc(100vh - var(--layout-header-height) - var(--space-8) * 2); +} + +aside.table-of-contents .toc-sticky nav { + height: auto; +} + +aside.table-of-contents .toc-list > .toc-h2 { + margin-left: var(--space-4); +} + +aside.table-of-contents .toc-list > .toc-h3 { + margin-left: var(--space-8); +} + +/* Hide TOC on mobile/tablet */ +@media (width <= 1279px) { + aside.table-of-contents { + display: none; + } + + body.has-toc { + grid-template-columns: var(--layout-sidebar-width) 1fr; + grid-template-areas: + "header header" + "nav main" + "footer footer"; + } +} + +/* Tablet adjustments (between mobile and desktop) */ +@media (width >= 768px) and (width <= 1023px) { + header.top-navbar { + padding: 0 var(--space-6); + } + + main { + padding: var(--space-8) var(--space-6); + max-width: 100%; + } +} + +.table-of-contents h3 { + font-size: var(--font-size-lg); + font-weight: var(--font-weight-semibold); + margin: 0 0 var(--space-5) 0; + color: var(--color-text-primary); +} + +.table-of-contents ul { + margin: 0; + padding: 0; + list-style: none; +} + +.table-of-contents ul ul { + margin-top: var(--space-3); + margin-left: var(--space-5); + border-left: 1px solid var(--color-border-default); + padding-left: var(--space-4); +} + +.table-of-contents li { + margin-bottom: var(--space-3); +} + +.table-of-contents a { + display: block; + color: var(--color-text-secondary); + text-decoration: none; + transition: color var(--transition-fast); + line-height: var(--line-height-relaxed); + overflow-wrap: break-word; + hyphens: auto; +} + +/* Nav hover styles sit here to keep specificity ordering with TOC links */ +nav a:hover { + color: var(--color-link-hover); + text-decoration: underline; +} + +.table-of-contents a:hover { + color: var(--color-link-hover); +} + +.table-of-contents a.active { + color: var(--color-accent-primary); + font-weight: var(--font-weight-medium); +} + +ol.breadcrumb { + display: flex; + padding: 0; + margin: 0 0 1em; +} + +ol.breadcrumb li { + display: block; + list-style: none; + font-size: 125%; +} + +/* 11. Footer */ +footer.site-footer { + grid-area: footer; + background: var(--color-background-secondary); + border-top: 1px solid var(--color-border-default); + padding: var(--space-12) var(--space-6); +} + +footer.site-footer .footer-content { + max-width: 1200px; + margin: 0 auto; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: var(--space-8); +} + +footer.site-footer h3 { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + margin: 0 0 var(--space-4) 0; + letter-spacing: 0.05em; +} + +footer.site-footer ul { + list-style: none; + margin: 0; + padding: 0; +} + +footer.site-footer li { + margin-bottom: var(--space-2); +} + +footer.site-footer a { + color: var(--color-text-secondary); + text-decoration: none; + font-size: var(--font-size-sm); + transition: color var(--transition-fast); +} + +footer.site-footer a:hover { + color: var(--color-link-hover); +} + +footer.site-footer .footer-bottom { + margin-top: var(--space-8); + padding-top: var(--space-6); + border-top: 1px solid var(--color-border-default); + text-align: center; + font-size: var(--font-size-xs); + color: var(--color-text-tertiary); +} + +footer.site-footer .footer-bottom:first-child { + margin-top: 0; + padding-top: 0; + border-top: none; +} + +/* Search */ +#search-section { + padding: var(--space-6); + background-color: var(--color-background-primary); + border-bottom: 1px solid var(--color-border-default); +} + +#search-field-wrapper { + position: relative; + display: flex; + align-items: center; +} + +#search-field { + width: 100%; + padding: var(--space-2) var(--space-4) var(--space-2) 2.5rem; + border: 1px solid var(--color-border-default); + border-radius: 1.25rem; + font-size: var(--font-size-sm); + outline: none; + transition: border-color var(--transition-base); + color: var(--color-text-primary); +} + +#search-field:focus { + border-color: var(--color-accent-primary); +} + +#search-field::placeholder { + color: var(--color-text-primary); +} + +#search-field-wrapper::before { + content: "\1F50D"; + position: absolute; + left: var(--space-3); + top: 50%; + transform: translateY(-50%); + font-size: var(--font-size-sm); + color: var(--color-text-primary); + opacity: 0.6; +} + +/* Search Results */ +.search-results { + font-family: var(--font-primary); + font-weight: 300; +} + +.search-results a { + color: var(--color-text-primary); +} + +.search-results a:hover { + color: var(--color-accent-primary); +} + +.search-results .search-match { + font-family: var(--font-heading); + font-weight: normal; +} + +.search-results .search-selected { + background: var(--color-code-bg); + border-bottom: 1px solid transparent; +} + +.search-results li { + list-style: none; + border-bottom: 1px solid var(--color-border-default); + margin-bottom: 0.5em; +} + +.search-results li:last-child { + border-bottom: none; + margin-bottom: 0; +} + +.search-results li p { + padding: 0; + margin: 0.5em; +} + +.search-results .search-namespace { + font-weight: bold; +} + +.search-results .search-type { + display: inline-block; + margin-left: var(--space-2); + padding: 0 var(--space-2); + font-size: var(--font-size-xs); + font-weight: 500; + border-radius: var(--radius-sm); + vertical-align: middle; + background: var(--color-background-tertiary); + color: var(--color-text-secondary); +} + +.search-results .search-type-class { + background: var(--color-search-type-class-bg); + color: var(--color-search-type-class-text); +} + +.search-results .search-type-module { + background: var(--color-search-type-module-bg); + color: var(--color-search-type-module-text); +} + +.search-results .search-type-constant { + background: var(--color-search-type-constant-bg); + color: var(--color-search-type-constant-text); +} + +.search-results .search-type-instance-method, +.search-results .search-type-class-method { + background: var(--color-search-type-method-bg); + color: var(--color-search-type-method-text); +} + +.search-results li em { + background-color: var(--color-search-highlight-bg); + font-style: normal; +} + +.search-results pre { + margin: 0.5em; + font-family: var(--font-code); +} + +header.top-navbar #search-field { + width: 100%; + padding: var(--space-2) var(--space-4); + border: 1px solid var(--color-border-default); + border-radius: var(--radius-md); + font-size: var(--font-size-base); + background: var(--color-background-primary); + color: var(--color-text-primary); + transition: border-color var(--transition-fast); +} + +header.top-navbar #search-field:focus { + outline: none; + border-color: var(--color-accent-primary); + box-shadow: 0 0 0 3px var(--color-accent-subtle); +} + +header.top-navbar #search-field::placeholder { + color: var(--color-text-tertiary); +} + +/* Search results dropdown in navbar */ +header.top-navbar #search-results-desktop { + position: absolute; + top: calc(100% + var(--space-2)); + left: 0; + width: var(--layout-search-width); + max-height: 60vh; + background: var(--color-background-primary); + border: 1px solid var(--color-border-default); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + overflow-y: auto; + z-index: var(--z-popover); + margin: 0; + padding: 0; +} + +header.top-navbar #search-results-desktop.initially-hidden { + display: none; +} + +header.top-navbar #search-results-desktop[aria-expanded="false"] { + display: none; +} diff --git a/project/doc/index.html b/project/doc/index.html new file mode 100644 index 0000000000..73b87777f1 --- /dev/null +++ b/project/doc/index.html @@ -0,0 +1,420 @@ + + + + + + + +CAMAAR - Documentação do Código + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + C + + + + + + + + + + I + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + CAMAAR - Documentação do Código + + + + + + + + + + +
+ + + + + + + + + +
+ +

This is the API documentation for CAMAAR - Documentação do Código.

+ +
+ + + + + + + + diff --git a/project/doc/js/aliki.js b/project/doc/js/aliki.js new file mode 100644 index 0000000000..7883132b00 --- /dev/null +++ b/project/doc/js/aliki.js @@ -0,0 +1,511 @@ +'use strict'; + +/* ===== Method Source Code Toggling ===== */ + +function showSource(e) { + let target = e.target; + while (!target.classList.contains('method-detail')) { + target = target.parentNode; + } + if (typeof target !== "undefined" && target !== null) { + target = target.querySelector('.method-source-code'); + } + if (typeof target !== "undefined" && target !== null) { + target.classList.toggle('active-menu') + } +} + +function hookSourceViews() { + document.querySelectorAll('.method-source-toggle').forEach((codeObject) => { + codeObject.addEventListener('click', showSource); + }); +} + +/* ===== Search Functionality ===== */ + +function createSearchInstance(input, result) { + if (!input || !result) return null; + + result.classList.remove("initially-hidden"); + + const search = new SearchController(search_data, input, result); + + search.renderItem = function(result) { + const li = document.createElement('li'); + let html = ''; + + // TODO add relative path to