9. MockExamsController
File: app/controllers/mock_exams_controller.rb
show action — return can_attempt (lines 25-38)
def show
@stats = @template.mock_exam_template_stat
respond_to do |format|
format.html
format.json do
render json: template_json(@template).merge(
stats: @stats ? stats_json(@stats) : nil,
pool_ready: @template.pool_ready?,
can_attempt: can_attempt?,
)
end
end
end
sets action — published sets with user attempt status (lines 45-59)
def sets
sets_data = @template.published_sets.map do |set_number, count|
attempts_for_set = @template.mock_exam_attempts.where(pool_set: set_number).count
user_attempted = current_user ? current_user.mock_exam_attempts
.for_template(@template)
.where(pool_set: set_number).exists? : false
{
set_number: set_number,
question_count: count,
attempts_count: attempts_for_set,
user_attempted: user_attempted,
}
end
render json: { sets: sets_data }
end
build_leaderboard_entries — per-set filtering, user names (lines 143-174)
def build_leaderboard_entries
scope = @template.mock_exam_attempts.submitted_or_timed_out
case params[:filter]
when "week"
scope = scope.where("submitted_at >= ?", 1.week.ago)
when "month"
scope = scope.where("submitted_at >= ?", 1.month.ago)
end
scope = scope.where(pool_set: params[:set]) if params[:set].present?
scope
.order(total_score: :desc, submitted_at: :asc)
.limit(20)
.includes(:user)
.map do |attempt|
{
attempt_id: attempt.id,
user_id: attempt.user_id,
username: attempt.user.username,
name: attempt.user.name.presence || attempt.user.username,
profile_image: attempt.user.profile_image_90,
total_score: attempt.total_score,
max_possible_score: attempt.max_possible_score,
accuracy_percent: attempt.accuracy_percent,
pool_set: attempt.pool_set,
time_taken_seconds: attempt.submitted_at && attempt.started_at ?
(attempt.submitted_at - attempt.started_at).to_i : nil,
}
end
end
can_attempt? — no daily limit (lines 222-224)
def can_attempt?
current_user.present?
end
10. MockExamAttemptsController: Set-Based Attempts
File: app/controllers/mock_exam_attempts_controller.rb
create action (lines 6-48)
def create
authorize MockExamAttempt
selected_set = params[:pool_set].present? ? params[:pool_set].to_i : nil
service = MockExams::AssembleExamService.new(@template, current_user, pool_set: selected_set)
questions = service.call
unless questions
msg = "No published questions available. Please try a different set or try later."
respond_to do |format|
format.html { redirect_to mock_exam_path(slug: @template.slug), alert: msg }
format.json { render json: { errors: [msg] }, status: :unprocessable_entity }
end
return
end
@attempt = current_user.mock_exam_attempts.build(
mock_exam_template: @template,
started_at: Time.current,
expires_at: Time.current + @template.duration_minutes.minutes,
pool_set: selected_set,
)
authorize @attempt
unless @attempt.save
respond_to do |format|
format.html { redirect_to mock_exam_path(slug: @template.slug), alert: @attempt.errors.full_messages.first }
format.json { render json: { errors: @attempt.errors.full_messages }, status: :unprocessable_entity }
end
return
end
questions.each do |q|
q.mock_exam_attempt = @attempt
q.save!
MockExamResponse.create!(mock_exam_attempt: @attempt, mock_exam_question: q)
end
respond_to do |format|
format.html { redirect_to mock_exam_attempt_path(slug: @template.slug, id: @attempt.id) }
format.json do
render json: { id: @attempt.id,
redirect_to: "/mock_exams/#{@template.slug}/attempts/#{@attempt.id}" }
end
end
end
Line 9: Reads pool_set from params (sent by frontend).
Line 26: Stores pool_set on the attempt.
11. Admin Controller
File: app/controllers/admin/mock_exam_templates_controller.rb
review_set, publish_set, unpublish_set (lines 20-41)
def review_set
@template = MockExamTemplate.find(params[:id])
@set_number = params[:set].to_i
@questions = @template.set_questions(@set_number)
@is_published = @template.set_published?(@set_number)
end
def publish_set
@template = MockExamTemplate.find(params[:id])
set_number = params[:set].to_i
@template.set_questions(set_number).update_all(set_published: true)
redirect_to review_set_admin_mock_exam_template_path(@template, set: set_number),
notice: "Set #{set_number} published successfully."
end
def unpublish_set
@template = MockExamTemplate.find(params[:id])
set_number = params[:set].to_i
@template.set_questions(set_number).update_all(set_published: false)
redirect_to review_set_admin_mock_exam_template_path(@template, set: set_number),
notice: "Set #{set_number} unpublished."
end
edit_question, update_question, destroy_question (lines 43-66)
def edit_question
@template = MockExamTemplate.find(params[:id])
@question = @template.pool_questions.find(params[:question_id])
end
def update_question
@template = MockExamTemplate.find(params[:id])
@question = @template.pool_questions.find(params[:question_id])
if @question.update(question_params)
redirect_to review_set_admin_mock_exam_template_path(@template, set: @question.pool_set),
notice: "Question updated."
else
render :edit_question
end
end
def destroy_question
@template = MockExamTemplate.find(params[:id])
@question = @template.pool_questions.find(params[:question_id])
set_number = @question.pool_set
@question.destroy!
redirect_to review_set_admin_mock_exam_template_path(@template, set: set_number),
notice: "Question deleted."
end
refresh_pool — only destroy unpublished (lines 108-114)
def refresh_pool
@template = MockExamTemplate.find(params[:id])
@template.pool_questions.where(set_published: false).destroy_all
MockExams::GeneratePoolWorker.perform_async(@template.id)
redirect_to admin_mock_exam_template_path(@template),
notice: "Unpublished questions cleared and new sets being generated. Published sets are preserved."
end
question_params — handle array of hashes (lines 118-129)
def question_params
permitted = params.require(:mock_exam_question).permit(
:question_text, :correct_option_key, :explanation,
:solution_steps, :difficulty, :section_name, :question_type,
)
if params[:mock_exam_question][:options].present?
permitted[:options] = params[:mock_exam_question][:options].map do |opt|
{ "key" => opt[:key], "text" => opt[:text] }
end
end
permitted
end
12. Admin Views
Admin Show — app/views/admin/mock_exam_templates/show.html.erb
The "Question Sets" table (lines 69-100):
<% if @available_sets.any? %>
<h3 class="crayons-subtitle-2 mt-4 mb-3">Question Sets</h3>
<table class="crayons-table mb-4">
<thead>
<tr>
<th>Set</th>
<th>Questions</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<% @available_sets.each do |set_number, count| %>
<% published = @template.set_published?(set_number) %>
<tr>
<td class="fw-bold">Set <%= set_number %></td>
<td><%= count %> questions</td>
<td>
<% if published %>
<span class="c-indicator c-indicator--success">Published</span>
<% else %>
<span class="c-indicator c-indicator--warning">Draft</span>
<% end %>
</td>
<td>
<%= link_to "Review",
review_set_admin_mock_exam_template_path(@template, set: set_number),
class: "c-btn c-btn--secondary c-btn--s" %>
</td>
</tr>
<% end %>
</tbody>
</table>
<% end %>
The recent attempts table now shows Set instead of Source (lines 164-182):
<tr>
<th>User</th>
<th>Status</th>
<th>Set</th>
<th>Score</th>
<th>Accuracy</th>
<th>Started</th>
</tr>
...
<td><%= attempt.pool_set ? "Set #{attempt.pool_set}" : "Random" %></td>
Admin Review Set — app/views/admin/mock_exam_templates/review_set.html.erb
Full file:
<div class="crayons-card p-6">
<div class="flex items-center justify-between mb-4">
<h1 class="crayons-title"><%= @template.title %> — Set <%= @set_number %></h1>
<div class="flex gap-2">
<% if @is_published %>
<%= button_to "Unpublish Set",
unpublish_set_admin_mock_exam_template_path(@template, set: @set_number),
method: :post, class: "c-btn c-btn--destructive",
data: { confirm: "This will hide Set #{@set_number} from users. Continue?" } %>
<% else %>
<%= button_to "Publish Set",
publish_set_admin_mock_exam_template_path(@template, set: @set_number),
method: :post, class: "c-btn c-btn--primary",
data: { confirm: "This will make Set #{@set_number} available to users. Continue?" } %>
<% end %>
<%= link_to "Back", admin_mock_exam_template_path(@template), class: "c-btn c-btn--secondary" %>
</div>
</div>
<div class="mb-3">
<% if @is_published %>
<span class="c-indicator c-indicator--success">Published</span>
<% else %>
<span class="c-indicator c-indicator--warning">Draft — not visible to users</span>
<% end %>
<span class="color-secondary ml-2"><%= @questions.count %> questions</span>
</div>
<% if @questions.any? %>
<% @questions.each_with_index do |q, idx| %>
<div class="crayons-card crayons-card--secondary p-4 mb-3">
<div class="flex items-start justify-between mb-2">
<div>
<span class="fw-bold">Q<%= idx + 1 %>.</span>
<span class="crayons-tag crayons-tag--monochrome fs-xs"><%= q.section_name %></span>
<span class="crayons-tag crayons-tag--monochrome fs-xs"><%= q.difficulty %></span>
<span class="crayons-tag crayons-tag--monochrome fs-xs"><%= q.question_type %></span>
</div>
<div class="flex gap-1">
<%= link_to "Edit",
edit_question_admin_mock_exam_template_path(@template, question_id: q.id),
class: "c-btn c-btn--secondary c-btn--s" %>
<%= button_to "Delete",
destroy_question_admin_mock_exam_template_path(@template, question_id: q.id),
method: :delete, class: "c-btn c-btn--destructive c-btn--s",
data: { confirm: "Delete this question permanently?" } %>
</div>
</div>
<div class="mb-2">
<% if q.question_html.present? %>
<div class="spec__body"><%= q.question_html.html_safe %></div>
<% else %>
<p><%= q.question_text %></p>
<% end %>
</div>
<div class="mb-2">
<% q.options.each do |opt| %>
<div class="p-2 mb-1 radius-default flex items-center gap-2"
style="background: <%= opt['key'] == q.correct_option_key ?
'var(--accent-success-a10)' : 'var(--card-secondary-bg)' %>;">
<span class="fw-bold"
style="<%= opt['key'] == q.correct_option_key ? 'color: var(--accent-success)' : '' %>">
<%= opt['key'] %>.
</span>
<span><%= opt['text'] %></span>
<% if opt['key'] == q.correct_option_key %>
<span class="fs-xs fw-bold" style="color: var(--accent-success);">✓ Correct</span>
<% end %>
</div>
<% end %>
</div>
<% if q.explanation.present? %>
<details class="mt-2">
<summary class="fw-bold fs-s cursor-pointer">Explanation</summary>
<div class="p-2 mt-1" style="background: var(--card-secondary-bg); border-radius: 6px;">
<% if q.explanation_html.present? %>
<div class="spec__body"><%= q.explanation_html.html_safe %></div>
<% else %>
<p><%= q.explanation %></p>
<% end %>
</div>
</details>
<% end %>
</div>
<% end %>
<% else %>
<div class="text-center color-secondary p-6">No questions in this set.</div>
<% end %>
</div>
Admin Edit Question — app/views/admin/mock_exam_templates/edit_question.html.erb
<div class="crayons-card p-6">
<div class="flex items-center justify-between mb-4">
<h1 class="crayons-title">Edit Question</h1>
<%= link_to "Back to Set",
review_set_admin_mock_exam_template_path(@template, set: @question.pool_set),
class: "c-btn c-btn--secondary" %>
</div>
<%= form_with model: @question,
url: update_question_admin_mock_exam_template_path(@template, question_id: @question.id),
method: :patch, local: true do |f| %>
<% if @question.errors.any? %>
<div class="crayons-notice crayons-notice--danger mb-4">
<ul>
<% @question.errors.full_messages.each do |msg| %>
<li><%= msg %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="crayons-field mb-4">
<%= f.label :section_name, class: "crayons-field__label" %>
<%= f.text_field :section_name, class: "crayons-textfield" %>
</div>
<div class="grid grid-cols-2 gap-4 mb-4">
<div class="crayons-field">
<%= f.label :question_type, class: "crayons-field__label" %>
<%= f.select :question_type,
MockExamQuestion.question_types.keys.map { |k| [k.titleize, k] },
{}, class: "crayons-select" %>
</div>
<div class="crayons-field">
<%= f.label :difficulty, class: "crayons-field__label" %>
<%= f.select :difficulty,
MockExamQuestion.difficulties.keys.map { |k| [k.titleize, k] },
{}, class: "crayons-select" %>
</div>
</div>
<div class="crayons-field mb-4">
<%= f.label :question_text, class: "crayons-field__label" %>
<%= f.text_area :question_text, class: "crayons-textfield", rows: 5 %>
</div>
<div class="crayons-field mb-4">
<label class="crayons-field__label">Options</label>
<% %w[A B C D].each_with_index do |key, i| %>
<div class="flex items-center gap-2 mb-2">
<span class="fw-bold" style="min-width: 24px;"><%= key %>.</span>
<input type="text" name="mock_exam_question[options][][key]"
value="<%= key %>" class="hidden">
<input type="text" name="mock_exam_question[options][][text]"
value="<%= @question.options[i]&.dig('text') %>"
class="crayons-textfield" style="flex: 1;" />
</div>
<% end %>
</div>
<div class="crayons-field mb-4">
<%= f.label :correct_option_key, "Correct Answer", class: "crayons-field__label" %>
<%= f.select :correct_option_key, %w[A B C D], {}, class: "crayons-select" %>
</div>
<div class="crayons-field mb-4">
<%= f.label :explanation, class: "crayons-field__label" %>
<%= f.text_area :explanation, class: "crayons-textfield", rows: 4 %>
</div>
<div class="crayons-field mb-4">
<%= f.label :solution_steps, class: "crayons-field__label" %>
<%= f.text_area :solution_steps, class: "crayons-textfield", rows: 4 %>
</div>
<div class="flex gap-2">
<%= f.submit "Save Changes", class: "c-btn c-btn--primary" %>
<%= link_to "Cancel",
review_set_admin_mock_exam_template_path(@template, set: @question.pool_set),
class: "c-btn c-btn--secondary" %>
</div>
<% end %>
</div>
13. ERB Layouts: Width Constraints & Full-Screen
Width-constrained pages
All public mock exam pages use crayons-layout--limited-l + crayons-layout__content to avoid full-width.
app/views/mock_exams/show.html.erb:
<%= content_for :page_meta do %>
<title><%= @template.title %> — Mock Exam — <%= community_name %></title>
<meta name="description" content="<%= truncate(@template.description || '', length: 160) %>" />
<% end %>
<div class="crayons-layout crayons-layout--limited-l">
<div class="crayons-layout__content">
<div id="mock-exam-detail" data-props="<%= { slug: @template.slug }.to_json %>"></div>
</div>
</div>
<%= javascript_include_tag "mockExams", defer: true %>
Same pattern for index.html.erb, dashboard.html.erb, and results.html.erb.
Full-screen exam page
app/views/mock_exam_attempts/show.html.erb:
<%= content_for :page_meta do %>
<title>Exam in Progress — <%= @template.title %> — <%= community_name %></title>
<meta name="robots" content="noindex" />
<style>
.top-bar, .crayons-header, #page-content > footer, footer,
.side-bar, .crayons-layout__sidebar-left, .stories-sidebar,
.broadcast-wrapper, #runtime-banner-container { display: none !important; }
#page-content { padding: 0 !important; margin: 0 !important; max-width: 100% !important; }
#page-content-inner { padding: 0 !important; }
body { overflow: hidden; }
</style>
<% end %>
<div id="mock-exam-interface"
data-props="<%= { slug: @template.slug, attemptId: @attempt.id }.to_json %>"></div>
<%= javascript_include_tag "mockExams", defer: true %>
Hides all Forem chrome (header, footer, sidebar) and makes the exam take the full viewport.
Top comments (0)