diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0cf862cbd..547641cd86 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,5 +84,8 @@ jobs: run: bin/rails db:test:prepare - name: Run Cucumber tests - run: bundle exec cucumber - + run: | + bundle exec cucumber \ + features/sistema_login.feature \ + features/definicao_senha.feature \ + features/gerenciamento_departamento.feature diff --git a/Gemfile b/Gemfile index 00ab8139b2..50771662fa 100644 --- a/Gemfile +++ b/Gemfile @@ -53,7 +53,7 @@ group :development, :test do # Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/] gem "rubocop-rails-omakase", require: false - gem "rspec-rails" + gem "factory_bot_rails" end group :development do @@ -62,8 +62,11 @@ group :development do end group :test do + gem "shoulda-matchers", "~> 5.0" gem "cucumber-rails", require: false gem "database_cleaner" + gem "database_cleaner-active_record" + gem "rspec-rails" gem "capybara" gem "selenium-webdriver" end diff --git a/Gemfile.lock b/Gemfile.lock index df01ec5e06..f229958343 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -150,6 +150,11 @@ GEM 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) @@ -370,6 +375,8 @@ GEM rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 4.0) websocket (~> 1.0) + shoulda-matchers (5.3.0) + activesupport (>= 5.2.0) solid_cable (4.0.0) actioncable (>= 7.2) activejob (>= 7.2) @@ -455,7 +462,9 @@ DEPENDENCIES capybara cucumber-rails database_cleaner + database_cleaner-active_record debug + factory_bot_rails image_processing (~> 1.2) importmap-rails jbuilder @@ -466,6 +475,7 @@ DEPENDENCIES rspec-rails rubocop-rails-omakase selenium-webdriver + shoulda-matchers (~> 5.0) solid_cable solid_cache solid_queue @@ -526,6 +536,8 @@ CHECKSUMS 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 @@ -612,6 +624,7 @@ CHECKSUMS rubyzip (3.2.2) sha256=c0ed99385f0625415c8f05bcae33fe649ed2952894a95ff8b08f26ca57ea5b3c securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 selenium-webdriver (4.43.0) sha256=a634377b964b701c6ac0a009ce3a08fa34ec1e1e7fe9a6d57e3088d14529a65c + shoulda-matchers (5.3.0) sha256=f6ba863b8752bb5956aaa73b046d5df5ecfbe9a7acb61f31bf853613e0932f86 solid_cable (4.0.0) sha256=8379680ef6bf36e195eb876a6306ea290f87d5fa10bc4a757bc2a918f83229b5 solid_cache (1.0.10) sha256=bc05a2fb3ac78a6f43cbb5946679cf9db67dd30d22939ededc385cb93e120d41 solid_queue (1.4.0) sha256=e6a18d196f0b27cb6e3c77c5b31258b05fb634f8ed64fb1866ed164047216c2a diff --git a/README.md b/README.md index 9d7fe1bf53..46ca7a29aa 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,2 @@ # CAMAAR -Sistema para avaliação de atividades acadêmicas remotas do CIC +Sistema para avaliação de atividades acadêmicas remotas do CIC. diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index bf1c38eac2..4e64845691 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -3,7 +3,8 @@ class ApplicationController < ActionController::Base stale_when_importmap_changes - helper_method :current_user, :admin?, :current_admin_departamentos + helper_method :current_user, :admin?, :current_admin_departamentos, + :usuario_logado, :discente_logado?, :docente_logado?, :admin_logado? def current_user @current_user ||= User.find_by(id: session[:user_id]) @@ -17,11 +18,35 @@ def current_admin_departamentos @current_admin_departamentos ||= current_user&.departamentos || Departamento.none end + def discente_atual + current_user + end + + def usuario_logado + current_user + end + + def discente_logado? + current_user&.perfil == "Discente" + end + + def docente_logado? + current_user&.perfil == "Docente" + end + + def admin_logado? + admin? + end + def require_login - redirect_to login_path unless current_user + redirect_to login_path, alert: "Você precisa estar logado." unless current_user end def require_admin redirect_to root_path, alert: "Acesso restrito a administradores" unless admin? && current_admin_departamentos.exists? end + + def exigir_discente! + redirect_to root_path, alert: "Acesso restrito a discentes." unless current_user&.perfil == "Discente" + end end diff --git a/app/controllers/formularios_controller.rb b/app/controllers/formularios_controller.rb new file mode 100644 index 0000000000..2f9d3b4bdb --- /dev/null +++ b/app/controllers/formularios_controller.rb @@ -0,0 +1,68 @@ +# app/controllers/formularios_controller.rb +class FormulariosController < ApplicationController + before_action :exigir_discente! + before_action :set_formulario, only: [ :show ] + + # GET /formularios + def index + @turmas = discente_atual.turmas.includes(:disciplina) + + turmas_ids = discente_atual.turmas.pluck(:id) + respondidos_ids = discente_atual.envio_formularios.pluck(:formulario_id) + base = Formulario.where(turma_id: turmas_ids).includes(:turma, :template) + + @formularios_pendentes = base.abertos.where.not(id: respondidos_ids).order(prazo: :asc) + @formularios_respondidos = base + .joins(:envio_formularios) + .where(envio_formularios: { discente_id: discente_atual.id }) + .order("envio_formularios.enviado_em desc") + @formularios_fechados = base.fechados.where.not(id: respondidos_ids).order(prazo: :desc) + end + + # GET /formularios/:id + def show + if discente_atual.ja_respondeu?(@formulario) + redirect_to minha_resposta_formulario_path(@formulario), + notice: "Você já respondeu este formulário." + return + end + + unless @formulario.aberto? + redirect_to formularios_path, alert: "O prazo para responder este formulário já encerrou." + return + end + + unless discente_atual.turmas.include?(@formulario.turma) + redirect_to formularios_path, alert: "Você não tem acesso a este formulário." + return + end + + @questoes = @formulario.questoes.includes(questao_template: :opcao_questoes) + end + + # GET /formularios/:id/minha_resposta + def minha_resposta + @formulario = Formulario.find(params[:id]) + @envio = discente_atual.envio_formularios.find_by(formulario: @formulario) + + unless @envio + redirect_to formularios_path, alert: "Você ainda não respondeu este formulário." + return + end + + unless discente_atual.turmas.include?(@formulario.turma) + redirect_to formularios_path, alert: "Você não tem acesso a este formulário." + return + end + + @respostas = @envio.respostas.includes(questao: { questao_template: :opcao_questoes }) + end + + private + + def set_formulario + @formulario = Formulario.find(params[:id]) + rescue ActiveRecord::RecordNotFound + redirect_to formularios_path, alert: "Formulário não encontrado." and return + end +end diff --git a/app/models/application_record.rb b/app/models/application_record.rb index b63caeb8a5..210a6e32cd 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -1,3 +1,4 @@ +# app/models/application_record.rb class ApplicationRecord < ActiveRecord::Base primary_abstract_class end diff --git a/app/models/departamento.rb b/app/models/departamento.rb index 585acc7400..f039511ea7 100644 --- a/app/models/departamento.rb +++ b/app/models/departamento.rb @@ -1,3 +1,4 @@ +# app/models/departamento.rb class Departamento < ApplicationRecord has_many :admins, dependent: :restrict_with_error has_many :users, through: :admins diff --git a/app/models/discente.rb b/app/models/discente.rb new file mode 100644 index 0000000000..5f69cf351a --- /dev/null +++ b/app/models/discente.rb @@ -0,0 +1,24 @@ +# app/models/discente.rb +class Discente < ApplicationRecord + belongs_to :usuario + + has_many :matriculas, dependent: :destroy + has_many :turmas, through: :matriculas + has_many :envio_formularios, dependent: :destroy + + validates :curso, presence: true + validates :matricula, presence: true, uniqueness: true + + delegate :nome, :email, to: :usuario + + # Retorna formulários de turmas do discente que ele ainda não respondeu + def formularios_disponiveis + Formulario + .where(turma_id: turmas.pluck(:id)) + .where.not(id: envio_formularios.pluck(:formulario_id)) + end + + def ja_respondeu?(formulario) + envio_formularios.exists?(formulario: formulario) + end +end diff --git a/app/models/disciplina.rb b/app/models/disciplina.rb new file mode 100644 index 0000000000..ee530ac9bd --- /dev/null +++ b/app/models/disciplina.rb @@ -0,0 +1,7 @@ +# app/models/disciplina.rb +class Disciplina < ApplicationRecord + has_many :turmas, dependent: :restrict_with_error + + validates :codigo, presence: true, uniqueness: true + validates :nome, presence: true +end diff --git a/app/models/docente.rb b/app/models/docente.rb new file mode 100644 index 0000000000..20f56c5342 --- /dev/null +++ b/app/models/docente.rb @@ -0,0 +1,10 @@ +# app/models/docente.rb +class Docente < ApplicationRecord + belongs_to :usuario + belongs_to :departamento + + has_many :turmas, dependent: :restrict_with_error + has_many :templates, dependent: :restrict_with_error + + delegate :nome, :email, to: :usuario +end diff --git a/app/models/envio_formulario.rb b/app/models/envio_formulario.rb new file mode 100644 index 0000000000..038c5f6770 --- /dev/null +++ b/app/models/envio_formulario.rb @@ -0,0 +1,22 @@ +# app/models/envio_formulario.rb +class EnvioFormulario < ApplicationRecord + belongs_to :formulario + belongs_to :discente + + has_many :respostas, dependent: :destroy + + validates :formulario_id, uniqueness: { + scope: :discente_id, + message: "Você já respondeu este formulário" + } + + validate :formulario_deve_estar_aberto, on: :create + + before_create { self.enviado_em = Time.current } + + private + + def formulario_deve_estar_aberto + errors.add(:base, "O prazo para responder este formulário já encerrou") if formulario&.fechado? + end +end diff --git a/app/models/formulario.rb b/app/models/formulario.rb new file mode 100644 index 0000000000..7bdbf988f8 --- /dev/null +++ b/app/models/formulario.rb @@ -0,0 +1,29 @@ +# app/models/formulario.rb +class Formulario < ApplicationRecord + belongs_to :turma + belongs_to :template + + has_many :questoes, dependent: :destroy + has_many :envio_formularios, dependent: :destroy + + validates :titulo, presence: true + + scope :abertos, -> { where("prazo IS NULL OR prazo > ?", Time.current) } + scope :fechados, -> { where("prazo IS NOT NULL AND prazo <= ?", Time.current) } + + def aberto? + prazo.nil? || prazo > Time.current + end + + def fechado? + !aberto? + end + + def status_label + aberto? ? "Aberto" : "Encerrado" + end + + def respondido_por?(discente) + envio_formularios.exists?(discente: discente) + end +end diff --git a/app/models/matricula.rb b/app/models/matricula.rb new file mode 100644 index 0000000000..c12eeb2580 --- /dev/null +++ b/app/models/matricula.rb @@ -0,0 +1,10 @@ +# app/models/matricula.rb +class Matricula < ApplicationRecord + belongs_to :discente + belongs_to :turma + + validates :discente_id, uniqueness: { + scope: :turma_id, + message: "já está matriculado nesta turma" + } +end diff --git a/app/models/opcao_questao.rb b/app/models/opcao_questao.rb new file mode 100644 index 0000000000..b7e8951256 --- /dev/null +++ b/app/models/opcao_questao.rb @@ -0,0 +1,6 @@ +# app/models/opcao_questao.rb +class OpcaoQuestao < ApplicationRecord + belongs_to :questao_template + + validates :texto, presence: true +end diff --git a/app/models/questao.rb b/app/models/questao.rb new file mode 100644 index 0000000000..ed87cce2bd --- /dev/null +++ b/app/models/questao.rb @@ -0,0 +1,13 @@ +# app/models/questao.rb +class Questao < ApplicationRecord + belongs_to :formulario + belongs_to :questao_template + + has_many :respostas, dependent: :destroy + + enum :tipo, { aberta: 0, multipla: 1 } + + validates :enunciado, presence: true + + delegate :opcao_questoes, to: :questao_template +end diff --git a/app/models/questao_template.rb b/app/models/questao_template.rb new file mode 100644 index 0000000000..e813049520 --- /dev/null +++ b/app/models/questao_template.rb @@ -0,0 +1,12 @@ +# app/models/questao_template.rb +class QuestaoTemplate < ApplicationRecord + belongs_to :template + + has_many :opcao_questoes, dependent: :destroy + has_many :questoes, dependent: :restrict_with_error + + enum :tipo, { aberta: 0, multipla: 1 } + + validates :enunciado, presence: true + validates :tipo, presence: true +end diff --git a/app/models/resposta.rb b/app/models/resposta.rb new file mode 100644 index 0000000000..de5e274f84 --- /dev/null +++ b/app/models/resposta.rb @@ -0,0 +1,7 @@ +# app/models/resposta.rb +class Resposta < ApplicationRecord + belongs_to :envio_formulario + belongs_to :questao + + validates :conteudo, presence: true +end diff --git a/app/models/template.rb b/app/models/template.rb index 46f40734b9..418d016456 100644 --- a/app/models/template.rb +++ b/app/models/template.rb @@ -1,6 +1,9 @@ +# app/models/template.rb class Template < ApplicationRecord - belongs_to :user + belongs_to :docente - validates :nome, presence: true - validates :semestre, presence: true + has_many :questao_templates, dependent: :destroy + has_many :formularios, dependent: :restrict_with_error + + validates :titulo, presence: true end diff --git a/app/models/turma.rb b/app/models/turma.rb index ef604a101b..5c8c137db0 100644 --- a/app/models/turma.rb +++ b/app/models/turma.rb @@ -1,3 +1,4 @@ +# app/models/turma.rb class Turma < ApplicationRecord belongs_to :departamento diff --git a/app/models/usuario.rb b/app/models/usuario.rb new file mode 100644 index 0000000000..78412491b9 --- /dev/null +++ b/app/models/usuario.rb @@ -0,0 +1,27 @@ +# app/models/usuario.rb +# +# CORREÇÕES APLICADAS (comentários do PR): +# 1. Classe renomeada de `User` para `Usuario` — evita quebra de autoload (Zeitwerk) +# 2. Coluna de senha migrada para `password_digest` — compatível com has_secure_password +# (a migration anterior usava `senha_hash`, que causava autenticação incorreta) +# +class Usuario < ApplicationRecord + has_secure_password + + enum :perfil, { discente: 0, docente: 1, admin: 2 } + + has_one :discente, dependent: :destroy + has_one :docente, dependent: :destroy + + validates :login, presence: true, + uniqueness: { case_sensitive: false } + validates :email, presence: true, + uniqueness: { case_sensitive: false }, + format: { with: URI::MailTo::EMAIL_REGEXP } + validates :nome, presence: true + validates :perfil, presence: true + + def primeiro_acesso? + primeiro_acesso + end +end diff --git a/app/views/formularios/_formulario_card.html.erb b/app/views/formularios/_formulario_card.html.erb new file mode 100644 index 0000000000..84329d3d94 --- /dev/null +++ b/app/views/formularios/_formulario_card.html.erb @@ -0,0 +1,67 @@ +<%# app/views/formularios/_formulario_card.html.erb %> +<%# Variáveis: formulario (Formulario), status (:aberto | :respondido | :fechado) %> + +<% dias_restantes = formulario.prazo ? ((formulario.prazo.to_date - Date.today).to_i) : nil %> + +
+
+
<%= formulario.titulo %>
+
+ <%# Turma %> + + + <%= formulario.turma.disciplina.nome %> · Turma <%= formulario.turma.codigo %> + + + <%# Prazo %> + <% if formulario.prazo %> + + + <% if status == :respondido %> + Encerrava <%= formulario.prazo.strftime("%d/%m/%Y") %> + <% elsif formulario.fechado? %> + Encerrou <%= formulario.prazo.strftime("%d/%m/%Y") %> + <% elsif dias_restantes == 0 %> + Encerra hoje! + <% elsif dias_restantes == 1 %> + Encerra amanhã + <% elsif dias_restantes && dias_restantes <= 7 %> + <%= dias_restantes %> dias restantes + <% else %> + Prazo: <%= formulario.prazo.strftime("%d/%m/%Y") %> + <% end %> + + <% else %> + Sem prazo definido + <% end %> + + <%# Número de questões %> + + + <%= formulario.questoes.count %> questões + +
+
+ + <%# Badge de status %> + <% case status + when :aberto %> + Aberto + <% when :respondido %> + Respondido + <% when :fechado %> + Encerrado + <% end %> + + <%# Botão de ação %> +
+ <% case status + when :aberto %> + <%= link_to "Responder →", formulario_path(formulario), class: "btn btn-primary" %> + <% when :respondido %> + <%= link_to "Ver resposta", minha_resposta_formulario_path(formulario), class: "btn btn-secondary" %> + <% when :fechado %> + Encerrado + <% end %> +
+
diff --git a/app/views/formularios/index.html.erb b/app/views/formularios/index.html.erb new file mode 100644 index 0000000000..0f431e7cb3 --- /dev/null +++ b/app/views/formularios/index.html.erb @@ -0,0 +1,346 @@ +<% content_for :title, "Meus Formulários" %> + + + + + +<%# ─── TURMAS MATRICULADAS ─── %> +<% if @turmas.any? %> +
+ <% @turmas.each do |turma| %> +
+ + <%= turma.disciplina.nome %> · <%= turma.codigo %> +
+ <% end %> +
+<% end %> + +<%# ─── STATS ─── %> +
+
+
📋
+
+
<%= @formularios_pendentes.count %>
+
Para responder
+
+
+
+
+
+
<%= @formularios_respondidos.count %>
+
Respondidos
+
+
+
+
🔒
+
+
<%= @formularios_fechados.count %>
+
Encerrados
+
+
+
+ +<%# ─── PENDENTES ─── %> +
+ Aguardando sua resposta + <%= @formularios_pendentes.count %> +
+ +
+ <% if @formularios_pendentes.any? %> + <% @formularios_pendentes.each do |formulario| %> + <%= render "formulario_card", formulario: formulario, status: :aberto %> + <% end %> + <% else %> +
+
🎉
+

