diff --git a/app/assets/stylesheets/_rating.scss b/app/assets/stylesheets/_rating.scss
new file mode 100644
index 00000000..63ff73c2
--- /dev/null
+++ b/app/assets/stylesheets/_rating.scss
@@ -0,0 +1,26 @@
+.rating-container {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ width: 100%;
+ gap: 12px;
+}
+
+.rating-item {
+ display: flex;
+ align-items: center;
+}
+
+.interactive-star {
+ transition: transform 0.25s ease;
+
+ &:hover {
+ transform: scale(1.3);
+ }
+}
+
+.login-container {
+ display: flex;
+ gap: 4px;
+ align-items: center;
+}
diff --git a/app/controllers/reviews_controller.rb b/app/controllers/reviews_controller.rb
new file mode 100644
index 00000000..e1a25a9d
--- /dev/null
+++ b/app/controllers/reviews_controller.rb
@@ -0,0 +1,28 @@
+class ReviewsController < ApplicationController
+ before_action :authenticate_user!
+ before_action :set_review, only: [:update, :destroy]
+
+ def create
+ @review = current_user.reviews.create!(subject_id: params[:subject_id], rating: params[:rating])
+
+ redirect_to subject_path(@review.subject)
+ end
+
+ def update
+ @review.update!(rating: params[:rating])
+
+ redirect_to subject_path(@review.subject)
+ end
+
+ def destroy
+ @review.destroy!
+
+ redirect_to subject_path(@review.subject)
+ end
+
+ private
+
+ def set_review
+ @review = current_user.reviews.find(params[:id])
+ end
+end
diff --git a/app/controllers/subjects_controller.rb b/app/controllers/subjects_controller.rb
index 2de04ccc..2511eda0 100644
--- a/app/controllers/subjects_controller.rb
+++ b/app/controllers/subjects_controller.rb
@@ -7,6 +7,8 @@ def index
end
def show
+ @user_review = current_user.reviews.find_by(subject:) if current_user
+
respond_to do |format|
format.html { subject }
end
diff --git a/app/models/review.rb b/app/models/review.rb
new file mode 100644
index 00000000..1067014b
--- /dev/null
+++ b/app/models/review.rb
@@ -0,0 +1,16 @@
+class Review < ApplicationRecord
+ belongs_to :user
+ belongs_to :subject
+
+ validates :user_id, uniqueness: { scope: :subject_id, message: "You can only review a subject once." }
+ validates :rating, presence: true, inclusion: { in: 1..5 }
+
+ after_commit :update_subject_rating, if: :saved_change_to_rating?
+ after_destroy :update_subject_rating
+
+ private
+
+ def update_subject_rating
+ subject.update_rating
+ end
+end
diff --git a/app/models/subject.rb b/app/models/subject.rb
index f825715c..4ce17120 100644
--- a/app/models/subject.rb
+++ b/app/models/subject.rb
@@ -2,6 +2,7 @@ class Subject < ApplicationRecord
has_one :course, -> { where is_exam: false }, class_name: 'Approvable', dependent: :destroy, inverse_of: :subject
has_one :exam, -> { where is_exam: true }, class_name: 'Approvable', dependent: :destroy, inverse_of: :subject
belongs_to :group, class_name: 'SubjectGroup', optional: true
+ has_many :reviews, dependent: :destroy
validates :name, presence: true
validates :credits, presence: true
@@ -47,5 +48,9 @@ def hidden_by_default?
revalid? || inactive? || outside_montevideo? || extension_module?
end
+ def update_rating
+ update(average_rating: reviews.average(:rating))
+ end
+
delegate :available?, to: :course
end
diff --git a/app/models/user.rb b/app/models/user.rb
index c9f7ad73..d8eb2678 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -5,6 +5,8 @@ class User < ApplicationRecord
serialize :approvals, type: Array, coder: YAML
+ has_many :reviews, dependent: :destroy
+
def self.from_omniauth(auth, cookie)
# check that user with same email exists
existing_user = User.find_by(email: auth.info.email)
diff --git a/app/views/subjects/_rating.html.erb b/app/views/subjects/_rating.html.erb
new file mode 100644
index 00000000..d6626b53
--- /dev/null
+++ b/app/views/subjects/_rating.html.erb
@@ -0,0 +1,33 @@
+
diff --git a/app/views/subjects/show.html.erb b/app/views/subjects/show.html.erb
index ea003271..3720dfae 100644
--- a/app/views/subjects/show.html.erb
+++ b/app/views/subjects/show.html.erb
@@ -19,6 +19,8 @@
Grupo: Desconocido
<% end %>
+
+ <%= render 'rating' %>
diff --git a/config/routes.rb b/config/routes.rb
index 669cd17c..ca9c080a 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -29,4 +29,5 @@
resource :user_onboardings, only: :update
resources :current_optional_subjects, only: :index
+ resources :reviews, only: [:create, :update, :destroy]
end
diff --git a/db/migrate/20240305231624_create_reviews.rb b/db/migrate/20240305231624_create_reviews.rb
new file mode 100644
index 00000000..5f8d8566
--- /dev/null
+++ b/db/migrate/20240305231624_create_reviews.rb
@@ -0,0 +1,13 @@
+class CreateReviews < ActiveRecord::Migration[7.1]
+ def change
+ create_table :reviews do |t|
+ t.references :user, null: false, foreign_key: true
+ t.references :subject, null: false, foreign_key: true
+ t.integer :rating
+
+ t.timestamps
+ end
+
+ add_index :reviews, [:user_id, :subject_id], unique: true
+ end
+end
diff --git a/db/migrate/20240316053425_add_average_rating_to_subjects.rb b/db/migrate/20240316053425_add_average_rating_to_subjects.rb
new file mode 100644
index 00000000..eb0c69fe
--- /dev/null
+++ b/db/migrate/20240316053425_add_average_rating_to_subjects.rb
@@ -0,0 +1,5 @@
+class AddAverageRatingToSubjects < ActiveRecord::Migration[7.1]
+ def change
+ add_column :subjects, :average_rating, :float
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index c4d032ec..a247daac 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[8.0].define(version: 2024_02_22_002214) do
+ActiveRecord::Schema[8.0].define(version: 2024_03_16_053425) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
enable_extension "unaccent"
@@ -33,6 +33,17 @@
t.integer "amount_of_subjects_needed"
end
+ create_table "reviews", force: :cascade do |t|
+ t.bigint "user_id", null: false
+ t.bigint "subject_id", null: false
+ t.integer "rating"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["subject_id"], name: "index_reviews_on_subject_id"
+ t.index ["user_id", "subject_id"], name: "index_reviews_on_user_id_and_subject_id", unique: true
+ t.index ["user_id"], name: "index_reviews_on_user_id"
+ end
+
create_table "subject_groups", force: :cascade do |t|
t.string "name", null: false
t.datetime "created_at", precision: nil, null: false
@@ -54,6 +65,7 @@
t.string "code"
t.string "category", default: "optional"
t.boolean "current_optional_subject", default: false
+ t.float "average_rating"
t.index ["code"], name: "index_subjects_on_code", unique: true
end
@@ -72,4 +84,7 @@
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
end
+
+ add_foreign_key "reviews", "subjects"
+ add_foreign_key "reviews", "users"
end
diff --git a/spec/controllers/reviews_controller_spec.rb b/spec/controllers/reviews_controller_spec.rb
new file mode 100644
index 00000000..98e92a2a
--- /dev/null
+++ b/spec/controllers/reviews_controller_spec.rb
@@ -0,0 +1,60 @@
+require 'rails_helper'
+
+RSpec.describe ReviewsController, type: :request do
+ let(:user) { create(:user) }
+ let(:subject) { create(:subject) }
+ let(:review) { create(:review, user: user, subject: subject) }
+
+ before do
+ # https://github.com/heartcombo/devise/issues/5705
+ Rails.application.reload_routes_unless_loaded
+ end
+
+ before do
+ sign_in user
+ end
+
+ describe 'POST #create' do
+ it 'creates a new review' do
+ expect {
+ post reviews_path, params: { subject_id: subject.id, rating: 5 }
+ }.to change(Review, :count).by(1)
+ end
+
+ it 'redirects to the subject page' do
+ post reviews_path, params: { subject_id: subject.id, rating: 5 }
+
+ expect(response).to redirect_to(subject_path(subject))
+ end
+ end
+
+ describe 'PATCH #update' do
+ it 'updates the review' do
+ patch review_path(review), params: { rating: 4 }
+
+ expect(review.reload.rating).to eq(4)
+ end
+
+ it 'redirects to the subject page' do
+ patch review_path(review), params: { rating: 4 }
+
+ expect(response).to redirect_to(subject_path(review.subject))
+ end
+ end
+
+ describe 'DELETE #destroy' do
+ it 'deletes the review' do
+ review
+
+ expect {
+ delete review_path(review)
+ }.to change(Review, :count).by(-1)
+ end
+
+ it 'redirects to the subject page' do
+ delete review_path(review)
+
+ expect(response).to redirect_to(subject_path(review.subject))
+ end
+ end
+end
diff --git a/spec/factories/reviews.rb b/spec/factories/reviews.rb
new file mode 100644
index 00000000..85b7f5f1
--- /dev/null
+++ b/spec/factories/reviews.rb
@@ -0,0 +1,7 @@
+FactoryBot.define do
+ factory :review do
+ user
+ subject
+ rating { 3 }
+ end
+end
diff --git a/spec/models/review_spec.rb b/spec/models/review_spec.rb
new file mode 100644
index 00000000..fc456517
--- /dev/null
+++ b/spec/models/review_spec.rb
@@ -0,0 +1,49 @@
+require 'rails_helper'
+
+RSpec.describe Review, type: :model do
+ describe 'associations' do
+ it { is_expected.to belong_to(:user) }
+ it { is_expected.to belong_to(:subject) }
+ end
+
+ describe 'validations' do
+ subject { create :review }
+
+ it {
+ is_expected.to validate_uniqueness_of(:user_id)
+ .scoped_to(:subject_id)
+ .with_message("You can only review a subject once.")
+ }
+ it { is_expected.to validate_presence_of(:rating) }
+ it { is_expected.to validate_inclusion_of(:rating).in_range(1..5) }
+ end
+
+ describe 'callbacks' do
+ context 'calls update_rating on subject after commit' do
+ let(:review) { build :review }
+
+ it 'calls update_rating on subject after create' do
+ expect(review.subject).to receive(:update_rating)
+ review.save!
+ end
+
+ it 'calls update_rating on subject after update' do
+ review.save!
+ expect(review.subject).to receive(:update_rating)
+ review.update!(rating: 5)
+ end
+
+ it 'calls update_rating on subject after destroy' do
+ review.save!
+ expect(review.reload.subject).to receive(:update_rating)
+ review.destroy!
+ end
+
+ it 'does not call update_rating on subject if rating was not changed' do
+ review.save!
+ expect(review.subject).not_to receive(:update_rating)
+ review.update!(rating: review.rating)
+ end
+ end
+ end
+end
diff --git a/spec/models/subject_spec.rb b/spec/models/subject_spec.rb
index 36dc7afe..5347a2ec 100644
--- a/spec/models/subject_spec.rb
+++ b/spec/models/subject_spec.rb
@@ -119,4 +119,25 @@
expect(Subject.current_semester_optionals).to eq([s1])
end
end
+
+ describe '#update_rating' do
+ let(:subject) { create :subject }
+
+ context 'when there are no reviews' do
+ it 'updates average rating' do
+ subject.update_rating
+ expect(subject.average_rating).to eq(nil)
+ end
+ end
+
+ context 'when there are reviews' do
+ let!(:review) { create :review, subject: subject, rating: 5 }
+ let!(:another_review) { create :review, subject: subject, rating: 3 }
+
+ it 'updates average rating' do
+ subject.update_rating
+ expect(subject.average_rating).to eq(4)
+ end
+ end
+ end
end
diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb
index ac8cf556..02b0d807 100644
--- a/spec/rails_helper.rb
+++ b/spec/rails_helper.rb
@@ -60,6 +60,8 @@
config.filter_rails_from_backtrace!
# arbitrary gems may also be filtered via:
# config.filter_gems_from_backtrace("gem name")
+
+ config.include Devise::Test::IntegrationHelpers, type: :request
end
Shoulda::Matchers.configure do |config|