From c003e41cb3aa53942f1073f5241ce54012072bfa Mon Sep 17 00:00:00 2001 From: Chris Preisinger Date: Wed, 20 Nov 2024 10:18:27 -0500 Subject: [PATCH] 230 Initial commit of evaluation and score backend --- Gemfile.lock | 168 ++++++------ app/models/evaluation.rb | 44 +++ app/models/evaluation_score.rb | 74 +++++ .../20241120024939_create_evaluations.rb | 17 ++ ...20241120024946_create_evaluation_scores.rb | 17 ++ db/structure.sql | 180 ++++++++++++- spec/factories/evaluation.rb | 16 ++ spec/factories/evaluation_criteria.rb | 2 +- spec/factories/evaluation_score.rb | 24 ++ spec/models/evaluation_score_spec.rb | 255 ++++++++++++++++++ spec/models/evaluation_spec.rb | 93 +++++++ 11 files changed, 804 insertions(+), 86 deletions(-) create mode 100644 app/models/evaluation.rb create mode 100644 app/models/evaluation_score.rb create mode 100644 db/migrate/20241120024939_create_evaluations.rb create mode 100644 db/migrate/20241120024946_create_evaluation_scores.rb create mode 100644 spec/factories/evaluation.rb create mode 100644 spec/factories/evaluation_score.rb create mode 100644 spec/models/evaluation_score_spec.rb create mode 100644 spec/models/evaluation_spec.rb diff --git a/Gemfile.lock b/Gemfile.lock index cb94e698..d463532b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,29 +1,29 @@ GEM remote: https://rubygems.org/ specs: - actioncable (7.2.1.2) - actionpack (= 7.2.1.2) - activesupport (= 7.2.1.2) + actioncable (7.2.2) + actionpack (= 7.2.2) + activesupport (= 7.2.2) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (7.2.1.2) - actionpack (= 7.2.1.2) - activejob (= 7.2.1.2) - activerecord (= 7.2.1.2) - activestorage (= 7.2.1.2) - activesupport (= 7.2.1.2) + actionmailbox (7.2.2) + actionpack (= 7.2.2) + activejob (= 7.2.2) + activerecord (= 7.2.2) + activestorage (= 7.2.2) + activesupport (= 7.2.2) mail (>= 2.8.0) - actionmailer (7.2.1.2) - actionpack (= 7.2.1.2) - actionview (= 7.2.1.2) - activejob (= 7.2.1.2) - activesupport (= 7.2.1.2) + actionmailer (7.2.2) + actionpack (= 7.2.2) + actionview (= 7.2.2) + activejob (= 7.2.2) + activesupport (= 7.2.2) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (7.2.1.2) - actionview (= 7.2.1.2) - activesupport (= 7.2.1.2) + actionpack (7.2.2) + actionview (= 7.2.2) + activesupport (= 7.2.2) nokogiri (>= 1.8.5) racc rack (>= 2.2.4, < 3.2) @@ -32,36 +32,37 @@ GEM rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (7.2.1.2) - actionpack (= 7.2.1.2) - activerecord (= 7.2.1.2) - activestorage (= 7.2.1.2) - activesupport (= 7.2.1.2) + actiontext (7.2.2) + actionpack (= 7.2.2) + activerecord (= 7.2.2) + activestorage (= 7.2.2) + activesupport (= 7.2.2) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.2.1.2) - activesupport (= 7.2.1.2) + actionview (7.2.2) + activesupport (= 7.2.2) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (7.2.1.2) - activesupport (= 7.2.1.2) + activejob (7.2.2) + activesupport (= 7.2.2) globalid (>= 0.3.6) - activemodel (7.2.1.2) - activesupport (= 7.2.1.2) - activerecord (7.2.1.2) - activemodel (= 7.2.1.2) - activesupport (= 7.2.1.2) + activemodel (7.2.2) + activesupport (= 7.2.2) + activerecord (7.2.2) + activemodel (= 7.2.2) + activesupport (= 7.2.2) timeout (>= 0.4.0) - activestorage (7.2.1.2) - actionpack (= 7.2.1.2) - activejob (= 7.2.1.2) - activerecord (= 7.2.1.2) - activesupport (= 7.2.1.2) + activestorage (7.2.2) + actionpack (= 7.2.2) + activejob (= 7.2.2) + activerecord (= 7.2.2) + activesupport (= 7.2.2) marcel (~> 1.0) - activesupport (7.2.1.2) + activesupport (7.2.2) base64 + benchmark (>= 0.3) bigdecimal concurrent-ruby (~> 1.0, >= 1.3.1) connection_pool (>= 2.2.5) @@ -77,12 +78,12 @@ GEM activerecord (>= 3.2, < 8.0) rake (>= 10.4, < 14.0) ast (2.4.2) - axe-core-api (4.10.1) + axe-core-api (4.10.2) dumb_delegator ostruct virtus - axe-core-rspec (4.10.1) - axe-core-api (= 4.10.1) + axe-core-rspec (4.10.2) + axe-core-api (= 4.10.2) dumb_delegator ostruct virtus @@ -91,6 +92,7 @@ GEM ice_nine (~> 0.11.0) thread_safe (~> 0.3, >= 0.3.1) base64 (0.2.0) + benchmark (0.4.0) bigdecimal (3.1.8) bindex (0.8.1) bootsnap (1.18.4) @@ -121,7 +123,7 @@ GEM crass (1.0.6) cssbundling-rails (1.4.1) railties (>= 6.0.0) - date (3.3.4) + date (3.4.0) debug (1.9.2) irb (~> 1.10) reline (>= 0.3.8) @@ -136,16 +138,16 @@ GEM activesupport (>= 5.0.0) faker (3.5.1) i18n (>= 1.8.11, < 2) - faraday (2.12.0) - faraday-net_http (>= 2.0, < 3.4) + faraday (2.12.1) + faraday-net_http (>= 2.0, < 3.5) json logger - faraday-net_http (3.3.0) - net-http + faraday-net_http (3.4.0) + net-http (>= 0.5.0) foreman (0.88.1) globalid (1.2.1) activesupport (>= 6.1) - hashdiff (1.1.1) + hashdiff (1.1.2) i18n (1.14.6) concurrent-ruby (~> 1.0) ice_nine (0.11.2) @@ -158,7 +160,7 @@ GEM activesupport (>= 5.0.0) jsbundling-rails (1.3.1) railties (>= 6.0.0) - json (2.7.4) + json (2.8.2) jwt (2.9.3) base64 language_server-protocol (3.17.0.3) @@ -176,10 +178,10 @@ GEM method_source (1.1.0) mini_mime (1.1.5) minitest (5.25.1) - msgpack (1.7.3) - net-http (0.4.1) + msgpack (1.7.5) + net-http (0.5.0) uri - net-imap (0.5.0) + net-imap (0.5.1) date net-protocol net-pop (0.1.2) @@ -201,9 +203,9 @@ GEM racc (~> 1.4) nokogiri (1.16.7-x86_64-linux) racc (~> 1.4) - ostruct (0.6.0) + ostruct (0.6.1) parallel (1.26.3) - parser (3.3.5.0) + parser (3.3.6.0) ast (~> 2.4.1) racc pg (1.5.9) @@ -212,10 +214,10 @@ GEM activesupport (>= 7.0.0) rack railties (>= 7.0.0) - pry (0.14.2) + pry (0.15.0) coderay (~> 1.1) method_source (~> 1.0) - psych (5.1.2) + psych (5.2.0) stringio public_suffix (6.0.1) puma (6.4.3) @@ -226,23 +228,22 @@ GEM rack (>= 3.0.0) rack-test (2.1.0) rack (>= 1.3) - rackup (2.1.0) + rackup (2.2.1) rack (>= 3) - webrick (~> 1.8) - rails (7.2.1.2) - actioncable (= 7.2.1.2) - actionmailbox (= 7.2.1.2) - actionmailer (= 7.2.1.2) - actionpack (= 7.2.1.2) - actiontext (= 7.2.1.2) - actionview (= 7.2.1.2) - activejob (= 7.2.1.2) - activemodel (= 7.2.1.2) - activerecord (= 7.2.1.2) - activestorage (= 7.2.1.2) - activesupport (= 7.2.1.2) + rails (7.2.2) + actioncable (= 7.2.2) + actionmailbox (= 7.2.2) + actionmailer (= 7.2.2) + actionpack (= 7.2.2) + actiontext (= 7.2.2) + actionview (= 7.2.2) + activejob (= 7.2.2) + activemodel (= 7.2.2) + activerecord (= 7.2.2) + activestorage (= 7.2.2) + activesupport (= 7.2.2) bundler (>= 1.15.0) - railties (= 7.2.1.2) + railties (= 7.2.2) rails-controller-testing (1.0.5) actionpack (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1) @@ -254,9 +255,9 @@ GEM rails-html-sanitizer (1.6.0) loofah (~> 2.21) nokogiri (~> 1.14) - railties (7.2.1.2) - actionpack (= 7.2.1.2) - activesupport (= 7.2.1.2) + railties (7.2.2) + actionpack (= 7.2.2) + activesupport (= 7.2.2) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) @@ -264,10 +265,10 @@ GEM zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.2.1) - rdoc (6.7.0) + rdoc (6.8.1) psych (>= 4.0.0) regexp_parser (2.9.2) - reline (0.5.10) + reline (0.5.11) io-console (~> 0.5) rexml (3.3.9) rspec-core (3.13.2) @@ -278,7 +279,7 @@ GEM rspec-mocks (3.13.2) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-rails (7.0.1) + rspec-rails (7.1.0) actionpack (>= 7.0) activesupport (>= 7.0) railties (>= 7.0) @@ -289,7 +290,7 @@ GEM rspec-support (3.13.1) rspec_junit_formatter (0.6.0) rspec-core (>= 2, < 4, != 2.12.0) - rubocop (1.67.0) + rubocop (1.68.0) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) @@ -299,11 +300,11 @@ GEM rubocop-ast (>= 1.32.2, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.32.3) + rubocop-ast (1.36.1) parser (>= 3.3.1.0) rubocop-capybara (2.21.0) rubocop (~> 1.41) - rubocop-performance (1.22.1) + rubocop-performance (1.23.0) rubocop (>= 1.48.1, < 2.0) rubocop-ast (>= 1.31.1, < 2.0) rubocop-rails (2.27.0) @@ -317,8 +318,8 @@ GEM rubocop (~> 1.61) ruby-progressbar (1.13.0) rubyzip (2.3.2) - securerandom (0.3.1) - selenium-webdriver (4.25.0) + securerandom (0.3.2) + selenium-webdriver (4.26.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) @@ -331,10 +332,10 @@ GEM simplecov-html (0.10.2) stimulus-rails (1.3.4) railties (>= 6.0.0) - stringio (3.1.1) + stringio (3.1.2) thor (1.3.2) thread_safe (0.3.6) - timeout (0.4.1) + timeout (0.4.2) turbo-rails (2.0.11) actionpack (>= 6.0.0) railties (>= 6.0.0) @@ -342,7 +343,7 @@ GEM concurrent-ruby (~> 1.0) unicode-display_width (2.6.0) uniform_notifier (1.16.0) - uri (0.13.1) + uri (1.0.2) useragent (0.16.10) virtus (2.0.0) axiom-types (~> 0.1) @@ -357,7 +358,6 @@ GEM addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - webrick (1.8.2) websocket (1.2.11) websocket-driver (0.7.6) websocket-extensions (>= 0.1.0) diff --git a/app/models/evaluation.rb b/app/models/evaluation.rb new file mode 100644 index 00000000..11b81ea8 --- /dev/null +++ b/app/models/evaluation.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: evaluations +# +# id :bigint not null, primary key +# user_id :bigint not null +# evaluation_form_id :bigint not null +# status :integer default("not_started"), not null +# total_score :integer default(nil) +# additional_comments :text +# revision_comments :text +# created_at :datetime not null +# updated_at :datetime not null +# +class Evaluation < ApplicationRecord + belongs_to :user + belongs_to :evaluation_form + has_many :evaluation_scores, dependent: :destroy + + enum :status, { + not_started: 0, + recused: 1, + in_progress: 2, + completed: 3 + } + + validates :total_score, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true + validates :additional_comments, length: { maximum: 3000, message: "cannot exceed 3000 characters" }, + allow_nil: true + validates :revision_comments, length: { maximum: 3000, message: "cannot exceed 3000 characters" }, + allow_nil: true + + validate :user_has_valid_role + + private + + def user_has_valid_role + return if User::VALID_EVALUATOR_ROLES.include?(user.role) + + errors.add(:user, "must have a valid evaluator role") + end +end diff --git a/app/models/evaluation_score.rb b/app/models/evaluation_score.rb new file mode 100644 index 00000000..4eb7c043 --- /dev/null +++ b/app/models/evaluation_score.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: evaluation_scores +# +# id :bigint not null, primary key +# evaluation_id :bigint not null +# evaluation_criterion_id :bigint not null +# score :integer not null +# score_override :integer +# comment :text +# comment_override :text +# created_at :datetime not null +# updated_at :datetime not null +# +class EvaluationScore < ApplicationRecord + belongs_to :evaluation + belongs_to :evaluation_criterion + + validates :score, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, presence: true + validates :score_override, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, allow_nil: true + validates :comment, presence: true, if: -> { evaluation.evaluation_form.comments_required? } + validates :comment, length: { maximum: 3000, message: "cannot exceed 3000 characters" }, allow_nil: true + validates :comment_override, length: { maximum: 3000, message: "cannot exceed 3000 characters" }, + allow_nil: true + + validate :score_within_criterion_limits + + def effective_score + score_override || score + end + + def effective_comment + comment_override || comment + end + + private + + # TODO: Should these error messages be more generic instead of specific values + # Ex. less than or equal to criterion points or weight + def score_within_criterion_limits + case evaluation_criterion.scoring_type + when 'numeric' + validate_numeric_score + when 'rating', 'binary' + validate_range_score + else + errors.add(:base, "Invalid scoring type for criterion") + end + end + + def validate_numeric_score + max_score = evaluation_criterion.points_or_weight + + if score && score > max_score + errors.add(:score, "must be less than or equal to #{max_score}") + elsif score_override && score_override > max_score + errors.add(:score_override, "must be less than or equal to #{max_score}") + end + end + + def validate_range_score + range_start = evaluation_criterion.option_range_start + range_end = evaluation_criterion.option_range_end + valid_range = (range_start..range_end) + + if score && valid_range.exclude?(score) + errors.add(:score, "must be within the range #{range_start} to #{range_end}") + elsif score_override && valid_range.exclude?(score_override) + errors.add(:score_override, "must be within the range #{range_start} to #{range_end}") + end + end +end diff --git a/db/migrate/20241120024939_create_evaluations.rb b/db/migrate/20241120024939_create_evaluations.rb new file mode 100644 index 00000000..897a37f9 --- /dev/null +++ b/db/migrate/20241120024939_create_evaluations.rb @@ -0,0 +1,17 @@ +class CreateEvaluations < ActiveRecord::Migration[7.2] + def change + create_table :evaluations do |t| + t.references :user, null: false, foreign_key: true + t.references :evaluation_form, null: false, foreign_key: true + + t.text :additional_comments + t.text :revision_comments + t.integer :status, default: 0, null: false + t.integer :total_score, default: nil + + t.timestamps + end + + add_index :evaluations, [:user_id, :evaluation_form_id], unique: true + end +end diff --git a/db/migrate/20241120024946_create_evaluation_scores.rb b/db/migrate/20241120024946_create_evaluation_scores.rb new file mode 100644 index 00000000..948f9e74 --- /dev/null +++ b/db/migrate/20241120024946_create_evaluation_scores.rb @@ -0,0 +1,17 @@ +class CreateEvaluationScores < ActiveRecord::Migration[7.2] + def change + create_table :evaluation_scores do |t| + t.references :evaluation, null: false, foreign_key: true + t.references :evaluation_criterion, null: false, foreign_key: true + + t.integer :score, null: false + t.integer :score_override + t.text :comment + t.text :comment_override + + t.timestamps + end + + add_index :evaluation_scores, [:evaluation_id, :evaluation_criterion_id], unique: true + end +end diff --git a/db/structure.sql b/db/structure.sql index db2287b8..ed721c39 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -430,6 +430,78 @@ CREATE SEQUENCE public.evaluation_forms_id_seq ALTER SEQUENCE public.evaluation_forms_id_seq OWNED BY public.evaluation_forms.id; +-- +-- Name: evaluation_scores; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.evaluation_scores ( + id bigint NOT NULL, + evaluation_id bigint NOT NULL, + evaluation_criterion_id bigint NOT NULL, + score integer NOT NULL, + score_override integer, + comment text, + comment_override text, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: evaluation_scores_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.evaluation_scores_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: evaluation_scores_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.evaluation_scores_id_seq OWNED BY public.evaluation_scores.id; + + +-- +-- Name: evaluations; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.evaluations ( + id bigint NOT NULL, + user_id bigint NOT NULL, + evaluation_form_id bigint NOT NULL, + additional_comments text, + revision_comments text, + status integer DEFAULT 0 NOT NULL, + total_score integer, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: evaluations_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.evaluations_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: evaluations_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.evaluations_id_seq OWNED BY public.evaluations.id; + + -- -- Name: evaluator_invitations; Type: TABLE; Schema: public; Owner: - -- @@ -685,7 +757,7 @@ CREATE TABLE public.oban_jobs ( attempted_by text[], discarded_at timestamp without time zone, priority integer DEFAULT 0 NOT NULL, - tags text[] DEFAULT ARRAY[]::text[], + tags character varying(255)[] DEFAULT ARRAY[]::character varying[], meta jsonb DEFAULT '{}'::jsonb, cancelled_at timestamp without time zone, CONSTRAINT attempt_range CHECK (((attempt >= 0) AND (attempt <= max_attempts))), @@ -1300,6 +1372,20 @@ ALTER TABLE ONLY public.evaluation_criteria ALTER COLUMN id SET DEFAULT nextval( ALTER TABLE ONLY public.evaluation_forms ALTER COLUMN id SET DEFAULT nextval('public.evaluation_forms_id_seq'::regclass); +-- +-- Name: evaluation_scores id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.evaluation_scores ALTER COLUMN id SET DEFAULT nextval('public.evaluation_scores_id_seq'::regclass); + + +-- +-- Name: evaluations id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.evaluations ALTER COLUMN id SET DEFAULT nextval('public.evaluations_id_seq'::regclass); + + -- -- Name: evaluator_invitations id; Type: DEFAULT; Schema: public; Owner: - -- @@ -1527,6 +1613,22 @@ ALTER TABLE ONLY public.evaluation_forms ADD CONSTRAINT evaluation_forms_pkey PRIMARY KEY (id); +-- +-- Name: evaluation_scores evaluation_scores_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.evaluation_scores + ADD CONSTRAINT evaluation_scores_pkey PRIMARY KEY (id); + + +-- +-- Name: evaluations evaluations_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.evaluations + ADD CONSTRAINT evaluations_pkey PRIMARY KEY (id); + + -- -- Name: evaluator_invitations evaluator_invitations_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -1740,6 +1842,13 @@ CREATE UNIQUE INDEX challenges_custom_url_index ON public.challenges USING btree CREATE UNIQUE INDEX idx_on_challenge_id_phase_id_email_b0ae3723d2 ON public.evaluator_invitations USING btree (challenge_id, phase_id, email); +-- +-- Name: idx_on_evaluation_id_evaluation_criterion_id_c69f3b58f4; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX idx_on_evaluation_id_evaluation_criterion_id_c69f3b58f4 ON public.evaluation_scores USING btree (evaluation_id, evaluation_criterion_id); + + -- -- Name: index_challenge_phases_evaluators_on_challenge_id; Type: INDEX; Schema: public; Owner: - -- @@ -1782,6 +1891,41 @@ CREATE INDEX index_evaluation_forms_on_challenge_id ON public.evaluation_forms U CREATE INDEX index_evaluation_forms_on_phase_id ON public.evaluation_forms USING btree (phase_id); +-- +-- Name: index_evaluation_scores_on_evaluation_criterion_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_evaluation_scores_on_evaluation_criterion_id ON public.evaluation_scores USING btree (evaluation_criterion_id); + + +-- +-- Name: index_evaluation_scores_on_evaluation_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_evaluation_scores_on_evaluation_id ON public.evaluation_scores USING btree (evaluation_id); + + +-- +-- Name: index_evaluations_on_evaluation_form_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_evaluations_on_evaluation_form_id ON public.evaluations USING btree (evaluation_form_id); + + +-- +-- Name: index_evaluations_on_user_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_evaluations_on_user_id ON public.evaluations USING btree (user_id); + + +-- +-- Name: index_evaluations_on_user_id_and_evaluation_form_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_evaluations_on_user_id_and_evaluation_form_id ON public.evaluations USING btree (user_id, evaluation_form_id); + + -- -- Name: index_evaluator_invitations_on_challenge_id; Type: INDEX; Schema: public; Owner: - -- @@ -2002,6 +2146,14 @@ ALTER TABLE ONLY public.evaluator_submission_assignments ADD CONSTRAINT fk_rails_3b40ec8e27 FOREIGN KEY (submission_id) REFERENCES public.submissions(id); +-- +-- Name: evaluation_scores fk_rails_446b90867a; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.evaluation_scores + ADD CONSTRAINT fk_rails_446b90867a FOREIGN KEY (evaluation_id) REFERENCES public.evaluations(id); + + -- -- Name: evaluator_submission_assignments fk_rails_67111ac897; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -2010,6 +2162,22 @@ ALTER TABLE ONLY public.evaluator_submission_assignments ADD CONSTRAINT fk_rails_67111ac897 FOREIGN KEY (user_id) REFERENCES public.users(id); +-- +-- Name: evaluations fk_rails_8e2f1350a5; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.evaluations + ADD CONSTRAINT fk_rails_8e2f1350a5 FOREIGN KEY (evaluation_form_id) REFERENCES public.evaluation_forms(id); + + +-- +-- Name: evaluation_scores fk_rails_92979ffe0c; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.evaluation_scores + ADD CONSTRAINT fk_rails_92979ffe0c FOREIGN KEY (evaluation_criterion_id) REFERENCES public.evaluation_criteria(id); + + -- -- Name: evaluation_criteria fk_rails_a39b8fa483; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -2050,6 +2218,14 @@ ALTER TABLE ONLY public.challenge_phases_evaluators ADD CONSTRAINT fk_rails_e27fcb2d4d FOREIGN KEY (challenge_id) REFERENCES public.challenges(id); +-- +-- Name: evaluations fk_rails_ef42eba623; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.evaluations + ADD CONSTRAINT fk_rails_ef42eba623 FOREIGN KEY (user_id) REFERENCES public.users(id); + + -- -- Name: message_context_statuses message_context_statuses_message_context_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -2257,6 +2433,8 @@ ALTER TABLE ONLY public.winners SET search_path TO "$user", public; INSERT INTO "schema_migrations" (version) VALUES +(20241120024946), +(20241120024939), (20241115193801), (20241115193605), (20241107161811), diff --git a/spec/factories/evaluation.rb b/spec/factories/evaluation.rb new file mode 100644 index 00000000..75b7b711 --- /dev/null +++ b/spec/factories/evaluation.rb @@ -0,0 +1,16 @@ +FactoryBot.define do + factory :evaluation do + association :user, :evaluator + association :evaluation_form + status { :not_started } + total_score { nil } + additional_comments { nil } + revision_comments { nil } + + after(:create) do |evaluation, _evaluator| + evaluation.evaluation_form.evaluation_criteria.each do |criterion| + create(:evaluation_score, evaluation_criterion: criterion, evaluation:) + end + end + end +end diff --git a/spec/factories/evaluation_criteria.rb b/spec/factories/evaluation_criteria.rb index 10d3e8b9..7ccac084 100644 --- a/spec/factories/evaluation_criteria.rb +++ b/spec/factories/evaluation_criteria.rb @@ -6,7 +6,7 @@ # Fields title { "Criterion #{Faker::Lorem.sentence(word_count: 3)}" } description { Faker::Lorem.sentence } - points_or_weight { rand(0..100) } + points_or_weight { rand(1..100) } scoring_type { [:numeric, :rating, :binary].sample } option_range_start { nil } option_range_end { nil } diff --git a/spec/factories/evaluation_score.rb b/spec/factories/evaluation_score.rb new file mode 100644 index 00000000..f1b12622 --- /dev/null +++ b/spec/factories/evaluation_score.rb @@ -0,0 +1,24 @@ +FactoryBot.define do + factory :evaluation_score do + association :evaluation + association :evaluation_criterion + score { nil } + score_override { nil } + comment { Faker::Lorem.sentence } + comment_override { nil } + + # Evaluator is a FactoryBot param containing attributes from the factory record + after(:build) do |evaluation_score, _evaluator| + criterion = evaluation_score.evaluation_criterion + + case criterion.scoring_type + when "numeric" + evaluation_score.score = rand(0..criterion.points_or_weight) + when "rating", "binary" + evaluation_score.score = rand(criterion.option_range_start..criterion.option_range_end) + else + raise ArgumentError, "Invalid scoring type '#{criterion.scoring_type}' for evaluation criterion" + end + end + end +end diff --git a/spec/models/evaluation_score_spec.rb b/spec/models/evaluation_score_spec.rb new file mode 100644 index 00000000..0c9dca9a --- /dev/null +++ b/spec/models/evaluation_score_spec.rb @@ -0,0 +1,255 @@ +require 'rails_helper' + +RSpec.describe EvaluationScore, type: :model do + let(:evaluation_form) { create(:evaluation_form) } + let(:evaluation) { create(:evaluation, evaluation_form:) } + let(:evaluation_score) { create(:evaluation_score, evaluation:) } + + describe "associations" do + it "belongs to an evaluation" do + expect(evaluation_score.evaluation).to eq(evaluation) + end + + # TODO: Possibly update this to check specific criterion + # Would require some factory rework + it "belongs to an evaluation criterion" do + evaluation_score = create(:evaluation_score) + expect(evaluation_score.evaluation_criterion).to be_present + end + + # TODO: Possibly check uniqueness with evaluation and evaluation_criterion? + end + + describe "score validations for numeric criterion" do + it "is valid if score is equal to or less than criterion points_or_weight" do + evaluation_criterion = create(:evaluation_criterion, :numeric) + points_or_weight = evaluation_criterion.points_or_weight + evaluation_score = create(:evaluation_score, evaluation_criterion:) + + # Initially valid in factory with random valid score + expect(evaluation_score).to be_valid + expect(evaluation_score.effective_score).to eq(evaluation_score.score) + + # Update to verify + new_score = rand(0..points_or_weight) + evaluation_score.update!(score: new_score) + expect(evaluation_score).to be_valid + expect(evaluation_score.effective_score).to eq(evaluation_score.score) + expect(evaluation_score.effective_score).to eq(new_score) + + # Verify override and effective score + new_score = rand(0..points_or_weight) + evaluation_score.update!(score_override: new_score) + expect(evaluation_score).to be_valid + expect(evaluation_score.effective_score).to eq(evaluation_score.score_override) + expect(evaluation_score.effective_score).to eq(new_score) + end + + it "is invalid if score is negative" do + evaluation_criterion = create(:evaluation_criterion, :numeric) + points_or_weight = evaluation_criterion.points_or_weight + evaluation_score = create(:evaluation_score, evaluation_criterion:) + + expect do + evaluation_score.update!(score: - 1) + end.to raise_error(ActiveRecord::RecordInvalid, "Validation failed: Score must be greater than or equal to 0") + + # Clear validation error + new_score = rand(0..points_or_weight) + evaluation_score.update!(score: new_score) + + expect do + evaluation_score.update!(score_override: - 1) + end.to raise_error(ActiveRecord::RecordInvalid, + "Validation failed: Score override must be greater than or equal to 0") + end + + it "is invalid if score is greater than criterion points_or_weight" do + evaluation_criterion = create(:evaluation_criterion, :numeric) + points_or_weight = evaluation_criterion.points_or_weight + evaluation_score = create(:evaluation_score, evaluation_criterion:) + + expect do + evaluation_score.update!(score: points_or_weight + 1) + end.to raise_error(ActiveRecord::RecordInvalid, + "Validation failed: Score must be less than or equal to #{points_or_weight}") + + # Clear validation error + new_score = rand(0..points_or_weight) + evaluation_score.update!(score: new_score) + + expect do + evaluation_score.update!(score_override: points_or_weight + 1) + end.to raise_error(ActiveRecord::RecordInvalid, + "Validation failed: Score override must be less than or equal to #{points_or_weight}") + end + end + + describe "score validations for rating criterion" do + it "is valid if score is equal to or less than criterion points_or_weight" do + evaluation_criterion = create(:evaluation_criterion, :rating) + option_range_start = evaluation_criterion.option_range_start + option_range_end = evaluation_criterion.option_range_end + evaluation_score = create(:evaluation_score, evaluation_criterion:) + + # Initially valid in factory with random valid score + expect(evaluation_score).to be_valid + expect(evaluation_score.effective_score).to eq(evaluation_score.score) + + # Update to verify + new_score = rand(option_range_start..option_range_end) + evaluation_score.update!(score: new_score) + expect(evaluation_score).to be_valid + expect(evaluation_score.effective_score).to eq(evaluation_score.score) + expect(evaluation_score.effective_score).to eq(new_score) + + # Verify override and effective score + new_score = rand(option_range_start..option_range_end) + evaluation_score.update!(score_override: new_score) + expect(evaluation_score).to be_valid + expect(evaluation_score.effective_score).to eq(evaluation_score.score_override) + expect(evaluation_score.effective_score).to eq(new_score) + end + + it "is invalid if score is negative" do + evaluation_criterion = create(:evaluation_criterion, :rating) + option_range_start = evaluation_criterion.option_range_start + option_range_end = evaluation_criterion.option_range_end + evaluation_score = create(:evaluation_score, evaluation_criterion:) + + expect do + evaluation_score.update!(score: - 1) + end.to raise_error(ActiveRecord::RecordInvalid, + "Validation failed: Score must be greater than or equal to 0, Score must be within the range #{option_range_start} to #{option_range_end}") + + # Clear validation error + new_score = rand(option_range_start..option_range_end) + evaluation_score.update!(score: new_score) + + expect do + evaluation_score.update!(score_override: - 1) + end.to raise_error(ActiveRecord::RecordInvalid, + "Validation failed: Score override must be greater than or equal to 0, Score override must be within the range #{option_range_start} to #{option_range_end}") + end + + it "is invalid if score is greater than criterion points_or_weight" do + evaluation_criterion = create(:evaluation_criterion, :rating) + option_range_start = evaluation_criterion.option_range_start + option_range_end = evaluation_criterion.option_range_end + evaluation_score = create(:evaluation_score, evaluation_criterion:) + + expect do + evaluation_score.update!(score: option_range_end + 1) + end.to raise_error(ActiveRecord::RecordInvalid, + "Validation failed: Score must be within the range #{option_range_start} to #{option_range_end}") + + # Clear validation error + new_score = rand(option_range_start..option_range_end) + evaluation_score.update!(score: new_score) + + expect do + evaluation_score.update!(score_override: option_range_end + 1) + end.to raise_error(ActiveRecord::RecordInvalid, + "Validation failed: Score override must be within the range #{option_range_start} to #{option_range_end}") + end + end + + describe "score validations for binary criterion" do + it "is valid if score is equal to or less than criterion points_or_weight" do + evaluation_criterion = create(:evaluation_criterion, :binary) + option_range_start = evaluation_criterion.option_range_start + option_range_end = evaluation_criterion.option_range_end + evaluation_score = create(:evaluation_score, evaluation_criterion:) + + # Initially valid in factory with random valid score + expect(evaluation_score).to be_valid + expect(evaluation_score.effective_score).to eq(evaluation_score.score) + + # Update to verify + new_score = rand(option_range_start..option_range_end) + evaluation_score.update!(score: new_score) + expect(evaluation_score).to be_valid + expect(evaluation_score.effective_score).to eq(evaluation_score.score) + expect(evaluation_score.effective_score).to eq(new_score) + + # Verify override and effective score + new_score = rand(option_range_start..option_range_end) + evaluation_score.update!(score_override: new_score) + expect(evaluation_score).to be_valid + expect(evaluation_score.effective_score).to eq(evaluation_score.score_override) + expect(evaluation_score.effective_score).to eq(new_score) + end + + it "is invalid if score is negative" do + evaluation_criterion = create(:evaluation_criterion, :binary) + option_range_start = evaluation_criterion.option_range_start + option_range_end = evaluation_criterion.option_range_end + evaluation_score = create(:evaluation_score, evaluation_criterion:) + + expect do + evaluation_score.update!(score: - 1) + end.to raise_error(ActiveRecord::RecordInvalid, + "Validation failed: Score must be greater than or equal to 0, Score must be within the range #{option_range_start} to #{option_range_end}") + + # Clear validation error + new_score = rand(option_range_start..option_range_end) + evaluation_score.update!(score: new_score) + + expect do + evaluation_score.update!(score_override: - 1) + end.to raise_error(ActiveRecord::RecordInvalid, + "Validation failed: Score override must be greater than or equal to 0, Score override must be within the range #{option_range_start} to #{option_range_end}") + end + + it "is invalid if score is greater than criterion points_or_weight" do + evaluation_criterion = create(:evaluation_criterion, :binary) + option_range_start = evaluation_criterion.option_range_start + option_range_end = evaluation_criterion.option_range_end + evaluation_score = create(:evaluation_score, evaluation_criterion:) + + expect do + evaluation_score.update!(score: option_range_end + 1) + end.to raise_error(ActiveRecord::RecordInvalid, + "Validation failed: Score must be within the range #{option_range_start} to #{option_range_end}") + + # Clear validation error + new_score = rand(option_range_start..option_range_end) + evaluation_score.update!(score: new_score) + + expect do + evaluation_score.update!(score_override: option_range_end + 1) + end.to raise_error(ActiveRecord::RecordInvalid, + "Validation failed: Score override must be within the range #{option_range_start} to #{option_range_end}") + end + end + + describe "comment validation" do + it "is valid if comment length is 3000 or less" do + new_comment = Faker::Lorem.characters(number: 3000) + evaluation_score.update!(comment: new_comment) + expect(evaluation_score).to be_valid + expect(evaluation_score.effective_comment).to eq(evaluation_score.comment) + expect(evaluation_score.comment).to eq(new_comment) + + new_comment = Faker::Lorem.characters(number: 3000) + evaluation_score.update!(comment_override: new_comment) + expect(evaluation_score).to be_valid + expect(evaluation_score.effective_comment).to eq(evaluation_score.comment_override) + expect(evaluation_score.comment_override).to eq(new_comment) + end + + it "is invalid if comments length is greater than 3000" do + expect do + evaluation_score.update!(comment: Faker::Lorem.characters(number: 3001)) + end.to raise_error(ActiveRecord::RecordInvalid, + "Validation failed: Comment cannot exceed 3000 characters") + + evaluation_score.update!(comment: Faker::Lorem.characters(number: 3000)) + + expect do + evaluation_score.update!(comment_override: Faker::Lorem.characters(number: 3001)) + end.to raise_error(ActiveRecord::RecordInvalid, + "Validation failed: Comment override cannot exceed 3000 characters") + end + end +end diff --git a/spec/models/evaluation_spec.rb b/spec/models/evaluation_spec.rb new file mode 100644 index 00000000..69f4d480 --- /dev/null +++ b/spec/models/evaluation_spec.rb @@ -0,0 +1,93 @@ +require 'rails_helper' + +RSpec.describe Evaluation, type: :model do + let(:user) { create(:user, :evaluator) } + let(:evaluation_form) { create(:evaluation_form) } + let(:evaluation) { create(:evaluation, user:, evaluation_form:) } + + describe "associations" do + it "belongs to a user" do + expect(evaluation.user).to eq(user) + end + + it "belongs to an evaluation form" do + expect(evaluation.evaluation_form).to eq(evaluation_form) + end + end + + describe "validations" do + it "must have a user with a valid evaluator role" do + user_with_invalid_role = create(:user, :admin) + + expect do + create(:evaluation, evaluation_form: evaluation_form, user: user_with_invalid_role) + end.to raise_error(ActiveRecord::RecordInvalid, "Validation failed: User must have a valid evaluator role") + + user_with_valid_role = create(:user, :evaluator) + evaluation = create(:evaluation, evaluation_form: evaluation_form, user: user_with_valid_role) + + expect(evaluation).to be_valid + end + + it "is invalid if total_score < 0" do + expect do + evaluation.update!(total_score: -1) + end.to raise_error(ActiveRecord::RecordInvalid, + "Validation failed: Total score must be greater than or equal to 0") + end + + it "is valid if total_score > 0" do + evaluation.update!(total_score: 1) + expect(evaluation).to be_valid + end + + it "is valid if additional_comments length is 3000 or less" do + evaluation.update!(additional_comments: Faker::Lorem.characters(number: 3000)) + expect(evaluation).to be_valid + end + + it "is invalid if additional_comments length is greater than 3000" do + expect do + evaluation.update!(additional_comments: Faker::Lorem.characters(number: 3001)) + end.to raise_error(ActiveRecord::RecordInvalid, + "Validation failed: Additional comments cannot exceed 3000 characters") + end + + it "is valid if revision_comments length is 3000 or less" do + evaluation.update!(revision_comments: Faker::Lorem.characters(number: 3000)) + expect(evaluation).to be_valid + end + + it "is invalid if revision_comments length is greater than 3000" do + expect do + evaluation.update!(revision_comments: Faker::Lorem.characters(number: 3001)) + end.to raise_error(ActiveRecord::RecordInvalid, + "Validation failed: Revision comments cannot exceed 3000 characters") + end + + # TODO: Possibly check uniqueness with user and evaluation_form? + end + + describe "status" do + it "allows updating and checking the status" do + expect(evaluation).to be_not_started + expect(evaluation.status).to eq("not_started") + + evaluation.recused! + expect(evaluation).to be_recused + expect(evaluation.status).to eq("recused") + + evaluation.not_started! + expect(evaluation).to be_not_started + expect(evaluation.status).to eq("not_started") + + evaluation.in_progress! + expect(evaluation).to be_in_progress + expect(evaluation.status).to eq("in_progress") + + evaluation.completed! + expect(evaluation).to be_completed + expect(evaluation.status).to eq("completed") + end + end +end