diff --git a/app/controllers/admin/answers_controller.rb b/app/controllers/admin/answers_controller.rb index 827d0dd..7305b28 100644 --- a/app/controllers/admin/answers_controller.rb +++ b/app/controllers/admin/answers_controller.rb @@ -1,5 +1,4 @@ class Admin::AnswersController < Admin::BaseController - before_action :find_answer, only: %i[ edit show update destroy ] before_action :find_question, only: %i[ new create ] @@ -16,7 +15,7 @@ def create @answer = @question.answers.new(answer_params) if @answer.save - redirect_to admin_answer_path(@answer), notice: 'Answer was successfully created.' + redirect_to admin_answer_path(@answer), notice: "Answer was successfully created." else render :new end @@ -24,7 +23,7 @@ def create def update if @answer.update(answer_params) - redirect_to admin_answer_path(@answer), notice: 'Answer was successfully update.' + redirect_to admin_answer_path(@answer), notice: "Answer was successfully update." else render :edit end @@ -36,7 +35,7 @@ def destroy end - private + private def find_answer @answer = Answer.find(params[:id]) diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb index b40d47a..2c2bfb0 100644 --- a/app/controllers/admin/base_controller.rb +++ b/app/controllers/admin/base_controller.rb @@ -1,6 +1,5 @@ class Admin::BaseController < ApplicationController - - layout 'admin' + layout "admin" before_action :authenticate_user! before_action :admin_required! diff --git a/app/controllers/admin/tests_controller.rb b/app/controllers/admin/tests_controller.rb index 22e8a73..6d4e0b2 100644 --- a/app/controllers/admin/tests_controller.rb +++ b/app/controllers/admin/tests_controller.rb @@ -57,7 +57,7 @@ def set_tests end def test_params - params.require(:test).permit(:title, :level, :category_id, :author_id) + params.require(:test).permit(:title, :level, :category_id, :author_id, :timer) end def find_test diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 0ceb1c5..38e7736 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,12 +1,11 @@ class ApplicationController < ActionController::Base - protect_from_forgery with: :exception before_action :configure_permitted_parameters, if: :devise_controller? before_action :set_locale def default_url_options - { lang: ((I18n.locale == I18n.default_locale) ? nil : I18n.locale) } + { lang: ((I18n.locale == I18n.default_locale) ? nil : I18n.locale) } end diff --git a/app/controllers/gists_controller.rb b/app/controllers/gists_controller.rb index 9c87f45..4e126fc 100644 --- a/app/controllers/gists_controller.rb +++ b/app/controllers/gists_controller.rb @@ -1,7 +1,6 @@ class GistsController < ApplicationController - before_action :authenticate_user! - + def create test_passage = TestPassage.find(params[:test_passage_id]) @@ -10,7 +9,7 @@ def create flash_answer = if result.success? { notice: "#{t('.success')} | #{link_in_gist(result.html_url)}" } else - { alert: t('.failure')} + { alert: t(".failure") } end redirect_to test_passage, flash_answer @@ -20,6 +19,6 @@ def create private def link_in_gist(gist_url) - view_context.link_to( t('helpers.link.go_gist'), gist_url, class: "link-dark link-offset-2 link-underline-opacity-25 link-underline-opacity-100-hover", target: '_blank') + view_context.link_to(t("helpers.link.go_gist"), gist_url, class: "link-dark link-offset-2 link-underline-opacity-25 link-underline-opacity-100-hover", target: "_blank") end end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 6f9e7c8..2865d5c 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,7 +1,6 @@ class SessionsController < Devise::SessionsController - def create super - flash[:notice] = t('sessions_controller.welcome', name: resource.first_name || resource.email ) + flash[:notice] = t("sessions_controller.welcome", name: resource.first_name || resource.email) end end diff --git a/app/controllers/test_passages_controller.rb b/app/controllers/test_passages_controller.rb index a9b4b46..dea520e 100644 --- a/app/controllers/test_passages_controller.rb +++ b/app/controllers/test_passages_controller.rb @@ -1,13 +1,19 @@ class TestPassagesController < ApplicationController before_action :authenticate_user! - before_action :set_test_passage, only: %i[ show result update ] + before_action :set_test_passage, only: %i[show result update] def show; end def result; end def update - if @test_passage.question_any?(params) + @test_passage = TestPassage.find(params[:id]) + + if @test_passage.time_over? + flash[:alert] = t("test_passages.times_up") + + redirect_to result_test_passage_path(@test_passage) + elsif @test_passage.question_any?(params) @test_passage.accept!(params[:answer_ids]) completed_test @@ -16,6 +22,7 @@ def update end end + private def set_test_passage @@ -24,20 +31,22 @@ def set_test_passage def completed_test if @test_passage.completed? - - TestMailer.completed_test(@test_passage).deliver_later - - if @test_passage.test_successful? - new_badges = BadgeAwardService.new(@test_passage).call - - if new_badges.any? - flash[:notice] = t("test_passages.badge", name_badge: new_badges.map(&:title).join(", ")) - end - end - + send_completion_notifications + award_badges redirect_to result_test_passage_path(@test_passage) else redirect_to test_passage_path(@test_passage) end end + + def send_completion_notifications + TestMailer.completed_test(@test_passage).deliver_later + end + + def award_badges + new_badges = BadgeAwardService.new(@test_passage).call + return unless new_badges.any? + + flash[:notice] = t("test_passages.badge", name_badge: new_badges.map(&:title).join(", ")) + end end diff --git a/app/controllers/tests_controller.rb b/app/controllers/tests_controller.rb index 1da58d3..0b9cbe5 100644 --- a/app/controllers/tests_controller.rb +++ b/app/controllers/tests_controller.rb @@ -1,12 +1,11 @@ class TestsController < ApplicationController - def index @tests = Test.all end - def start - authenticate_user! - + def start + authenticate_user! + current_user.tests.push(find_test) redirect_to current_user.test_passage(find_test) end diff --git a/app/helpers/answers_helper.rb b/app/helpers/answers_helper.rb index 0e3f7f0..bf69303 100644 --- a/app/helpers/answers_helper.rb +++ b/app/helpers/answers_helper.rb @@ -1,10 +1,9 @@ module AnswersHelper - def answer_header(answer) if answer.new_record? - t('answers_helper.create_new_answer') + t("answers_helper.create_new_answer") else - t('answers_helper.edit_answer', body: answer.body ) - end + t("answers_helper.edit_answer", body: answer.body) + end end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 9374988..e2849dc 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,5 +1,4 @@ module ApplicationHelper - def current_year Time.current.year end @@ -11,10 +10,10 @@ def github_url(author, repo) def flash_message safe_join(flash.map do |flash_type, message| alert_class = case flash_type.to_sym - when :notice, :success then 'alert-success' - when :alert, :error then 'alert-danger' - when :warning then 'alert-warning' - else 'alert-info' + when :notice, :success then "alert-success" + when :alert, :error then "alert-danger" + when :warning then "alert-warning" + else "alert-info" end content_tag :div, sanitize(message), class: "alert #{alert_class} my-4" diff --git a/app/helpers/questions_helper.rb b/app/helpers/questions_helper.rb index 5a80c23..58b61cf 100644 --- a/app/helpers/questions_helper.rb +++ b/app/helpers/questions_helper.rb @@ -1,10 +1,9 @@ module QuestionsHelper - def question_header(question) if question.new_record? - t('question_helper.create_new_question', question_title: question.test.title ) + t("question_helper.create_new_question", question_title: question.test.title) else - t('question_helper.edit_question', question_title: question.test.title ) - end + t("question_helper.edit_question", question_title: question.test.title) + end end end diff --git a/app/helpers/test_passages_helper.rb b/app/helpers/test_passages_helper.rb index a6cc767..8c8e472 100644 --- a/app/helpers/test_passages_helper.rb +++ b/app/helpers/test_passages_helper.rb @@ -1,12 +1,11 @@ module TestPassagesHelper - def success_rate_message(test_passage) if test_passage.test_successful? - content_tag(:h3, I18n.t('test_passages_helper.success_rate_message.success_test'), class: 'text-start md-4') + - I18n.t('test_passages_helper.success_rate_message.rate') + content_tag(:span, "#{test_passage.result_test}%", style: "color: green;") + content_tag(:h3, I18n.t("test_passages_helper.success_rate_message.success_test"), class: "text-start md-4") + + I18n.t("test_passages_helper.success_rate_message.rate") + content_tag(:span, "#{test_passage.result_test}%", style: "color: green;") else - content_tag(:h3, I18n.t('test_passages_helper.success_rate_message.failed_test'), class: 'text-start') + - I18n.t('test_passages_helper.success_rate_message.rate') + content_tag(:span, "#{test_passage.result_test}%", style: "color: red;") + content_tag(:h3, I18n.t("test_passages_helper.success_rate_message.failed_test"), class: "text-start") + + I18n.t("test_passages_helper.success_rate_message.rate") + content_tag(:span, "#{test_passage.result_test}%", style: "color: red;") end end end diff --git a/app/helpers/tests_helper.rb b/app/helpers/tests_helper.rb index 096b37e..fff72c5 100644 --- a/app/helpers/tests_helper.rb +++ b/app/helpers/tests_helper.rb @@ -1,10 +1,9 @@ module TestsHelper - def test_header(test) if test.new_record? - t('tests_helper.create_new_test') + t("tests_helper.create_new_test") else - t('tests_helper.edit_test', test_title: test.title) - end + t("tests_helper.edit_test", test_title: test.title) + end end end diff --git a/app/javascript/application.js b/app/javascript/application.js index eb27e91..2ecfa2d 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -7,3 +7,4 @@ import "utilities/sorting" import "utilities/check_password" import "utilities/form_inline" import "utilities/progress_bar" +import "utilities/timer" diff --git a/app/javascript/utilities/timer.js b/app/javascript/utilities/timer.js new file mode 100644 index 0000000..89387bd --- /dev/null +++ b/app/javascript/utilities/timer.js @@ -0,0 +1,31 @@ +document.addEventListener("turbo:load", function() { + const timerElement = document.getElementById("timer"); + if (!timerElement) return; + + const form = document.getElementById("test-passage-form"); + const initialMinutes = parseInt(timerElement.dataset.minutes) || 0; + const initialSeconds = parseInt(timerElement.dataset.seconds) || 0; + let totalSeconds = initialMinutes * 60 + initialSeconds; + + function updateTimer() { + if (totalSeconds <= 0) { + clearInterval(timerInterval); + timerElement.textContent = "0:00"; + + if (form) form.submit(); + + return; + } + + totalSeconds--; + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + timerElement.textContent = `${minutes}:${seconds.toString().padStart(2, "0")}`; + } + + const timerInterval = setInterval(updateTimer, 1000); + + document.addEventListener("turbo:before-visit", () => { + clearInterval(timerInterval); + }); +}); diff --git a/app/mailers/test_mailer.rb b/app/mailers/test_mailer.rb index 0e94604..d9a1a7c 100644 --- a/app/mailers/test_mailer.rb +++ b/app/mailers/test_mailer.rb @@ -1,9 +1,8 @@ class TestMailer < ApplicationMailer - def completed_test(test_passage) @user = test_passage.user @test = test_passage.test - + mail to: @user.email end end diff --git a/app/models/admin.rb b/app/models/admin.rb index 962ec1e..ef25cf2 100644 --- a/app/models/admin.rb +++ b/app/models/admin.rb @@ -1,6 +1,4 @@ class Admin < User - validates :first_name, presence: true validates :last_name, presence: true - end diff --git a/app/models/answer.rb b/app/models/answer.rb index 535964d..8ef5cb0 100644 --- a/app/models/answer.rb +++ b/app/models/answer.rb @@ -1,5 +1,4 @@ class Answer < ApplicationRecord - belongs_to :question validates :body, presence: true diff --git a/app/models/category.rb b/app/models/category.rb index 17afbe8..cf48f1f 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -1,9 +1,7 @@ class Category < ApplicationRecord - has_many :tests - + validates :title, presence: true - + default_scope { order(title: :asc) } - end diff --git a/app/models/gist.rb b/app/models/gist.rb index c431189..7adb90b 100644 --- a/app/models/gist.rb +++ b/app/models/gist.rb @@ -1,6 +1,4 @@ class Gist < ApplicationRecord - belongs_to :question belongs_to :user - end diff --git a/app/models/test.rb b/app/models/test.rb index bed8227..4db6f08 100644 --- a/app/models/test.rb +++ b/app/models/test.rb @@ -9,6 +9,8 @@ class Test < ApplicationRecord validates :level, numericality: { only_integer: true, greater_than_or_equal_to: 0 } validates :title, presence: true, uniqueness: { scope: :level } + validates :timer, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + scope :easy_level, -> { where(level: 0..1) } scope :average_level, -> { where(level: 2..4) } diff --git a/app/models/test_passage.rb b/app/models/test_passage.rb index 31bb45e..e2925ce 100644 --- a/app/models/test_passage.rb +++ b/app/models/test_passage.rb @@ -7,11 +7,49 @@ class TestPassage < ApplicationRecord before_validation :set_current_question + + def timer_enabled? + test.timer != 0 + end + + def time_over? + return false unless timer_enabled? && !completed? + return false unless test_completion_time + + Time.current >= test_completion_time + end + + def test_completion_time + return unless test.timer.present? + created_at + (test.timer * 60) + end + + def remaining_time + return unless timer_enabled? + [ test_completion_time - Time.current, 0 ].max.round + end + + def remaining_minutes + return unless remaining_time + (remaining_time / 60).floor + end + + def remaining_seconds + return unless remaining_time + remaining_time % 60 + end + def passed? completed? && test_successful? end def accept!(answer_ids) + if time_over? + self.current_question = nil + save! + return + end + if correct_answer?(answer_ids) self.correct_question += 1 end diff --git a/app/services/gist_question_services.rb b/app/services/gist_question_services.rb index faf00f1..e266c42 100644 --- a/app/services/gist_question_services.rb +++ b/app/services/gist_question_services.rb @@ -1,8 +1,7 @@ class GistQuestionServices - GistResult = Struct.new(:success?, :html_url) - ACCESS_TOKEN = ENV.fetch('GITHUB_ACCESS_GIST_TOKEN') + ACCESS_TOKEN = ENV.fetch("GITHUB_ACCESS_GIST_TOKEN") def initialize(test_passage, user, client = default_client) @test_passage = test_passage @@ -17,7 +16,7 @@ def call if gist.html_url.present? save_gist_in_db!(question: @question.id, gist_url: gist.html_url, user: @user.id) - + GistResult.new(true, gist.html_url) else GistResult.new(false, nil) @@ -30,24 +29,24 @@ def call def save_gist_in_db!(question:, gist_url:, user:) Gist.create!( question_id: question, - gist_url: gist_url, + gist_url: gist_url, user_id: user ) end def gist_params { - description: I18n.t('services.gist.description', test_title: @test.title), + description: I18n.t("services.gist.description", test_title: @test.title), files: { - 'test-guru-question.txt' => { + "test-guru-question.txt" => { content: gist_content } } - } + } end def gist_content - [@question.body, *@question.answers.pluck(:body)].join("\n") + [ @question.body, *@question.answers.pluck(:body) ].join("\n") end def default_client diff --git a/app/views/admin/tests/_form.html.erb b/app/views/admin/tests/_form.html.erb index f244c7e..9197b5a 100644 --- a/app/views/admin/tests/_form.html.erb +++ b/app/views/admin/tests/_form.html.erb @@ -21,6 +21,12 @@ <%= form.select :category_id, Category.all.collect { |p| [ p.title, p.id ] }, { prompt: true }, class: 'form-select' %> +
+ <%= form.label :timer, t('.timer'), class: 'form-label' %> + <%= form.number_field :timer, min: 0, class: 'form-control' %> + <%= t('.zero') %> +
+
<%= form.submit class: 'btn btn-primary' %>
diff --git a/app/views/test_passages/result.html.erb b/app/views/test_passages/result.html.erb index 27b605e..b086a8d 100644 --- a/app/views/test_passages/result.html.erb +++ b/app/views/test_passages/result.html.erb @@ -3,6 +3,15 @@ <%= t('.header', title: @test_passage.test.title) %> + <% if @test_passage.timer_enabled? %> +
+ <%= t('.still_time') %> + + <%= "#{@test_passage.remaining_minutes}:#{@test_passage.remaining_seconds.to_s.rjust(2, '0')}" %> + +
+ <% end %> + <%= success_rate_message(@test_passage) %>

diff --git a/app/views/test_passages/show.html.erb b/app/views/test_passages/show.html.erb index a8dc693..07dcbd3 100644 --- a/app/views/test_passages/show.html.erb +++ b/app/views/test_passages/show.html.erb @@ -2,6 +2,23 @@ <%= t('.header', title: @test_passage.test.title) %> <%= @test_passage.current_question_number %> / <%= @test_passage.test.questions.count %> +<% if @test_passage.timer_enabled? %> +

+ <%= t('.still_time') %> + + <%= "#{@test_passage.remaining_minutes}:#{@test_passage.remaining_seconds.to_s.rjust(2, '0')}" %> + +
+<% end %> + +<% if @test_passage.timer_enabled? %> + +<% end %>
<%= content_tag(:div, "", class: "progress-bar", @@ -23,7 +40,11 @@ <%= @test_passage.current_question.body %>

-<%= form_with url: test_passage_path(@test_passage), method: :put, local: true, class: 'mb-4' do |form| %> +<%= form_with url: test_passage_path(@test_passage), + method: :put, + local: true, + class: 'mb-4', + id: 'test-passage-form' do |form| %>
<%= form.collection_check_boxes :answer_ids, @test_passage.current_question.answers, :id, :body, include_hidden: false do |b| %>
@@ -36,6 +57,8 @@
- <%= form.submit t('helpers.submit.test_passages.next'), class: 'btn btn-primary btn-lg' %> + <%= form.submit t('helpers.submit.test_passages.next'), + class: 'btn btn-primary btn-lg', + id: 'submit-button' %>
<% end %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 8849157..9eb3e1f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -135,6 +135,9 @@ en: success: "Test was successfully update." delete: success: "Test was successfully deleted." + form: + zero: "0 - means there is no timer." + timer: "Timer (minutes)" questions: show: header: "%{test_title} Question" @@ -167,11 +170,13 @@ en: test_passages: badge: "Получена новая награда: %{name_badge}" + times_up: "The test is completed. Time's up!" result: header: "Test %{title} was completed!" + still_time: "There's still time left:" show: header: "Pass the %{title} test" - save_gist: "Save Question in Gist" + save_gist: "Save Question in Gist" test_passages_helper: success_rate_message: diff --git a/config/locales/ru.yml b/config/locales/ru.yml index 8306add..ca18349 100644 --- a/config/locales/ru.yml +++ b/config/locales/ru.yml @@ -135,7 +135,10 @@ ru: update: success: "Тест обнавлен успешно." delete: - success: "Тест успешно удален." + success: "Тест успешно удален." + form: + zero: "0 - означает отсутствие таймерa" + timer: "Таймер (минут)" questions: show: header: "%{test_title} вопрос" @@ -168,11 +171,14 @@ ru: test_passages: badge: "Получена новая награда: %{name_badge}" + times_up: "Тест завершен. Время вышло!" result: header: "Тест %{title} завершен!" + still_time: "Осталось времени:" show: header: "Прохождение теста: %{title}" - save_gist: "Сохранить вопрос в Gist" + save_gist: "Сохранить вопрос в Gist" + still_time: "Осталось времени:" test_passages_helper: success_rate_message: diff --git a/db/migrate/20241221125327_add_index_to_tests.rb b/db/migrate/20241221125327_add_index_to_tests.rb index ed5b938..6071ec3 100644 --- a/db/migrate/20241221125327_add_index_to_tests.rb +++ b/db/migrate/20241221125327_add_index_to_tests.rb @@ -1,5 +1,5 @@ class AddIndexToTests < ActiveRecord::Migration[6.1] def change - add_index :tests, [:title, :level], unique: true + add_index :tests, [ :title, :level ], unique: true end end diff --git a/db/migrate/20250129204714_create_test_passages.rb b/db/migrate/20250129204714_create_test_passages.rb index 38e279f..2eaebb3 100644 --- a/db/migrate/20250129204714_create_test_passages.rb +++ b/db/migrate/20250129204714_create_test_passages.rb @@ -10,4 +10,3 @@ def change end end end - diff --git a/db/migrate/20250212175643_add_devise_to_users.rb b/db/migrate/20250212175643_add_devise_to_users.rb index a24e3c4..c70881b 100644 --- a/db/migrate/20250212175643_add_devise_to_users.rb +++ b/db/migrate/20250212175643_add_devise_to_users.rb @@ -48,8 +48,8 @@ def self.down remove_columns(:users, :encrypted_password, :reset_password_token, :reset_password_sent_at, :remember_created_at, :sign_in_count, :current_sign_in_at, :last_sign_in_at, :current_sign_in_ip, :last_sign_in_ip, :confirmation_token, :confirmed_at, :confirmation_sent_at, :unconfirmed_email) - + add_column :users, :password_digest, :string - change_column_default(:users, :email, nil) + change_column_default(:users, :email, nil) end end diff --git a/db/migrate/20250620184036_add_timer_to_tests.rb b/db/migrate/20250620184036_add_timer_to_tests.rb new file mode 100644 index 0000000..f380ab7 --- /dev/null +++ b/db/migrate/20250620184036_add_timer_to_tests.rb @@ -0,0 +1,5 @@ +class AddTimerToTests < ActiveRecord::Migration[7.2] + def change + add_column :tests, :timer, :integer, null: true + end +end diff --git a/db/seeds.rb b/db/seeds.rb index aa913e1..491cf00 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -12,12 +12,12 @@ ]) tests = Test.create!([ - { title: 'Ruby', level: 1, category_id: categories[0].id, author_id: Admin.first.id }, - { title: 'Ruby on Rails', level: 2, category_id: categories[0].id, author_id: Admin.first.id }, - { title: 'JavaScript', level: 1, category_id: categories[1].id, author_id: Admin.first.id }, - { title: 'Docker', level: 3, category_id: categories[2].id, author_id: Admin.first.id }, - { title: 'HTML & CSS', level: 2, category_id: categories[1].id, author_id: Admin.first.id }, - { title: 'PostgreSQL', level: 3, category_id: categories[2].id, author_id: Admin.first.id } + { title: 'Ruby', level: 1, category_id: categories[0].id, author_id: Admin.first.id, timer: 0 }, + { title: 'Ruby on Rails', level: 2, category_id: categories[0].id, author_id: Admin.first.id, timer: 0 }, + { title: 'JavaScript', level: 1, category_id: categories[1].id, author_id: Admin.first.id, timer: 0 }, + { title: 'Docker', level: 3, category_id: categories[2].id, author_id: Admin.first.id, timer: 0 }, + { title: 'HTML & CSS', level: 2, category_id: categories[1].id, author_id: Admin.first.id, timer: 0 }, + { title: 'PostgreSQL', level: 3, category_id: categories[2].id, author_id: Admin.first.id, timer: 0 } ])