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 %> +
+ + + + +
+ +
+ <%= 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' %> + + + + + + + + + + + + + + <% @badges.each do |badge| %> + + + + + + + + + <% end %> + +
<%= t('admin.badges.index.title') %><%= t('admin.badges.index.image_url') %><%= t('admin.badges.index.rule') %><%= t('admin.badges.index.value') %>
<%= 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' %>
+ + + + 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 @@ + @@ -48,14 +52,7 @@
- <% flash.each do |type, 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? %> + + <% 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? %> 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