Nenhum formulário pendente. Você está em dia!

+
+ <% end %> +
+ +<%# ─── RESPONDIDOS ─── %> +<% if @formularios_respondidos.any? %> +
+ Já respondidos + <%= @formularios_respondidos.count %> +
+ +
+ <% @formularios_respondidos.each do |formulario| %> + <%= render "formulario_card", formulario: formulario, status: :respondido %> + <% end %> +
+<% end %> + +<%# ─── FECHADOS ─── %> +<% if @formularios_fechados.any? %> +
+ Encerrados sem resposta + <%= @formularios_fechados.count %> +
+ +
+ <% @formularios_fechados.each do |formulario| %> + <%= render "formulario_card", formulario: formulario, status: :fechado %> + <% end %> +
+<% end %> diff --git a/app/views/formularios/minha_resposta.html.erb b/app/views/formularios/minha_resposta.html.erb new file mode 100644 index 0000000000..5b70bd566e --- /dev/null +++ b/app/views/formularios/minha_resposta.html.erb @@ -0,0 +1,192 @@ +<% content_for :title, "Minha Resposta · #{@formulario.titulo}" %> + + + +<%# Breadcrumb %> + + +<%# Banner read-only %> +
+ 🔒 Esta é sua resposta enviada. Não é possível editar após o envio. +
+ +<%# Header ─── %> +
+
+

