diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js
index 6ce98d5..ddd546a 100644
--- a/app/assets/config/manifest.js
+++ b/app/assets/config/manifest.js
@@ -1,8 +1,4 @@
//= link_tree ../images
//= link_directory ../stylesheets .css
-//= link application.js
-//= link controllers/application.js
-//= link controllers/form_validation_controller.js
-//= link controllers/nested_form_controller.js
-//= link controllers/questions_controller.js
-//= link controllers/index.js
+//= link_tree ../../javascript .js
+//= link_tree ../../../vendor/javascript .js
diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js
deleted file mode 100644
index 75ab71f..0000000
--- a/app/assets/javascripts/application.js
+++ /dev/null
@@ -1,6 +0,0 @@
-//= require jquery
-//= require jquery_ujs
-//= require popper
-//= require turbolinks
-//= require bootstrap-sprockets
-//= require_tree
diff --git a/app/assets/stylesheets/global.scss b/app/assets/stylesheets/global.scss
index c08d32a..30c5886 100644
--- a/app/assets/stylesheets/global.scss
+++ b/app/assets/stylesheets/global.scss
@@ -5,3 +5,6 @@
.hide {
display: none;
}
+.category-title-badge {
+ cursor: pointer;
+}
\ No newline at end of file
diff --git a/app/controllers/admin/badges_controller.rb b/app/controllers/admin/badges_controller.rb
new file mode 100644
index 0000000..8f53759
--- /dev/null
+++ b/app/controllers/admin/badges_controller.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+# app/controllers/admin/badges_controller.rb
+module Admin
+ class BadgesController < ApplicationController
+ before_action :set_badge, only: %i[show edit update destroy]
+ before_action :get_images, only: %i[new edit]
+
+ def index
+ @badges = Badge.all
+ end
+
+ def new
+ @badge = Badge.new
+ end
+
+ def create
+ @badge = Badge.new(badge_params)
+ if @badge.save
+ redirect_to admin_badges_path, notice: t('admin.badges.create.success')
+ else
+ render :new
+ end
+ end
+
+ def edit; end
+
+ def update
+ if @badge.update(badge_params)
+ redirect_to admin_badges_path, notice: t('admin.badges.update.updated')
+ else
+ render :edit
+ end
+ end
+
+ def destroy
+ @badge.destroy
+ redirect_to admin_badges_path, alert: t('admin.badges.destroy.deleted')
+ end
+
+ private
+
+ def get_images
+ @images = Dir.glob(Rails.root.join('public', 'badges', '*')).map { |path| File.basename(path) }
+ end
+
+ def set_badge
+ @badge = Badge.find(params[:id])
+ end
+
+ def badge_params
+ params.require(:badge).permit(:title, :image, :image_url, :rule, :value, :status)
+ end
+ end
+end
diff --git a/app/controllers/admin/tests_controller.rb b/app/controllers/admin/tests_controller.rb
index e75ccc1..1a646ad 100644
--- a/app/controllers/admin/tests_controller.rb
+++ b/app/controllers/admin/tests_controller.rb
@@ -64,6 +64,7 @@ def set_test
end
def test_params
+ params[:test][:status] = params[:test][:status].to_i if params[:test][:status].present?
params.require(:test).permit(:title, :level, :category_id, :status)
end
diff --git a/app/controllers/profile_controller.rb b/app/controllers/profile_controller.rb
new file mode 100644
index 0000000..b6184da
--- /dev/null
+++ b/app/controllers/profile_controller.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+# app/controllers/profiles_controller.rb
+class ProfileController < ApplicationController
+ def show
+ @user = current_user
+ @earned_badges = @user.badges.order(created_at: :desc)
+ end
+end
diff --git a/app/controllers/test_passages_controller.rb b/app/controllers/test_passages_controller.rb
index c95ede6..7018b30 100644
--- a/app/controllers/test_passages_controller.rb
+++ b/app/controllers/test_passages_controller.rb
@@ -12,7 +12,14 @@ def result; end
def update
@test_passage.accept!(params[:answer_ids])
+ @test_passage.check_successful_completed!
if @test_passage.completed?
+ if @test_passage.successful?
+ flash[:notice] = t('test_passages.update.success')
+ BadgeAssigner.new(@test_passage).call
+ else
+ flash[:alert] = t('test_passages.update.failed')
+ end
TestsMailer.completed_test(@test_passage).deliver_now
redirect_to result_test_passage_path(@test_passage)
else
diff --git a/app/helpers/admin/badges_helper.rb b/app/helpers/admin/badges_helper.rb
new file mode 100644
index 0000000..c89bfd8
--- /dev/null
+++ b/app/helpers/admin/badges_helper.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Admin
+ module BadgesHelper
+ def badge_value_text(badge)
+ case badge.rule
+ when 'all_category_tests_passed'
+ badge.value.to_s
+ when 'all_level_tests_passed'
+ t("tests.levels.#{badge.value}")
+ else
+ badge.value.to_s
+ end
+ end
+ end
+end
diff --git a/app/javascript/application.js b/app/javascript/application.js
index 0d7b494..c275ea8 100644
--- a/app/javascript/application.js
+++ b/app/javascript/application.js
@@ -1,3 +1,2 @@
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
-import "@hotwired/turbo-rails"
import "controllers"
diff --git a/app/javascript/controllers/admin/badge_value_controller.js b/app/javascript/controllers/admin/badge_value_controller.js
new file mode 100644
index 0000000..49fe382
--- /dev/null
+++ b/app/javascript/controllers/admin/badge_value_controller.js
@@ -0,0 +1,16 @@
+// app/javascript/controllers/admin/badge_value_controller.js
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static targets = ["rule", "valueField", "template"]
+
+ connect() {
+ this.switchValueField()
+ }
+
+ switchValueField() {
+ const rule = this.ruleTarget.value
+ const template = this.templateTargets.find(t => t.dataset.rule === rule) || this.templateTargets.find(t => t.dataset.rule === 'default')
+ this.valueFieldTarget.innerHTML = template.innerHTML
+ }
+}
\ No newline at end of file
diff --git a/app/javascript/controllers/admin/category_copy_controller.js b/app/javascript/controllers/admin/category_copy_controller.js
new file mode 100644
index 0000000..edb3686
--- /dev/null
+++ b/app/javascript/controllers/admin/category_copy_controller.js
@@ -0,0 +1,19 @@
+import { Controller } from "@hotwired/stimulus"
+
+// Контроллер для копирования значения из badge в input
+export default class extends Controller {
+ static targets = ["input"]
+ static values = {
+ param: String
+ }
+
+ copy(event) {
+ const clickedElement = event.currentTarget
+ const value = clickedElement.dataset.categoryCopyValueParam
+
+ if (this.hasInputTarget && value) {
+ this.inputTarget.value = value
+ this.inputTarget.dispatchEvent(new Event('input')) // важно для реактивности
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/javascript/controllers/admin/image_preview_controller.js b/app/javascript/controllers/admin/image_preview_controller.js
new file mode 100644
index 0000000..4b4b14e
--- /dev/null
+++ b/app/javascript/controllers/admin/image_preview_controller.js
@@ -0,0 +1,16 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static targets = [ "select", "preview" ]
+ connect() {
+ }
+
+ updateImage() {
+ const selectedImage = this.selectTarget.value;
+ if (selectedImage) {
+ this.previewTarget.src = `/badges/${selectedImage}`;
+ } else {
+ this.previewTarget.src = "";
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/javascript/controllers/application.js b/app/javascript/controllers/application.js
deleted file mode 100644
index da97c57..0000000
--- a/app/javascript/controllers/application.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import { Application } from "@hotwired/stimulus"
-import { FlashController } from "flash_controller"
-
-const application = Application.start()
-application.register("flash", FlashController)
-
-// Configure Stimulus development experience
-application.debug = false
-window.Stimulus = application
-
-export { application }
diff --git a/app/javascript/controllers/form_validation_controller.js b/app/javascript/controllers/form_validation_controller.js
deleted file mode 100644
index 480a56b..0000000
--- a/app/javascript/controllers/form_validation_controller.js
+++ /dev/null
@@ -1,35 +0,0 @@
-// app/javascript/controllers/form_validation_controller.js
-import { Controller } from "@hotwired/stimulus"
-
-export default class extends Controller {
- static targets = ["input"]
-
- connect() {
- this.inputTargets.forEach(input => {
- input.addEventListener('input', this.validate.bind(this))
- })
- }
-
- validate(event) {
- const input = event.target
- input.classList.remove('is-invalid', 'is-valid')
-
- if (input.checkValidity()) {
- input.classList.add('is-valid')
- } else {
- input.classList.add('is-invalid')
- }
- }
-
- submit(event) {
- const isValid = this.inputTargets.every(input => input.checkValidity())
- if (!isValid) {
- event.preventDefault()
- this.inputTargets.forEach(input => {
- if (!input.checkValidity()) {
- input.classList.add('is-invalid')
- }
- })
- }
- }
-}
\ No newline at end of file
diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js
index 54ad4ca..9ea5adb 100644
--- a/app/javascript/controllers/index.js
+++ b/app/javascript/controllers/index.js
@@ -1,11 +1,19 @@
-// Import and register all your controllers from the importmap under controllers/*
+import { Application } from "@hotwired/stimulus"
-import { application } from "controllers/application"
+import ImagePreviewController from "./admin/image_preview_controller"
+import BadgeValueController from "./admin/badge_value_controller"
+import CategoryCopyController from "./admin/category_copy_controller"
-// Eager load all controllers defined in the import map under controllers/**/*_controller
-import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
-eagerLoadControllersFrom("controllers", application)
+const application = Application.start()
+
+application.register("image-preview", ImagePreviewController)
+application.register("badge-value", BadgeValueController)
+application.register("category-copy", CategoryCopyController)
+
+
+// Configure Stimulus development experience
+application.debug = true
+window.Stimulus = application
+
+export { application }
-// Lazy load controllers as they appear in the DOM (remember not to preload controllers in import map!)
-// import { lazyLoadControllersFrom } from "@hotwired/stimulus-loading"
-// lazyLoadControllersFrom("controllers", application)
diff --git a/app/javascript/controllers/nested_form_controller.js b/app/javascript/controllers/nested_form_controller.js
deleted file mode 100644
index 466e6ab..0000000
--- a/app/javascript/controllers/nested_form_controller.js
+++ /dev/null
@@ -1,20 +0,0 @@
-import { Controller } from "@hotwired/stimulus"
-
-export default class extends Controller {
- static targets = ["container", "item", "form"]
-
- connect() {
- this.template = this.containerTarget.querySelector("template")
- }
-
- remove(event) {
- const item = event.target.closest("[data-nested-form-target='item']")
- const destroyInput = item.querySelector("input[name*='_destroy']")
- if (destroyInput) {
- destroyInput.value = "1"
- item.style.display = "none"
- } else {
- item.remove()
- }
- }
-}
\ No newline at end of file
diff --git a/app/javascript/controllers/questions_controller.js b/app/javascript/controllers/questions_controller.js
deleted file mode 100644
index 510f37e..0000000
--- a/app/javascript/controllers/questions_controller.js
+++ /dev/null
@@ -1,43 +0,0 @@
-import { Controller } from "@hotwired/stimulus"
-
-export default class extends Controller {
- static targets = ["item", "newForm"]
- static values = { url: String }
-
- connect() {
- console.log("Questions controller connected")
- }
-
- async remove(event) {
- event.preventDefault()
- const item = event.target.closest("[data-questions-target='item']")
- const questionId = item.dataset.id
-
- if (!questionId) {
- item.remove()
- return
- }
-
- try {
- const response = await fetch(`/admin/questions/${questionId}`, {
- method: 'DELETE',
- headers: {
- 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content,
- 'Accept': 'application/json'
- }
- })
-
- if (response.ok) {
- item.remove()
- } else {
- const data = await response.json()
- alert(data.errors || 'Error deleting question')
- }
- } catch (error) {
- console.error('Error:', error)
- alert('Error deleting question')
- }
- }
-
-
-}
\ No newline at end of file
diff --git a/app/models/badge.rb b/app/models/badge.rb
new file mode 100644
index 0000000..ac01769
--- /dev/null
+++ b/app/models/badge.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: badges
+#
+# id :bigint not null, primary key
+# image :string
+# image_url :string
+# rule :string
+# status :integer default("inactive")
+# title :string
+# value :string
+# created_at :datetime not null
+# updated_at :datetime not null
+#
+class Badge < ApplicationRecord
+ enum status: { inactive: 0, active: 1 }
+
+ scope :active, -> { where(status: :active) }
+ scope :inactive, -> { where(status: :inactive) }
+
+ has_many :user_badges, dependent: :destroy
+ has_many :badges, through: :user_badges
+end
diff --git a/app/models/test.rb b/app/models/test.rb
index dbc5e2e..91379b3 100644
--- a/app/models/test.rb
+++ b/app/models/test.rb
@@ -5,6 +5,7 @@
# Table name: tests
#
# id :bigint not null, primary key
+# duration :integer default(0)
# level :integer default("medium"), not null
# status :integer default("draft")
# title :string not null
@@ -40,7 +41,12 @@ class Test < ApplicationRecord
scope :medium, -> { where(level: :medium) }
scope :hard, -> { where(level: :hard) }
scope :ready, -> { where(status: 1) }
- scope :by_category, ->(category_title) { joins(:category).where(categories: { title: category_title }) }
+ scope :by_category, lambda { |category_title|
+ return self unless category_title.present?
+
+ sanitized_title = ActiveRecord::Base.sanitize_sql_like(category_title)
+ joins(:category).where(Category.arel_table[:title].matches(sanitized_title))
+ }
def self.titles_by_category(category_title)
by_category(category_title).pluck(:title)
diff --git a/app/models/test_passage.rb b/app/models/test_passage.rb
index 4f721eb..b2c9ab7 100644
--- a/app/models/test_passage.rb
+++ b/app/models/test_passage.rb
@@ -6,6 +6,7 @@
#
# id :bigint not null, primary key
# correct_questions :integer default(0)
+# successful :boolean default(FALSE)
# created_at :datetime not null
# updated_at :datetime not null
# current_question_id :bigint
@@ -31,7 +32,16 @@ class TestPassage < ApplicationRecord
belongs_to :test
belongs_to :current_question, class_name: 'Question', optional: true
- before_validation :before_validation_set_first_question, on: %i[create update]
+ delegate :category, to: :test
+
+ before_validation :set_first_or_next_question, on: %i[create update]
+
+ scope :successful, -> { where(successful: true) }
+ scope :completed, -> { where(current_question: nil) }
+
+ scope :attempts_count, lambda { |user_id, test_id|
+ completed.where(user_id: user_id, test_id: test_id)
+ }
def accept!(answer_ids)
self.correct_questions += 1 if correct_answer?(answer_ids)
@@ -59,9 +69,17 @@ def current_question_number
test.questions.index(current_question) + 1
end
+ def check_successful_completed!
+ update_column(:successful, true) if completed? && successful_passage?
+ end
+
+ def successful?
+ successful
+ end
+
private
- def before_validation_set_first_question
+ def set_first_or_next_question
if current_question.nil?
self.current_question = test.questions.first if test.present?
else
diff --git a/app/models/user.rb b/app/models/user.rb
index 26722ad..1686e88 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -47,6 +47,8 @@ class User < ApplicationRecord
validates :email, uniqueness: { case_sensitive: false }, format: URI::MailTo::EMAIL_REGEXP
validates :username, :email, presence: true
+ has_many :user_badges, dependent: :destroy
+ has_many :badges, -> { distinct }, through: :user_badges
def show_tests(level)
tests.where(level:)
diff --git a/app/models/user_badge.rb b/app/models/user_badge.rb
new file mode 100644
index 0000000..4917dfd
--- /dev/null
+++ b/app/models/user_badge.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: user_badges
+#
+# id :bigint not null, primary key
+# created_at :datetime not null
+# updated_at :datetime not null
+# badge_id :bigint not null
+# user_id :bigint not null
+#
+# Indexes
+#
+# index_user_badges_on_badge_id (badge_id)
+# index_user_badges_on_user_id (user_id)
+# index_user_badges_on_user_id_and_badge_id (user_id,badge_id) UNIQUE
+#
+# Foreign Keys
+#
+# fk_rails_... (badge_id => badges.id)
+# fk_rails_... (user_id => users.id)
+#
+class UserBadge < ApplicationRecord
+ belongs_to :user
+ belongs_to :badge
+
+ validates :user, uniqueness: { scope: :badge }
+end
diff --git a/app/services/badge_assigner.rb b/app/services/badge_assigner.rb
new file mode 100644
index 0000000..436e3e6
--- /dev/null
+++ b/app/services/badge_assigner.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+# app/services/badge_assigner.rb
+class BadgeAssigner
+ RULES = %w[all_category_tests_passed first_attempt_success all_level_tests_passed].freeze
+ def initialize(test_passage)
+ @test_passage = test_passage
+ @user = test_passage.user
+ end
+
+ def call
+ Badge.where(rule: RULES).active.each do |badge|
+ assign_badge!(badge) if rule_satisfied?(badge.rule, badge.value)
+ end
+ end
+
+ private
+
+ def assign_badge!(badge)
+ @user.badges << badge unless @user.badges.exists?(id: badge.id)
+ end
+
+ def rule_satisfied?(rule, value)
+ send("#{rule}?", value)
+ end
+
+ def all_category_tests_passed?(category_title)
+ test_ids = Test.ready.by_category(category_title).pluck(:id)
+ check_successful_tests_by_ids(test_ids)
+ end
+
+ def first_attempt_success?(_value)
+ @test_passage.successful_passage? && TestPassage.attempts_count(@test_passage.user_id,
+ @test_passage.test_id).count == 1
+ end
+
+ def all_level_tests_passed?(level)
+ test_ids = Test.ready.where(level: level).pluck(:id)
+ check_successful_tests_by_ids(test_ids)
+ end
+
+ def check_successful_tests_by_ids(test_ids)
+ completed_ids = @user.test_passages.where(test_id: test_ids).successful.pluck(:test_id)
+ (test_ids - completed_ids).empty?
+ end
+end
diff --git a/app/views/admin/badges/_form.html.erb b/app/views/admin/badges/_form.html.erb
new file mode 100644
index 0000000..af8953b
--- /dev/null
+++ b/app/views/admin/badges/_form.html.erb
@@ -0,0 +1,73 @@
+
+<%= form_with(model: [:admin, badge], local: true) do |form| %>
+
+ <%= form.label :title, t('admin.badges.form.title'), class: 'form-label' %>
+ <%= form.text_field :title, class: 'form-control', required: true %>
+
+
+
+ <%= form.label :image_url, t('admin.badges.form.image_url'), class: 'form-label' %>
+ <%= form.url_field :image_url, class: 'form-control' %>
+
+
+
+ <%= form.label :rule, t('admin.badges.form.rule'), class: 'form-label' %>
+ <%= form.select :rule,
+ BadgeAssigner::RULES.map { |key| [t("admin.badges.rules.#{key}"), key] },
+ { include_blank: true },
+ { class: 'form-select', required: true, data: { badge_value_target: "rule", action: "change->badge-value#switchValueField" } } %>
+
+
+ <% case badge.rule %>
+ <% when "all_category_tests_passed" %>
+ <%= render partial: "admin/badges/value_fields/category_select", locals: { form: form } %>
+ <% when "all_level_tests_passed" %>
+ <%= render partial: "admin/badges/value_fields/level_select", locals: { form: form } %>
+ <% else %>
+ <%= render partial: "admin/badges/value_fields/text_field", locals: { form: form } %>
+ <% end %>
+
+
+
+ <%= render partial: "admin/badges/value_fields/category_select", locals: { form: form } %>
+
+
+ <%= render partial: "admin/badges/value_fields/level_select", locals: { form: form } %>
+
+
+ <%= render partial: "admin/badges/value_fields/text_field", locals: { form: form } %>
+
+
+
+
+ <%= form.label :image, t('admin.badges.form.image'), class: 'form-label' %>
+ <%= form.select :image, @images.map { |img| [img, img] }, {include_blank: t('admin.labels.select_image')}, {class: 'form-select', data: { action: "change->image-preview#updateImage", "image-preview-target": "select" }} %>
+
+
+
+
+
+
+ <%= form.check_box :status, {data: { controller: "switch", action: "click->switch#toggle" }, class: "form-check-input"},'active', 'inactive' %>
+ <%= form.label :status, t("admin.badges.form.#{badge.status ? 'active' : 'inactive'}"), class: "form-check-label", id: "switchLabel" %>
+
+
+
+ <%= form.submit t('admin.labels.save'), class: 'btn btn-primary me-2' %>
+ <%= link_to t('admin.labels.cancel'), admin_badges_path, class: 'btn btn-secondary' %>
+
+<% end %>
+
+
\ No newline at end of file
diff --git a/app/views/admin/badges/edit.html.erb b/app/views/admin/badges/edit.html.erb
new file mode 100644
index 0000000..028d2fa
--- /dev/null
+++ b/app/views/admin/badges/edit.html.erb
@@ -0,0 +1,6 @@
+
+<%= t('admin.badges.edit.header') %>
+
+<%= render 'form', badge: @badge %>
+
+<%= link_to t('shared.actions.back'), admin_badges_path, class: 'btn btn-secondary' %>
\ No newline at end of file
diff --git a/app/views/admin/badges/index.html.erb b/app/views/admin/badges/index.html.erb
new file mode 100644
index 0000000..6b8796b
--- /dev/null
+++ b/app/views/admin/badges/index.html.erb
@@ -0,0 +1,34 @@
+
+<%= t('admin.badges.index.header') %>
+
+<%= link_to t('admin.badges.new.header'), new_admin_badge_path, class: 'btn btn-primary' %>
+
+
+
+
+ <%= t('admin.badges.index.title') %>
+ <%= t('admin.badges.index.image_url') %>
+ <%= t('admin.badges.index.rule') %>
+ <%= t('admin.badges.index.value') %>
+
+
+
+
+
+ <% @badges.each do |badge| %>
+
+ <%= badge.title %>
+ <%= image_tag("/badges/#{badge.image}", height: '50') %>
+ <%= t("admin.badges.rules.#{badge.rule}") %>
+ <%= badge_value_text(badge) %>
+ <%= link_to t('admin.badges.index.edit'), edit_admin_badge_path(badge), class: 'btn btn-warning' %>
+ <%= link_to t('admin.badges.index.destroy'), admin_badge_path(badge),
+ method: :delete, data: { confirm: t('shared.confirmation.are_you_sure') }, class: 'btn btn-danger' %>
+
+ <% end %>
+
+
+
+
+
+
diff --git a/app/views/admin/badges/new.html.erb b/app/views/admin/badges/new.html.erb
new file mode 100644
index 0000000..9b9447b
--- /dev/null
+++ b/app/views/admin/badges/new.html.erb
@@ -0,0 +1,6 @@
+
+<%= t('admin.badges.new.header') %>
+
+<%= render 'form', badge: @badge %>
+
+<%= link_to t('shared.actions.back'), admin_badges_path, class: 'btn btn-secondary' %>
\ No newline at end of file
diff --git a/app/views/admin/badges/value_fields/_category_select.html.erb b/app/views/admin/badges/value_fields/_category_select.html.erb
new file mode 100644
index 0000000..06bd5a9
--- /dev/null
+++ b/app/views/admin/badges/value_fields/_category_select.html.erb
@@ -0,0 +1,19 @@
+
+
+
+
+ <% Category.sort_by_title.pluck(:title).each do |category_title| %>
+
+ <%= category_title %>
+
+ <% end %>
+
+ <%= form.text_field :value,
+ class: 'form-control mt-3',
+ data: { category_copy_target: "input" } %>
+
\ No newline at end of file
diff --git a/app/views/admin/badges/value_fields/_level_select.html.erb b/app/views/admin/badges/value_fields/_level_select.html.erb
new file mode 100644
index 0000000..c30592c
--- /dev/null
+++ b/app/views/admin/badges/value_fields/_level_select.html.erb
@@ -0,0 +1,5 @@
+
+<%= form.select :value,
+ Test.levels.map { |k, v| [t("tests.levels.#{k}"), k] },
+ { prompt: t('admin.tests.select_level') },
+ class: "form-select" %>
\ No newline at end of file
diff --git a/app/views/admin/badges/value_fields/_text_field.html.erb b/app/views/admin/badges/value_fields/_text_field.html.erb
new file mode 100644
index 0000000..29bbfb4
--- /dev/null
+++ b/app/views/admin/badges/value_fields/_text_field.html.erb
@@ -0,0 +1,2 @@
+
+<%= form.text_field :value, class: 'form-control' %>
\ No newline at end of file
diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb
index 2df87e1..dfc3a6b 100644
--- a/app/views/layouts/admin.html.erb
+++ b/app/views/layouts/admin.html.erb
@@ -6,6 +6,7 @@
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
+ <%= javascript_include_tag "application", "data-turbolinks-track": "reload" %>
<%= javascript_importmap_tags %>
@@ -30,6 +31,9 @@
<%= link_to t('admin.labels.tests'), admin_tests_path, class: "nav-link" %>
+
+ <%= link_to t('admin.labels.badges'), admin_badges_path, class: "nav-link" %>
+
<%= link_to t('admin.labels.users'), admin_users_path, class: "nav-link" %>
@@ -48,14 +52,7 @@
- <% flash.each do |type, msg| %>
-
- <%= msg %>
-
-
- <% end %>
-
+ <%= render 'shared/flash' %>
<%= yield %>
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index 45bcd68..8b56055 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -10,6 +10,7 @@
<%= stylesheet_link_tag "application", media: "all", "data-turbolinks-track": "reload" %>
<%= javascript_include_tag "application", "data-turbolinks-track": "reload" %>
+ <%= javascript_importmap_tags %>
diff --git a/app/views/profile/show.html.erb b/app/views/profile/show.html.erb
new file mode 100644
index 0000000..74fa1c9
--- /dev/null
+++ b/app/views/profile/show.html.erb
@@ -0,0 +1,19 @@
+
+<%= t('profile.show.header') %>
+
+
+
+
<%= t('profile.show.earned_badges') %>
+ <% if @earned_badges.any? %>
+
+ <% @earned_badges.each do |badge| %>
+
+ <%= image_tag("/badges/#{badge.image}", height: '50', alt: badge.title) %> <%= badge.title %>
+
+ <% end %>
+
+ <% else %>
+
<%= t('profile.show.no_earned_badges') %>
+ <% end %>
+
+
\ No newline at end of file
diff --git a/app/views/shared/_nav.html.erb b/app/views/shared/_nav.html.erb
index b4a627b..bf4e1c4 100644
--- a/app/views/shared/_nav.html.erb
+++ b/app/views/shared/_nav.html.erb
@@ -5,10 +5,11 @@
<% if user_signed_in? %>
Привет, <%= current_user.username %> Guru |
- <% if current_user.is_a?(Admin) %>
- <%= link_to t("admin_alexey"), admin_tests_path, class: 'text-decoration-none me-2 ms-2' %> |
+ <% if current_user.admin? %>
+ <%= link_to t("admin_alexey"), admin_root_path, class: 'text-decoration-none me-2 ms-2' %> |
<% end %>
<%= link_to t('feedbacks.feedback'), new_feedback_path, class: 'text-decoration-none me-2 ms-2 '%> |
+ <%= link_to t('profile.show.header'), profile_path(current_user), class: 'text-decoration-none me-2 ms-2 '%> |
<%= link_to t('logout'), destroy_user_session_path, method: :delete, data: { confirm: 'Are you sure?' },
class: 'text-decoration-none ms-2' %>
diff --git a/bin/importmap b/bin/importmap
index d423864..36502ab 100755
--- a/bin/importmap
+++ b/bin/importmap
@@ -1,5 +1,4 @@
#!/usr/bin/env ruby
-# frozen_string_literal: true
-require_relative '../config/application'
-require 'importmap/commands'
+require_relative "../config/application"
+require "importmap/commands"
diff --git a/config/importmap.rb b/config/importmap.rb
index b57e7be..9cbf71f 100644
--- a/config/importmap.rb
+++ b/config/importmap.rb
@@ -3,7 +3,6 @@
# Pin npm packages by running ./bin/importmap
pin 'application', preload: true
-pin '@hotwired/turbo-rails', to: 'turbo.min.js', preload: true
pin '@hotwired/stimulus', to: 'stimulus.min.js', preload: true
pin '@hotwired/stimulus-loading', to: 'stimulus-loading.js', preload: true
pin_all_from 'app/javascript/controllers', under: 'controllers'
diff --git a/config/locales/en.admin.yml b/config/locales/en.admin.yml
index c441a45..a07b8d7 100644
--- a/config/locales/en.admin.yml
+++ b/config/locales/en.admin.yml
@@ -8,6 +8,7 @@ en:
tests: "Tests"
users: "Users"
gists: "Gists"
+ badges: "Badges"
logout: "Logout"
unauthorized: "You are not authorized to access this page"
view_all: "View All"
@@ -66,4 +67,11 @@ en:
questions:
body: "Question Text"
- new: "New Question"
\ No newline at end of file
+ new: "New Question"
+ badges:
+ form:
+ title: "Title"
+ image_url: "Image URL"
+ rule: "Rule"
+ active: "Active"
+ inactive: "Inactive"
\ No newline at end of file
diff --git a/config/locales/ru.admin.yml b/config/locales/ru.admin.yml
index 9693aff..26c0fcd 100644
--- a/config/locales/ru.admin.yml
+++ b/config/locales/ru.admin.yml
@@ -9,6 +9,7 @@ ru:
tests: "Тесты"
users: "Пользователи"
gists: "Гисты"
+ badges: "Награды"
logout: "Выйти"
unauthorized: "У вас нет прав для доступа к этой странице"
view_all: "Посмотреть все"
@@ -43,6 +44,7 @@ ru:
status_updated: "Статус теста успешно обновлен"
published: "Опубликован"
select_category: "Выберите категорию"
+ select_level: "Выберите уровень"
not_found: "Тест не найден"
creator: "Создатель"
@@ -74,4 +76,29 @@ ru:
answers:
title: "Ответы"
correct: "Правильный"
- body: 'Ответ'
\ No newline at end of file
+ body: 'Ответ'
+ badges:
+ rules:
+ first_attempt_success: "Тест пройден с первой попытки"
+ all_level_tests_passed: "Пройдены все тесты уровня"
+ all_category_tests_passed: "Пройдены все тесты в категории"
+ index:
+ header: "Управление бейджами"
+ title: "Название"
+ image_url: "Ссылка на изображение"
+ rule: "Правило"
+ value: "Значение"
+ edit: "Редактировать"
+ destroy: "Удалить"
+ new:
+ header: "Добавить новый бейдж"
+ form:
+ title: "Название"
+ image_url: "Ссылка на изображение"
+ rule: "Правило"
+ active: "Активный"
+ inactive: "Неактивный"
+ update:
+ updated: "Награда успешна обновлена"
+ create:
+ success: "Награда успешна добавлена"
\ No newline at end of file
diff --git a/config/locales/ru.yml b/config/locales/ru.yml
index 51bfe38..5a27b79 100644
--- a/config/locales/ru.yml
+++ b/config/locales/ru.yml
@@ -69,6 +69,11 @@ ru:
success: "Сообщение успешно отправлено!"
feedback: "Связаться"
+ profile:
+ show:
+ header: "Личный кабинет"
+ earned_badges: "Заработанные бейджи:"
+ no_earned_badges: "У вас ещё нет заработанных бейджей."
admin_:
tests:
new:
diff --git a/config/routes.rb b/config/routes.rb
index 177d7a1..bd1d8a0 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -21,15 +21,17 @@
resources :gists, only: :create
end
end
+ resources :profile, only: %i[show]
namespace :admin do
root to: 'dashboard#index'
resources :categories
resources :users
resources :gists, only: :index
+ resources :badges, only: %i[index new create edit update destroy]
resources :tests do
- patch :update_status, on: :member
+ get :update_status, on: :member
resources :questions # , shallow: true, except: :index do
resources :answers # , shallow: true, except: :index
diff --git a/db/migrate/20250406193420_create_badges.rb b/db/migrate/20250406193420_create_badges.rb
new file mode 100644
index 0000000..45ffd01
--- /dev/null
+++ b/db/migrate/20250406193420_create_badges.rb
@@ -0,0 +1,14 @@
+class CreateBadges < ActiveRecord::Migration[7.0]
+ def change
+ create_table :badges do |t|
+ t.string :title
+ t.string :image_url
+ t.string :image
+ t.string :rule
+ t.string :value
+ t.integer :status, default: 0
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20250407173028_user_badges.rb b/db/migrate/20250407173028_user_badges.rb
new file mode 100644
index 0000000..c53dd5d
--- /dev/null
+++ b/db/migrate/20250407173028_user_badges.rb
@@ -0,0 +1,12 @@
+class UserBadges < ActiveRecord::Migration[7.0]
+ def change
+ create_table :user_badges do |t|
+ t.references :user, null: false, foreign_key: true
+ t.references :badge, null: false, foreign_key: true
+
+ t.timestamps
+ end
+
+ add_index :user_badges, %i[user_id badge_id], unique: true
+ end
+end
diff --git a/db/migrate/20250422102300_add_successful_to_test_passage.rb b/db/migrate/20250422102300_add_successful_to_test_passage.rb
new file mode 100644
index 0000000..48c9785
--- /dev/null
+++ b/db/migrate/20250422102300_add_successful_to_test_passage.rb
@@ -0,0 +1,5 @@
+class AddSuccessfulToTestPassage < ActiveRecord::Migration[7.0]
+ def change
+ add_column :test_passages, :successful, :boolean
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 5b05dd6..b563b16 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.0].define(version: 2025_03_28_082030) do
+ActiveRecord::Schema[7.0].define(version: 2025_04_22_102300) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -23,6 +23,17 @@
t.index ["question_id"], name: "index_answers_on_question_id"
end
+ create_table "badges", force: :cascade do |t|
+ t.string "title"
+ t.string "image_url"
+ t.string "image"
+ t.string "rule"
+ t.string "value"
+ t.integer "status", default: 0
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ end
+
create_table "categories", force: :cascade do |t|
t.string "title", null: false
t.datetime "created_at", null: false
@@ -64,6 +75,7 @@
t.integer "correct_questions", default: 0
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
+ t.boolean "successful", default: false
t.index ["current_question_id"], name: "index_test_passages_on_current_question_id"
t.index ["test_id"], name: "index_test_passages_on_test_id"
t.index ["user_id"], name: "index_test_passages_on_user_id"
@@ -82,6 +94,16 @@
t.index ["title", "level", "category_id"], name: "index_tests_on_title_and_level_and_category_id", unique: true
end
+ create_table "user_badges", force: :cascade do |t|
+ t.bigint "user_id", null: false
+ t.bigint "badge_id", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["badge_id"], name: "index_user_badges_on_badge_id"
+ t.index ["user_id", "badge_id"], name: "index_user_badges_on_user_id_and_badge_id", unique: true
+ t.index ["user_id"], name: "index_user_badges_on_user_id"
+ end
+
create_table "users", force: :cascade do |t|
t.string "email", default: "", null: false
t.boolean "admin", default: false, null: false
@@ -117,4 +139,6 @@
add_foreign_key "test_passages", "users"
add_foreign_key "tests", "categories"
add_foreign_key "tests", "users", column: "creator_id"
+ add_foreign_key "user_badges", "badges"
+ add_foreign_key "user_badges", "users"
end
diff --git a/db/seeds.rb b/db/seeds.rb
index d991d71..99fda46 100644
--- a/db/seeds.rb
+++ b/db/seeds.rb
@@ -14,6 +14,8 @@
Test.destroy_all
Question.destroy_all
Answer.destroy_all
+UserBadge.destroy_all
+Badge.destroy_all
admin = User.create!(first_name: 'Alexey', last_name: 'Kasabutsky', username: 'bobcop', email: 'amsak@yandex.by', admin: true,
password: 'zse4321')
@@ -435,4 +437,29 @@ def create_questions_and_answers(test, questions_data)
create_questions_and_answers(test_11_medium, questions_11_medium)
create_questions_and_answers(test_11_hard, questions_11_hard)
+Badge.create!(
+ [
+ {
+ title: 'Пройдены все тесты категории "Информатика 9кл."',
+ rule: 'all_category_tests_passed',
+ image: 'trophy.png',
+ status: :active,
+ value: Category.first.title
+ },
+ {
+ title: 'Тест пройден с 1й попытки',
+ rule: 'first_attempt_success',
+ image: 'first_place_ribbon.png',
+ status: :active
+ },
+ {
+ title: 'Пройдены все тесты уровня "Легкий"',
+ rule: 'all_level_tests_passed',
+ image: 'graduation-cap.png',
+ status: :active,
+ value: '1'
+ }
+ ]
+)
+
puts 'Seeds успешно созданы!'
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..e61ff11
--- /dev/null
+++ b/package.json
@@ -0,0 +1,5 @@
+{
+ "dependencies": {
+ "@hotwired/stimulus": "^3.2.2"
+ }
+}
diff --git a/public/badges/certificate.png b/public/badges/certificate.png
new file mode 100644
index 0000000..f82ba0d
Binary files /dev/null and b/public/badges/certificate.png differ
diff --git a/public/badges/document.png b/public/badges/document.png
new file mode 100644
index 0000000..4986744
Binary files /dev/null and b/public/badges/document.png differ
diff --git a/public/badges/first_place_ribbon.png b/public/badges/first_place_ribbon.png
new file mode 100644
index 0000000..bcf3c17
Binary files /dev/null and b/public/badges/first_place_ribbon.png differ
diff --git a/public/badges/glowing_star.png b/public/badges/glowing_star.png
new file mode 100644
index 0000000..bf72b20
Binary files /dev/null and b/public/badges/glowing_star.png differ
diff --git a/public/badges/golden_seal.png b/public/badges/golden_seal.png
new file mode 100644
index 0000000..5a36337
Binary files /dev/null and b/public/badges/golden_seal.png differ
diff --git a/public/badges/graduation-cap.png b/public/badges/graduation-cap.png
new file mode 100644
index 0000000..6958bae
Binary files /dev/null and b/public/badges/graduation-cap.png differ
diff --git a/public/badges/knowledge_book.png b/public/badges/knowledge_book.png
new file mode 100644
index 0000000..f3d4277
Binary files /dev/null and b/public/badges/knowledge_book.png differ
diff --git a/public/badges/lightbulb.png b/public/badges/lightbulb.png
new file mode 100644
index 0000000..5adb2e2
Binary files /dev/null and b/public/badges/lightbulb.png differ
diff --git a/public/badges/trophy.png b/public/badges/trophy.png
new file mode 100644
index 0000000..9880868
Binary files /dev/null and b/public/badges/trophy.png differ
diff --git a/test/controllers/admin/badges_controller_test.rb b/test/controllers/admin/badges_controller_test.rb
new file mode 100644
index 0000000..969d182
--- /dev/null
+++ b/test/controllers/admin/badges_controller_test.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require 'test_helper'
+
+module Admin
+ class BadgesControllerTest < ActionDispatch::IntegrationTest
+ # test "the truth" do
+ # assert true
+ # end
+ end
+end
diff --git a/test/fixtures/badges.yml b/test/fixtures/badges.yml
new file mode 100644
index 0000000..ada3d0a
--- /dev/null
+++ b/test/fixtures/badges.yml
@@ -0,0 +1,24 @@
+# == Schema Information
+#
+# Table name: badges
+#
+# id :bigint not null, primary key
+# image :string
+# image_url :string
+# rule :string
+# status :integer default("inactive")
+# title :string
+# value :string
+# created_at :datetime not null
+# updated_at :datetime not null
+#
+
+one:
+ title: MyString
+ image_url: MyString
+ rule: MyString
+
+two:
+ title: MyString
+ image_url: MyString
+ rule: MyString
diff --git a/test/fixtures/test_passages.yml b/test/fixtures/test_passages.yml
index 1c85121..fd97991 100644
--- a/test/fixtures/test_passages.yml
+++ b/test/fixtures/test_passages.yml
@@ -4,6 +4,7 @@
#
# id :bigint not null, primary key
# correct_questions :integer default(0)
+# successful :boolean default(FALSE)
# created_at :datetime not null
# updated_at :datetime not null
# current_question_id :bigint
diff --git a/test/models/badge_test.rb b/test/models/badge_test.rb
new file mode 100644
index 0000000..50a236d
--- /dev/null
+++ b/test/models/badge_test.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: badges
+#
+# id :bigint not null, primary key
+# image :string
+# image_url :string
+# rule :string
+# status :integer default("inactive")
+# title :string
+# value :string
+# created_at :datetime not null
+# updated_at :datetime not null
+#
+require 'test_helper'
+
+class BadgeTest < ActiveSupport::TestCase
+ # test "the truth" do
+ # assert true
+ # end
+end
diff --git a/test/models/test_passage_test.rb b/test/models/test_passage_test.rb
index 8a8053d..b28dcee 100644
--- a/test/models/test_passage_test.rb
+++ b/test/models/test_passage_test.rb
@@ -6,6 +6,7 @@
#
# id :bigint not null, primary key
# correct_questions :integer default(0)
+# successful :boolean default(FALSE)
# created_at :datetime not null
# updated_at :datetime not null
# current_question_id :bigint