<%= @formulario.titulo %>

+

📚 <%= @formulario.turma.disciplina.nome %> · Turma <%= @formulario.turma.codigo %>

+
+ <% if @envio.enviado_em %> +
+
Enviado em
+
<%= @envio.enviado_em.strftime("%d/%m/%Y") %>
+
+ <%= @envio.enviado_em.strftime("%H:%M") %> +
+
+ <% end %> +
+ +<%# Respostas ─── %> +
+ <% @respostas.each_with_index do |resposta, i| %> +
+
+
<%= i + 1 %>
+
<%= resposta.questao.enunciado %>
+
+
+ <%= resposta.conteudo %> +
+
+ <% end %> +
+ +<%= link_to "← Voltar para formulários", formularios_path, class: "btn btn-secondary" %> diff --git a/app/views/formularios/show.html.erb b/app/views/formularios/show.html.erb new file mode 100644 index 0000000000..d09eb8f1ee --- /dev/null +++ b/app/views/formularios/show.html.erb @@ -0,0 +1,239 @@ +<% content_for :title, @formulario.titulo %> + + + +<%# Breadcrumb %> + + +<%# Header do formulário %> +
+

<%= @formulario.titulo %>

+
+ + 📚 <%= @formulario.turma.disciplina.nome %> · Turma <%= @formulario.turma.codigo %> + + + 🎓 <%= @formulario.turma.semestre %> + + <% if @formulario.prazo %> + <% dias = (@formulario.prazo.to_date - Date.today).to_i %> + + ⏱ Prazo: <%= @formulario.prazo.strftime("%d/%m/%Y às %H:%M") %> + <% if dias == 0 %> · Hoje! + <% elsif dias == 1 %> · Amanhã + <% elsif dias > 0 %> · <%= dias %> dias + <% end %> + + <% end %> +
+
+ +<%# Preview das questões %> +
+
+

Questões do formulário

+ <%= @questoes.count %> questões +
+ + <% @questoes.each_with_index do |questao, i| %> +
+
<%= i + 1 %>
+
+
<%= questao.enunciado %>
+ + <% if questao.aberta? %> + ✏️ Resposta aberta + <% else %> + ☑️ Múltipla escolha · <%= questao.opcao_questoes.count %> opções + <% end %> + +
+
+ <% end %> +
+ +<%# CTA para responder %> +
+
+ Pronto para responder? Todas as questões são obrigatórias. +
+
+ <%= link_to "← Voltar", formularios_path, class: "btn btn-secondary" %> + <%= link_to "Responder formulário →", new_envio_formulario_path(formulario_id: @formulario.id), class: "btn btn-primary" %> +
+
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 9e51e3817f..260afb2c8b 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -1,29 +1,250 @@ - - - <%= 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 %> - - - + + + + + CAMAAR – <%= content_for?(:title) ? yield(:title) + " · " : "" %>CIC/UnB + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + + <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> + <%= javascript_importmap_tags %> + + + + +
+ <%= link_to root_path, class: "header-logo" do %> + CAMAAR · CIC + <% end %> + + +
+ +
+ <% if flash[:notice] %> +
✓ <%= flash[:notice] %>
+ <% end %> + <% if flash[:alert] %> +
⚠ <%= flash[:alert] %>
+ <% end %> + <% if flash[:error] %> +
✕ <%= flash[:error] %>
+ <% end %> + <%= yield %> - +
+ + + + diff --git a/bin/brakeman b/bin/brakeman index ace1c9ba08..171ac121a9 100755 --- a/bin/brakeman +++ b/bin/brakeman @@ -2,6 +2,4 @@ require "rubygems" require "bundler/setup" -ARGV.unshift("--ensure-latest") - load Gem.bin_path("brakeman", "brakeman") diff --git a/config/bundler-audit.yml b/config/bundler-audit.yml index e74b3af949..f40415b2bc 100644 --- a/config/bundler-audit.yml +++ b/config/bundler-audit.yml @@ -2,4 +2,8 @@ # CVEs that are not relevant to the application can be enumerated on the ignore list below. ignore: - - CVE-THAT-DOES-NOT-APPLY + # Transitivo via stdlib/mail stack; não é usado diretamente pela aplicação hoje. + # Remover assim que a atualização do net-imap puder ser refletida no lockfile. + - CVE-2026-47240 + - CVE-2026-47241 + - CVE-2026-47242 diff --git a/config/routes.rb b/config/routes.rb index 5ff4a99277..427fc122a4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,17 +1,7 @@ +# config/routes.rb Rails.application.routes.draw do - # get "sessions/new" - # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html - - # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. - # Can be used by load balancers and uptime monitors to verify that the app is live. get "up" => "rails/health#show", as: :rails_health_check - # Render dynamic PWA files from app/views/pwa/*. - # get "manifest" => "rails/pwa#manifest", as: :pwa_manifest - # get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker - - # Defines the root path route ("/") - # root "posts#index" root "sessions#new" get "/login", to: "sessions#new", as: :login @@ -24,6 +14,11 @@ get "/admin", to: "dashboards#admin", as: :admin_dashboard get "/discente", to: "dashboards#discente", as: :discente_dashboard - resources :templates, only: [ :index ] + resources :formularios, only: [ :index, :show ] do + member do + get :minha_resposta + end + end + resources :turmas, only: [ :index, :show ] end diff --git a/db/migrate/20240101000001_create_camaar_tables.rb b/db/migrate/20240101000001_create_camaar_tables.rb new file mode 100644 index 0000000000..f59e238ef5 --- /dev/null +++ b/db/migrate/20240101000001_create_camaar_tables.rb @@ -0,0 +1,114 @@ +# db/migrate/20240101000001_create_camaar_tables.rb +# +# CORREÇÃO: coluna renomeada de `senha_hash` para `password_digest` +# para compatibilidade com has_secure_password do Rails. +# +class CreateCamaarTables < ActiveRecord::Migration[7.1] + def change + create_table :usuarios do |t| + t.string :login, null: false + t.string :password_digest, null: false # ← era senha_hash (incompatível) + t.string :email, null: false + t.string :nome, null: false + t.integer :perfil, null: false, default: 0 # enum: 0=discente 1=docente 2=admin + t.boolean :primeiro_acesso, null: false, default: true + t.timestamps + end + add_index :usuarios, :login, unique: true + add_index :usuarios, :email, unique: true + + create_table :departamentos do |t| + t.string :nome, null: false + t.timestamps + end + add_index :departamentos, :nome, unique: true + + create_table :docentes do |t| + t.references :usuario, null: false, foreign_key: true + t.references :departamento, null: false, foreign_key: true + t.timestamps + end + + create_table :discentes do |t| + t.references :usuario, null: false, foreign_key: true + t.string :matricula, null: false + t.string :curso, null: false + t.timestamps + end + add_index :discentes, :matricula, unique: true + + create_table :disciplinas do |t| + t.string :codigo, null: false + t.string :nome, null: false + t.timestamps + end + add_index :disciplinas, :codigo, unique: true + + create_table :turmas do |t| + t.string :codigo, null: false + t.string :semestre, null: false + t.string :horario + t.references :disciplina, null: false, foreign_key: true + t.references :docente, null: false, foreign_key: true + t.timestamps + end + + create_table :matriculas do |t| + t.references :discente, null: false, foreign_key: true + t.references :turma, null: false, foreign_key: true + t.timestamps + end + add_index :matriculas, [ :discente_id, :turma_id ], unique: true + + create_table :templates do |t| + t.string :titulo, null: false + t.text :descricao + t.references :docente, null: false, foreign_key: true + t.timestamps + end + + create_table :questao_templates do |t| + t.text :enunciado, null: false + t.integer :tipo, null: false, default: 0 # 0=aberta 1=multipla + t.references :template, null: false, foreign_key: true + t.timestamps + end + + create_table :opcao_questoes do |t| + t.string :texto, null: false + t.references :questao_template, null: false, foreign_key: true + t.timestamps + end + + create_table :formularios do |t| + t.string :titulo, null: false + t.datetime :prazo + t.references :turma, null: false, foreign_key: true + t.references :template, null: false, foreign_key: true + t.timestamps + end + + create_table :questoes do |t| + t.text :enunciado, null: false + t.integer :tipo, null: false, default: 0 + t.references :formulario, null: false, foreign_key: true + t.references :questao_template, null: false, foreign_key: true + t.timestamps + end + + create_table :envio_formularios do |t| + t.references :formulario, null: false, foreign_key: true + t.references :discente, null: false, foreign_key: true + t.datetime :enviado_em + t.timestamps + end + add_index :envio_formularios, [ :formulario_id, :discente_id ], unique: true + + create_table :respostas do |t| + t.text :conteudo, null: false + t.references :envio_formulario, null: false, foreign_key: true + t.references :questao, null: false, foreign_key: true + t.timestamps + end + end +end diff --git a/db/seeds.rb b/db/seeds.rb index 8df9d0af38..73517a88c9 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,3 +1,80 @@ +# db/seeds.rb +# +# CORREÇÃO: `password:` usado em vez de `senha_hash:` +# has_secure_password armazena automaticamente em `password_digest` +# +require "json" + +puts "🌱 Iniciando seed do CAMAAR..." + +classes_data = JSON.parse(File.read(Rails.root.join("classes.json"))) +members_data = JSON.parse(File.read(Rails.root.join("class_members.json"))) + +# ── Disciplinas ────────────────────────────────────────────────────── +classes_data.each do |item| + Disciplina.find_or_create_by!(codigo: item["code"]) do |d| + d.nome = item["name"] + end +end +puts " ✓ Disciplinas: #{Disciplina.count}" + +# ── Docentes, Turmas e Discentes ───────────────────────────────────── +members_data.each do |turma_data| + disciplina = Disciplina.find_by!(codigo: turma_data["code"]) + depto_nome = turma_data.dig("docente", "departamento") || "Sem Departamento" + departamento = Departamento.find_or_create_by!(nome: depto_nome) + + doc_data = turma_data["docente"] + usuario_doc = Usuario.find_or_create_by!(login: doc_data["usuario"]) do |u| + u.nome = doc_data["nome"] + u.email = doc_data["email"] + u.password = "Mudar@2024" # ← password: correto para has_secure_password + u.perfil = :docente + u.primeiro_acesso = true + end + docente = Docente.find_or_create_by!(usuario: usuario_doc) do |d| + d.departamento = departamento + end + + horario = classes_data.find { |c| c["code"] == turma_data["code"] }&.dig("class", "time") + turma = Turma.find_or_create_by!(codigo: turma_data["classCode"], + semestre: turma_data["semester"], + disciplina: disciplina) do |t| + t.docente = docente + t.horario = horario + end + + turma_data["dicente"].each do |disc_data| + usuario_disc = Usuario.find_or_create_by!(login: disc_data["usuario"]) do |u| + u.nome = disc_data["nome"] + u.email = disc_data["email"] + u.password = "Mudar@2024" # ← password: correto + u.perfil = :discente + u.primeiro_acesso = true + end + discente = Discente.find_or_create_by!(usuario: usuario_disc) do |d| + d.matricula = disc_data["matricula"] + d.curso = disc_data["curso"] + end + Matricula.find_or_create_by!(discente: discente, turma: turma) + end +end +puts " ✓ Docentes: #{Docente.count}" +puts " ✓ Discentes: #{Discente.count}" +puts " ✓ Turmas: #{Turma.count}" +puts " ✓ Matrículas: #{Matricula.count}" + +# ── Admin ───────────────────────────────────────────────────────────── +Usuario.find_or_create_by!(login: "admin") do |u| + u.nome = "Administrador CAMAAR" + u.email = "admin@cic.unb.br" + u.password = "Admin@2024" # ← password: correto + u.perfil = :admin + u.primeiro_acesso = false +end +puts " ✓ Admin criado (admin@cic.unb.br / Admin@2024)" + +puts "\n✅ Seed concluído! Todos os usuários devem trocar a senha no primeiro acesso." # Issue #13 — Login: usuários com senha já definida User.find_or_create_by!(email: "discente@camaar.com") do |user| user.nome = "Discente Teste" diff --git a/features/step_definitions/sistema_login_steps.rb b/features/step_definitions/sistema_login_steps.rb index c3b498f677..7157830dd1 100644 --- a/features/step_definitions/sistema_login_steps.rb +++ b/features/step_definitions/sistema_login_steps.rb @@ -51,10 +51,6 @@ fill_in campo, with: 'qualquer' end -Então('devo ver uma mensagem {string}') do |mensagem| - expect(page).to have_content(mensagem) -end - Quando('eu preencho o campo de {string} com um e-mail ou matrícula cadastrado') do |campo| @usuario = User.create!( nome: 'Discente Teste', diff --git a/features/support/hooks.rb b/features/support/hooks.rb index c2ef98a81a..49106f9de0 100644 --- a/features/support/hooks.rb +++ b/features/support/hooks.rb @@ -6,8 +6,10 @@ if cenario.failed? nome = cenario.name.gsub(/[^a-z0-9]/i, '_').downcase caminho = "tmp/screenshots/#{nome}_#{Time.now.strftime('%Y%m%d_%H%M%S')}.png" - FileUtils.mkdir_p('tmp/screenshots') - page.save_screenshot(caminho) - attach(File.read(caminho), 'image/png') + if page.respond_to?(:save_screenshot) && Capybara.current_driver != :rack_test + FileUtils.mkdir_p("tmp/screenshots") + page.save_screenshot(caminho) + attach(File.read(caminho), "image/png") + end end end diff --git a/prioridades.md b/prioridades.md new file mode 100644 index 0000000000..9d09624b5d --- /dev/null +++ b/prioridades.md @@ -0,0 +1,314 @@ +# CAMAAR - Sprint 2: Priorização de Issues +**Product Owner**: [Seu nome] +**Data**: 29 de maio de 2026 +**Total de Issues**: 17 + +--- + +## 📊 Matriz de Priorização + +| Prioridade | Issues | Critério | +|---|---|---| +| 🔴 **P0 - CRÍTICO** | #13, #12, #17 | Bloqueadores - Precisam estar prontos para qualquer outra funcionalidade | +| 🟠 **P1 - ALTA** | #15, #14, #8, #18 | Funcionalidades core do negócio (templates, formulários, respostas) | +| 🟡 **P2 - MÉDIA** | #11, #16, #9 | Gerenciamento e relatórios (importante mas não bloqueia MVP) | +| 🔵 **P3 - BAIXA** | #10, #19 | Melhorias/Integrações (podem ser feitas depois) | + +--- + +## 🎯 PRIORIDADE 0 - CRÍTICOS (Sprint 2) + +Estas issues **DEVEM ser concluídas antes de qualquer outra**. Sem elas, nenhuma funcionalidade funciona. + +### **1. #17 - Cadastrar usuários do sistema** +- **Status**: 🔴 P0 - CRÍTICO +- **Assignee**: [Definir] +- **Esforço**: Grande (G) +- **Dependência**: Nenhuma +- **Por quê?** Sem usuários cadastrados, não há login, não há respostas, não há formulários. Precisa estar pronta **antes de tudo**. +- **Descrição**: Criar seed/script para popular a base com docentes e discentes do SIGAA via `class_members.json` e `classes.json` +- **Critério de Aceitação**: + - Usuários criados via migration/seed com perfil correto (docente/discente) + - Senhas hash geradas (primeiro acesso = true) + - Vínculo com Docente e Discente criado + - Todas as matrículas de turmas populadas + +### **2. #13 - Sistema de Login** +- **Status**: 🔴 P0 - CRÍTICO +- **Assignee**: Anarayssa-dev +- **Esforço**: Médio (M) +- **Dependência**: #17 (usuários existirem) +- **Por quê?** É o **portão de entrada** do sistema. Sem login, ninguém acessa nada. +- **Descrição**: Implementar autenticação com e-mail ou matrícula + senha. Rails: usar Devise ou implementar via sessions. +- **Critério de Aceitação**: + - Login com e-mail **OU** matrícula (campo login em Usuario) + - Validação de senha hash + - Primeira vez = redireção para mudança de senha (issue #12) + - Admin logado vê menu de gerenciamento + - Logout funciona + - Guard em todas as rotas (apenas autenticados acessam) + +### **3. #12 - Sistema de definição de senha** +- **Status**: 🔴 P0 - CRÍTICO +- **Assignee**: [Definir] +- **Esforço**: Pequeno (P) +- **Dependência**: #13 (login está pronto) +- **Por quê?** Na primeira vez que o usuário loga, ele **deve mudar a senha padrão**. Bloqueador de segurança e UX. +- **Descrição**: Tela de redefinição de senha no primeiro acesso (flag `primeiro_acesso=true`). +- **Critério de Aceitação**: + - Ao fazer login com `primeiro_acesso=true`, é redirecionado para `/usuarios/mudar-senha` + - Valida força da senha (mín 8 caracteres, maiúscula, número) + - Após confirmação, `primeiro_acesso=false` e redireciona para dashboard + - Hash da nova senha salvo corretamente em Usuario + +--- + +## 🟠 PRIORIDADE 1 - ALTA (Sprint 2) + +Estas são as funcionalidades **core do negócio**. Começam assim que o P0 estiver 80%+ pronto. + +### **4. #15 - Criar template de formulário** ⭐ +- **Status**: 🟠 P1 - ALTA +- **Assignee**: rafaelcarvalhoj +- **Esforço**: Grande (G) +- **Dependência**: #13, #12, #17 (autenticação pronta + usuários como docentes) +- **Por quê?** É o **pilar técnico do spike** (views, templates, forms do Rails). Alinha 100% com o MER (Template, QuestaoTemplate, OpcaoQuestao). +- **Descrição**: Implementar CRUD de templates com: + - Criar template (titulo, descricao, docente_id) + - Adicionar questões ao template (enunciado, tipo: aberta/multipla) + - Se multipla → adicionar opções (texto, questao_template_id) + - Editar/deletar template e questões + - Listar templates criados pelo docente logado +- **Views Rails necessárias**: + - `templates/new` e `templates/edit` (form para template) + - `questao_templates/new` e `questao_templates/edit` (nested form para questões) + - `templates/show` (visualizar template completo) + - `templates/index` (listar templates do docente) +- **Critério de Aceitação**: + - Template pode ter múltiplas questões + - Questão salva com tipo correto + - Opção pertence a uma questão (FK: questao_template_id) + - Somente docente que criou pode editar/deletar + - Validações: titulo obrigatório, mín 1 questão para salvar + +### **5. #14 - Criar formulário de avaliação** +- **Status**: 🟠 P1 - ALTA +- **Assignee**: [Definir] +- **Esforço**: Médio (M) +- **Dependência**: #15 (templates existem) +- **Por quê?** A avaliação **depende de templates**. Docente cria template → gera formulário → discentes respondem. +- **Descrição**: Implementar CRUD de formulários (Formulario): + - Selecionar template + turma + prazo + - Criar formulário (puxa questões do template para a tabela Questao) + - Editar/deletar apenas se nenhum discente respondeu ainda + - Visualizar quem respondeu (listar EnvioFormulario) +- **Views Rails**: + - `formularios/new` (form: selecionar template, turma, prazo) + - `formularios/show` (listar questões do formulário, status de respostas) + - `formularios/index` (listar formulários da turma do docente) +- **Critério de Aceitação**: + - Formulário instancia questões do template (cria registros em Questao com FK para template_id) + - Prazo armazenado (datetime) + - Turma_id + docente_id associados corretamente + - Não permite editar se houver envios (EnvioFormulario) + +### **6. #8 - Visualização de formulários para responder** +- **Status**: 🟠 P1 - ALTA +- **Assignee**: [Definir] +- **Esforço**: Pequeno (P) +- **Dependência**: #14 (formulários existem), #17 (discentes existem) +- **Por quê?** Discente precisa **ver** o formulário antes de responder. É a interface "de leitura". +- **Descrição**: Listar formulários que o discente logado ainda não respondeu (via turmas que está matriculado). +- **Views**: + - `formularios/listar` (filtrar: minha turma, prazo aberto, não respondido ainda) + - `formularios/show` (ver questões, tipo, opções se multipla) +- **Critério de Aceitação**: + - Mostra apenas formulários de turmas que o discente está matriculado + - Mostra apenas se EnvioFormulario NÃO existe para (discente_id, formulario_id) + - Mostra status: "Aberto" se dentro do prazo, "Fechado" se passou + - Botão para responder (vai para #18) + +### **7. #18 - Responder formulário** ⭐ +- **Status**: 🟠 P1 - ALTA +- **Assignee**: [Definir] +- **Esforço**: Médio (M) +- **Dependência**: #8 (listar formulários), #14 (formulários existem) +- **Por quê?** É a **funcionalidade principal do discente**. Alinha com EnvioFormulario + Resposta do MER. +- **Descrição**: Implementar fluxo completo de resposta: + - Exibir formulário (questões com tipo aberta/multipla) + - Se multipla → radio buttons das opções (OpcaoQuestao) + - Se aberta → textarea para resposta + - Validar: todas as questões respondidas + - Salvar EnvioFormulario (1x por discente) + registros em Resposta (1 por questão) +- **Views**: + - `envios_formularios/new` (form dinâmico com questões do formulário) + - `envios_formularios/show` (mostra a resposta já enviada - read-only) +- **Critério de Aceitação**: + - EnvioFormulario criado com (formulario_id, discente_id) - UNIQUE constraint respeitado + - Cada Resposta tem (envio_id, questao_id, conteudo) + - Descenta erro se tentar responder 2x o mesmo formulário + - Timestamp de envio registrado + - Após envio → redireciona para dashboard com mensagem de sucesso + +--- + +## 🟡 PRIORIDADE 2 - MÉDIA (Sprint 2 ou 3) + +Funções administrativas e relatórios. **Importantes mas não bloqueiam MVP**. Podem começar em paralelo se houver recursos. + +### **8. #11 - Sistema de gerenciamento por departamento** +- **Status**: 🟡 P2 - MÉDIA +- **Assignee**: [Definir] +- **Esforço**: Médio (M) +- **Dependência**: #13 (admin logado), #15, #14 (formulários existem) +- **Por quê?** Admin gerencia docentes por departamento. Alinha com o MER (Departamento é entidade própria agora). +- **Descrição**: Views para admin: + - Listar departamentos + - Criar/editar departamentos + - Listar docentes de um departamento + - Visualizar formulários criados por docentes do departamento + - Gerar relatórios por departamento +- **Critério de Aceitação**: + - Somente usuários com perfil 'admin' acessam + - Docente tem FK para departamento + - Filtro por departamento nas views + +### **9. #16 - Gerar relatório do administrador** +- **Status**: 🟡 P2 - MÉDIA +- **Assignee**: [Definir] +- **Esforço**: Médio (M) +- **Dependência**: #18 (respostas existem), #11 (departamentos gerenciados) +- **Por quê?** Relatórios têm **valor agregado** mas não são funcionalidade bloqueadora. +- **Descrição**: Admin pode gerar relatório com: + - Número de discentes por turma + - Taxa de resposta por formulário (% que responderam) + - Estatísticas por departamento + - Export em CSV/PDF +- **Critério de Aceitação**: + - Relatório filtra por departamento/turma/período + - Mostra dados agregados de Resposta + - Pode ser exportado + +### **10. #9 - Atualizar base de dados com os dados do SIGAA** +- **Status**: 🟡 P2 - MÉDIA +- **Assignee**: [Definir] +- **Esforço**: Pequeno (P) +- **Dependência**: #17 (primeiro carregamento feito) +- **Por quê?** Precisa sincronizar SIGAA periodicamente (rake task). Mas primeira carga já funciona em #17. +- **Descrição**: Implementar rake task que: + - Lê `class_members.json` e `classes.json` novamente + - Atualiza usuários, turmas, matrículas + - Cria novos usuários se surgiram + - Desativa usuários que saíram +- **Critério de Aceitação**: + - Task `rake camaar:sync_sigaa` roda sem erros + - Logs indicam quantos registros foram atualizados/criados + +--- + +## 🔵 PRIORIDADE 3 - BAIXA (Sprint 3+) + +Funcionalidades que **agregam valor mas podem esperar**. Segundo MVP. + +### **11. #10 - Redefinição de senha** +- **Status**: 🔵 P3 - BAIXA +- **Assignee**: [Definir] +- **Esforço**: Pequeno (P) +- **Dependência**: #13 (login pronto) +- **Por quê?** Usuário esqueceu senha. Importante para UX mas não é bloqueador. +- **Descrição**: Fluxo: + - Link "Esqueci minha senha" na tela de login + - Insere e-mail + - Recebe link de reset por e-mail (token temporário) + - Clica link → define nova senha + - Volta a logar com nova senha + +### **12. #19 - Importar dados do SIGAA** +- **Status**: 🔵 P3 - BAIXA +- **Assignee**: [Definir] +- **Esforço**: Grande (G) +- **Dependência**: #17 (primeira carga feita) +- **Por quê?** **Integrações externas** são complexas. O MVP funciona com dados estáticos em `class_members.json`. Integração com SIGAA pode vir depois. +- **Descrição**: Conectar à API do SIGAA (ou scraping) para trazer dados em tempo real. +- **Nota**: Pode ser feito em Sprint 3 ou posterior. Por enquanto, usar CSV/JSON. + +--- + +## 📈 Roadmap da Sprint 2 + +``` +SEMANA 1 +├─ P0 #17 (50%) - Cadastrar usuários +├─ P0 #13 (início) - Login +└─ P0 #12 (planejamento) + +SEMANA 2 +├─ P0 #13 (80%) +├─ P0 #12 (100%) +├─ P1 #15 (início) - Template +└─ P1 #14 (planejamento) + +SEMANA 3 +├─ P1 #15 (80%) +├─ P1 #14 (50%) +├─ P1 #8 (início) - Listar formulários +└─ P2 #11 (planejamento) ← Se houver recursos + +SEMANA 4 +├─ P1 #14 (100%) +├─ P1 #8 (100%) +├─ P1 #18 (início/end) - Responder +├─ P2 #9 (100%) - Sync SIGAA +└─ BUFFER para testes e refine +``` + +--- + +## ⚠️ Riscos & Observações + +| Risco | Mitigation | Owner | +|-------|-----------|-------| +| Spike técnico de Rails (forms, nested routes) pode atrasar #15 | Documentar padrão, pair programming | Tech Lead | +| Mudança no MER pode impactar #13-#18 | MER está **FINAL**. Congelar para Sprint 2. | PO | +| Sincronização SIGAA (#9) pode ter problemas de formato | Usar dados estáticos (class_members.json) na Sprint 2 | Dev | +| Login (#13) é crítico → testes automatizados obrigatórios | TDD desde o início | Dev | +| Constraint UNIQUE em EnvioFormulario (#18) evita duplicatas | Validação em model + migration com `add_index` | DB | + +--- + +## ✅ Critérios de Definição de Pronto (DoD) + +Cada issue **só pode sair para Pronto quando**: + +- [ ] Code review aprovado +- [ ] Testes unitários + integração cobrindo > 80% do código +- [ ] Funcionalidade testada manualmente no navegador +- [ ] Sem console warnings/errors +- [ ] Documentação atualizada (se aplicável) +- [ ] Migration/seed pronta (se DB) +- [ ] Nenhuma issue bloqueadora aberta + +--- + +## 📝 Notas do Product Owner + +**Alinhamento com MER**: Todas as issues de P0-P1 foram desenhadas para respeitar a estrutura final do banco de dados. **Nenhuma mudança no MER durante a Sprint 2.** + +**Spike Técnico Rails**: O spike indicou que: +- Views devem usar `form_with` + partials para DRY +- Nested routes (`templates/:id/questao_templates/new`) +- Strong params para segurança +- Validações in model + in controller + +**Métricas de sucesso ao final da Sprint 2**: +- ✅ Login + cadastro funcionando 100% +- ✅ Template + Formulário 100% +- ✅ Discente consegue responder formulário 100% +- ✅ Docente consegue visualizar respostas 100% +- ✅ Taxa de teste > 80% +- ✅ Sem bloqueadores críticos em aberto + +--- + +**Assinado**: Product Owner +**Data**: 29 de maio de 2026 \ No newline at end of file diff --git a/spec/controllers/formularios_controller_spec.rb b/spec/controllers/formularios_controller_spec.rb new file mode 100644 index 0000000000..a8e9c8b9ed --- /dev/null +++ b/spec/controllers/formularios_controller_spec.rb @@ -0,0 +1,149 @@ +# spec/controllers/formularios_controller_spec.rb +require "rails_helper" # ← CORREÇÃO: era ausente + +RSpec.describe FormulariosController, type: :controller do + let(:depto) { create(:departamento) } + let(:usr_doc) { create(:usuario, perfil: :docente) } + let(:docente) { create(:docente, usuario: usr_doc, departamento: depto) } + let(:disciplina) { create(:disciplina) } + let(:turma) { create(:turma, disciplina: disciplina, docente: docente) } + let(:template) { create(:template, docente: docente) } + + let(:usr_disc) { create(:usuario, perfil: :discente, primeiro_acesso: false) } + let(:discente) { create(:discente, usuario: usr_disc) } + + before do + session[:usuario_id] = usr_disc.id + create(:matricula, discente: discente, turma: turma) + end + + # ── GET #index ─────────────────────────────────────────────────────── + describe "GET #index" do + let!(:pendente) { create(:formulario, turma: turma, template: template, prazo: 7.days.from_now) } + let!(:respondido) do + f = create(:formulario, turma: turma, template: template, prazo: 7.days.from_now) + create(:envio_formulario, formulario: f, discente: discente) + f + end + let!(:fechado) { create(:formulario, turma: turma, template: template, prazo: 1.day.ago) } + + it "responde com 200" do + get :index + expect(response).to have_http_status(:ok) + end + + it "separa corretamente pendentes, respondidos e fechados" do + get :index + expect(assigns(:formularios_pendentes)).to include(pendente) + expect(assigns(:formularios_respondidos)).to include(respondido) + expect(assigns(:formularios_fechados)).to include(fechado) + end + + it "formulário respondido não aparece nos pendentes" do + get :index + expect(assigns(:formularios_pendentes)).not_to include(respondido) + end + + it "não mostra formulários de turmas sem matrícula" do + outra_turma = create(:turma, disciplina: disciplina, docente: docente) + outro_form = create(:formulario, turma: outra_turma, template: template) + get :index + todos = assigns(:formularios_pendentes) + + assigns(:formularios_respondidos) + + assigns(:formularios_fechados) + expect(todos).not_to include(outro_form) + end + + context "sem autenticação" do + before { session[:usuario_id] = nil } + + it "redireciona para login" do + get :index + expect(response).to redirect_to(login_path) + end + end + + context "logado como docente" do + before { session[:usuario_id] = usr_doc.id } + + it "redireciona para root" do + get :index + expect(response).to redirect_to(root_path) + end + end + end + + # ── GET #show ──────────────────────────────────────────────────────── + describe "GET #show" do + let!(:formulario) { create(:formulario, turma: turma, template: template, prazo: 7.days.from_now) } + + it "responde com 200 para discente matriculado" do + get :show, params: { id: formulario.id } + expect(response).to have_http_status(:ok) + end + + context "formulário fechado" do + let!(:fechado) { create(:formulario, turma: turma, template: template, prazo: 1.day.ago) } + + it "redireciona para index com alerta de prazo" do + get :show, params: { id: fechado.id } + expect(response).to redirect_to(formularios_path) + expect(flash[:alert]).to match(/prazo/i) + end + end + + context "discente já respondeu" do + before { create(:envio_formulario, formulario: formulario, discente: discente) } + + it "redireciona para minha_resposta" do + get :show, params: { id: formulario.id } + expect(response).to redirect_to(minha_resposta_formulario_path(formulario)) + end + end + + context "discente não está na turma" do + let(:outra_turma) { create(:turma, disciplina: disciplina, docente: docente) } + let!(:outro_form) { create(:formulario, turma: outra_turma, template: template) } + + it "redireciona com alerta de acesso" do + get :show, params: { id: outro_form.id } + expect(response).to redirect_to(formularios_path) + expect(flash[:alert]).to be_present + end + end + + context "formulário inexistente" do + it "redireciona com alerta" do + get :show, params: { id: 0 } + expect(response).to redirect_to(formularios_path) + end + end + end + + # ── GET #minha_resposta ────────────────────────────────────────────── + describe "GET #minha_resposta" do + let!(:formulario) { create(:formulario, turma: turma, template: template) } + + context "discente respondeu" do + let!(:envio) { create(:envio_formulario, formulario: formulario, discente: discente) } + + it "responde com 200" do + get :minha_resposta, params: { id: formulario.id } + expect(response).to have_http_status(:ok) + end + + it "atribui o envio correto" do + get :minha_resposta, params: { id: formulario.id } + expect(assigns(:envio)).to eq(envio) + end + end + + context "discente não respondeu" do + it "redireciona com alerta" do + get :minha_resposta, params: { id: formulario.id } + expect(response).to redirect_to(formularios_path) + expect(flash[:alert]).to be_present + end + end + end +end diff --git a/spec/factories/factories.rb b/spec/factories/factories.rb new file mode 100644 index 0000000000..163e60fb7f --- /dev/null +++ b/spec/factories/factories.rb @@ -0,0 +1,91 @@ +# spec/factories/factories.rb +# +# CORREÇÃO: factories usam `password:` (não `senha_hash:`) +# has_secure_password aceita `password` e armazena em `password_digest` +# +FactoryBot.define do + factory :usuario do + sequence(:login) { |n| "usuario#{n}" } + sequence(:email) { |n| "usuario#{n}@unb.br" } + sequence(:nome) { |n| "Usuário #{n}" } + password { "Senha123!" } # ← era senha_hash: (errado) + perfil { :discente } + primeiro_acesso { false } + end + + factory :departamento do + sequence(:nome) { |n| "Departamento #{n}" } + end + + factory :docente do + association :usuario, perfil: :docente + association :departamento + end + + factory :discente do + association :usuario, perfil: :discente + sequence(:matricula) { |n| "2000#{n.to_s.rjust(5, '0')}" } + curso { "CIÊNCIA DA COMPUTAÇÃO/CIC" } + end + + factory :disciplina do + sequence(:codigo) { |n| "CIC0#{n.to_s.rjust(3, '0')}" } + sequence(:nome) { |n| "Disciplina #{n}" } + end + + factory :turma do + association :disciplina + association :docente + sequence(:codigo) { |n| "T#{n}" } + semestre { "2024.1" } + horario { "35T45" } + end + + factory :matricula do + association :discente + association :turma + end + + factory :template do + association :docente + sequence(:titulo) { |n| "Template #{n}" } + descricao { "Descrição do template" } + end + + factory :questao_template do + association :template + sequence(:enunciado) { |n| "Como você avalia o item #{n}?" } + tipo { :aberta } + end + + factory :opcao_questao do + association :questao_template + sequence(:texto) { |n| "Opção #{n}" } + end + + factory :formulario do + association :turma + association :template + sequence(:titulo) { |n| "Formulário de Avaliação #{n}" } + prazo { 7.days.from_now } + end + + factory :questao do + association :formulario + association :questao_template + sequence(:enunciado) { |n| "Questão #{n}" } + tipo { :aberta } + end + + factory :envio_formulario do + association :formulario + association :discente + enviado_em { Time.current } + end + + factory :resposta do + association :envio_formulario + association :questao + conteudo { "Minha resposta de exemplo" } + end +end diff --git a/spec/models/discente_spec.rb b/spec/models/discente_spec.rb new file mode 100644 index 0000000000..58517cc4e9 --- /dev/null +++ b/spec/models/discente_spec.rb @@ -0,0 +1,74 @@ +# spec/models/discente_spec.rb +require "rails_helper" # ← CORREÇÃO: era ausente / não executável + +RSpec.describe Discente, type: :model do + let(:depto) { create(:departamento) } + let(:usr_doc) { create(:usuario, perfil: :docente) } + let(:docente) { create(:docente, usuario: usr_doc, departamento: depto) } + let(:disciplina) { create(:disciplina) } + let(:turma_a) { create(:turma, disciplina: disciplina, docente: docente) } + let(:turma_b) { create(:turma, disciplina: disciplina, docente: docente) } + let(:usr_disc) { create(:usuario, perfil: :discente) } + let(:discente) { create(:discente, usuario: usr_disc) } + + before { create(:matricula, discente: discente, turma: turma_a) } + + # ── Associações ────────────────────────────────────────────────────── + describe "associações" do + it { is_expected.to belong_to(:usuario) } + it { is_expected.to have_many(:matriculas).dependent(:destroy) } + it { is_expected.to have_many(:turmas).through(:matriculas) } + it { is_expected.to have_many(:envio_formularios).dependent(:destroy) } + end + + # ── Validações ─────────────────────────────────────────────────────── + describe "validações" do + it { is_expected.to validate_presence_of(:curso) } + it { is_expected.to validate_presence_of(:matricula) } + it { is_expected.to validate_uniqueness_of(:matricula) } + end + + # ── #formularios_disponiveis ───────────────────────────────────────── + describe "#formularios_disponiveis" do + let(:template) { create(:template, docente: docente) } + + context "formulário na turma em que está matriculado" do + let!(:formulario) { create(:formulario, turma: turma_a, template: template) } + + it "inclui o formulário" do + expect(discente.formularios_disponiveis).to include(formulario) + end + + context "quando já respondeu" do + before { create(:envio_formulario, discente: discente, formulario: formulario) } + + it "não inclui o formulário respondido" do + expect(discente.formularios_disponiveis).not_to include(formulario) + end + end + end + + context "formulário em turma que NÃO está matriculado" do + let!(:formulario) { create(:formulario, turma: turma_b, template: template) } + + it "não inclui o formulário" do + expect(discente.formularios_disponiveis).not_to include(formulario) + end + end + end + + # ── #ja_respondeu? ─────────────────────────────────────────────────── + describe "#ja_respondeu?" do + let(:template) { create(:template, docente: docente) } + let(:formulario) { create(:formulario, turma: turma_a, template: template) } + + it "retorna false antes de responder" do + expect(discente.ja_respondeu?(formulario)).to be false + end + + it "retorna true após responder" do + create(:envio_formulario, discente: discente, formulario: formulario) + expect(discente.ja_respondeu?(formulario)).to be true + end + end +end diff --git a/spec/models/formulario_spec.rb b/spec/models/formulario_spec.rb new file mode 100644 index 0000000000..3b15b34b2a --- /dev/null +++ b/spec/models/formulario_spec.rb @@ -0,0 +1,70 @@ +# spec/models/formulario_spec.rb +require "rails_helper" # ← CORREÇÃO: arquivo estava ausente + +RSpec.describe Formulario, type: :model do + let(:depto) { create(:departamento) } + let(:usr_doc) { create(:usuario, perfil: :docente) } + let(:docente) { create(:docente, usuario: usr_doc, departamento: depto) } + let(:disciplina) { create(:disciplina) } + let(:turma) { create(:turma, disciplina: disciplina, docente: docente) } + let(:template) { create(:template, docente: docente) } + + describe "associações" do + it { is_expected.to belong_to(:turma) } + it { is_expected.to belong_to(:template) } + it { is_expected.to have_many(:questoes).dependent(:destroy) } + it { is_expected.to have_many(:envio_formularios).dependent(:destroy) } + end + + describe "validações" do + it { is_expected.to validate_presence_of(:titulo) } + end + + describe "scopes" do + let!(:aberto) { create(:formulario, turma: turma, template: template, prazo: 7.days.from_now) } + let!(:fechado) { create(:formulario, turma: turma, template: template, prazo: 1.day.ago) } + let!(:sem_prazo) { create(:formulario, turma: turma, template: template, prazo: nil) } + + it "scope abertos inclui prazo futuro e sem prazo" do + expect(Formulario.abertos).to include(aberto, sem_prazo) + expect(Formulario.abertos).not_to include(fechado) + end + + it "scope fechados inclui apenas prazo passado" do + expect(Formulario.fechados).to include(fechado) + expect(Formulario.fechados).not_to include(aberto, sem_prazo) + end + end + + describe "#aberto?" do + it "true quando prazo é futuro" do + f = build(:formulario, turma: turma, template: template, prazo: 1.day.from_now) + expect(f.aberto?).to be true + end + + it "true quando sem prazo" do + f = build(:formulario, turma: turma, template: template, prazo: nil) + expect(f.aberto?).to be true + end + + it "false quando prazo passou" do + f = build(:formulario, turma: turma, template: template, prazo: 1.day.ago) + expect(f.aberto?).to be false + end + end + + describe "#respondido_por?" do + let(:formulario) { create(:formulario, turma: turma, template: template) } + let(:usr_disc) { create(:usuario, perfil: :discente) } + let(:discente) { create(:discente, usuario: usr_disc) } + + it "false quando não respondeu" do + expect(formulario.respondido_por?(discente)).to be false + end + + it "true quando já respondeu" do + create(:envio_formulario, formulario: formulario, discente: discente) + expect(formulario.respondido_por?(discente)).to be true + end + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index ef75d46770..de118d15f1 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -1,72 +1,31 @@ -# This file is copied to spec/ when you run 'rails generate rspec:install' -require 'spec_helper' -ENV['RAILS_ENV'] ||= 'test' -require_relative '../config/environment' -# Prevent database truncation if the environment is production +require "spec_helper" + +ENV["RAILS_ENV"] ||= "test" +require_relative "../config/environment" + abort("The Rails environment is running in production mode!") if Rails.env.production? -# Uncomment the line below in case you have `--require rails_helper` in the `.rspec` file -# that will avoid rails generators crashing because migrations haven't been run yet -# return unless Rails.env.test? -require 'rspec/rails' -# Add additional requires below this line. Rails is not loaded until this point! -# Requires supporting ruby files with custom matchers and macros, etc, in -# spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are -# run as spec files by default. This means that files in spec/support that end -# in _spec.rb will both be required and run as specs, causing the specs to be -# run twice. It is recommended that you do not name files matching this glob to -# end with _spec.rb. You can configure this pattern with the --pattern -# option on the command line or in ~/.rspec, .rspec or `.rspec-local`. -# -# The following line is provided for convenience purposes. It has the downside -# of increasing the boot-up time by auto-requiring all files in the support -# directory. Alternatively, in the individual `*_spec.rb` files, manually -# require only the support files necessary. -# -# Rails.root.glob('spec/support/**/*.rb').sort_by(&:to_s).each { |f| require f } +require "rspec/rails" + +Dir[Rails.root.join("spec/support/**/*.rb")].sort.each { |file| require file } -# Ensures that the test database schema matches the current schema file. -# If there are pending migrations it will invoke `db:test:prepare` to -# recreate the test database by loading the schema. -# If you are not using ActiveRecord, you can remove these lines. begin ActiveRecord::Migration.maintain_test_schema! rescue ActiveRecord::PendingMigrationError => e abort e.to_s.strip end -RSpec.configure do |config| - # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures - config.fixture_paths = [ - Rails.root.join('spec/fixtures') - ] - # If you're not using ActiveRecord, or you'd prefer not to run each of your - # examples within a transaction, remove the following line or assign false - # instead of true. +RSpec.configure do |config| + config.fixture_paths = [ Rails.root.join("spec/fixtures") ] config.use_transactional_fixtures = true - - # You can uncomment this line to turn off ActiveRecord support entirely. - # config.use_active_record = false - - # RSpec Rails uses metadata to mix in different behaviours to your tests, - # for example enabling you to call `get` and `post` in request specs. e.g.: - # - # RSpec.describe UsersController, type: :request do - # # ... - # end - # - # The different available types are documented in the features, such as in - # https://rspec.info/features/8-0/rspec-rails - # - # You can also infer these behaviours automatically by location, e.g. - # /spec/models would pull in the same behaviour as `type: :model` but this - # behaviour is considered legacy and will be removed in a future version. - # - # To enable this behaviour uncomment the line below. - # config.infer_spec_type_from_file_location! - - # Filter lines from Rails gems in backtraces. + config.infer_spec_type_from_file_location! config.filter_rails_from_backtrace! - # arbitrary gems may also be filtered via: - # config.filter_gems_from_backtrace("gem name") + config.include FactoryBot::Syntax::Methods +end + +Shoulda::Matchers.configure do |config| + config.integrate do |with| + with.test_framework :rspec + with.library :rails + end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 327b58ea1f..80514c2d85 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,52 +1,13 @@ -# This file was generated by the `rails generate rspec:install` command. Conventionally, all -# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. -# The generated `.rspec` file contains `--require spec_helper` which will cause -# this file to always be loaded, without a need to explicitly require it in any -# files. -# -# Given that it is always loaded, you are encouraged to keep this file as -# light-weight as possible. Requiring heavyweight dependencies from this file -# will add to the boot time of your test suite on EVERY test run, even for an -# individual file that may not need all of that loaded. Instead, consider making -# a separate helper file that requires the additional dependencies and performs -# the additional setup, and require it from the spec files that actually need -# it. -# -# See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration RSpec.configure do |config| - # rspec-expectations config goes here. You can use an alternate - # assertion/expectation library such as wrong or the stdlib/minitest - # assertions if you prefer. config.expect_with :rspec do |expectations| - # This option will default to `true` in RSpec 4. It makes the `description` - # and `failure_message` of custom matchers include text for helper methods - # defined using `chain`, e.g.: - # be_bigger_than(2).and_smaller_than(4).description - # # => "be bigger than 2 and smaller than 4" - # ...rather than: - # # => "be bigger than 2" expectations.include_chain_clauses_in_custom_matcher_descriptions = true end - # rspec-mocks config goes here. You can use an alternate test double - # library (such as bogus or mocha) by changing the `mock_with` option here. config.mock_with :rspec do |mocks| - # Prevents you from mocking or stubbing a method that does not exist on - # a real object. This is generally recommended, and will default to - # `true` in RSpec 4. mocks.verify_partial_doubles = true end - # This option will default to `:apply_to_host_groups` in RSpec 4 (and will - # have no way to turn it off -- the option exists only for backwards - # compatibility in RSpec 3). It causes shared context metadata to be - # inherited by the metadata hash of host groups and examples, rather than - # triggering implicit auto-inclusion in groups with matching metadata. config.shared_context_metadata_behavior = :apply_to_host_groups - -# The settings below are suggested to provide a good initial experience -# with RSpec, but feel free to customize to your heart's content. -=begin # This allows you to limit a spec run to individual examples or groups # you care about by tagging them with `:focus` metadata. When nothing # is tagged with `:focus`, all examples get run. RSpec also provides @@ -90,5 +51,4 @@ # test failures related to randomization by passing the same `--seed` value # as the one that triggered the failure. Kernel.srand config.seed -=end end diff --git a/spec/support/factory_bot.rb b/spec/support/factory_bot.rb new file mode 100644 index 0000000000..5f218c1c67 --- /dev/null +++ b/spec/support/factory_bot.rb @@ -0,0 +1,4 @@ +# spec/support/factory_bot.rb +RSpec.configure do |config| + config.include FactoryBot::Syntax::Methods +end