Skip to content

Commit 5fd7cd0

Browse files
Feature/859 team skill tracking over time (#890)
* Generate SkillSnapshot model * Make skills discardable * Rename SkillSnapshot to DepartmentSkillSnapshot, serialize skills as json and implement first logic that creates snapshots * Add explanatory comment to the department skill snapshot builder * Install and configure delayed_job_activerecord * Install and configure delayed cron job * Add delayed cron job that is executed once a month and reates department skills snapshots * Run rubocop fix * Rename field skills on department skill snapshot to department_skill_levels * Start writing domain specs for departments * Write test that tests creation of snapshots * Add delayed_job model to be able to read djs from console, make snapshot logic ignore people without a department and revert unwanted changes to old migration * Delete old migration test that is not needed anymore
1 parent 469581f commit 5fd7cd0

21 files changed

+216
-50
lines changed

Gemfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ gem 'countries'
1717
gem 'cssbundling-rails'
1818
gem 'csv'
1919
gem 'database_cleaner'
20+
gem 'delayed_cron_job'
21+
gem 'delayed_job_active_record'
2022
gem 'devise'
23+
gem 'discard', '~> 1.4'
2124
gem 'drb'
2225
gem 'faker'
2326
gem 'haml-rails'

Gemfile.lock

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,18 +141,29 @@ GEM
141141
database_cleaner-core (2.0.1)
142142
date (3.4.1)
143143
deep_merge (1.2.2)
144+
delayed_cron_job (0.9.0)
145+
fugit (>= 1.5)
146+
delayed_job (4.1.13)
147+
activesupport (>= 3.0, < 9.0)
148+
delayed_job_active_record (4.1.11)
149+
activerecord (>= 3.0, < 9.0)
150+
delayed_job (>= 3.0, < 5)
144151
devise (4.9.4)
145152
bcrypt (~> 3.0)
146153
orm_adapter (~> 0.1)
147154
railties (>= 4.1.0)
148155
responders
149156
warden (~> 1.2.3)
150157
diff-lcs (1.6.0)
158+
discard (1.4.0)
159+
activerecord (>= 4.2, < 9.0)
151160
docile (1.4.1)
152161
domain_name (0.6.20240107)
153162
dotenv (3.1.7)
154163
drb (2.2.1)
155164
erubi (1.13.1)
165+
et-orbi (1.2.11)
166+
tzinfo
156167
faker (3.5.1)
157168
i18n (>= 1.8.11, < 2)
158169
faraday (2.12.2)
@@ -164,6 +175,9 @@ GEM
164175
faraday-net_http (3.4.0)
165176
net-http (>= 0.5.0)
166177
ffi (1.17.1-x86_64-linux-gnu)
178+
fugit (1.11.1)
179+
et-orbi (~> 1, >= 1.2.11)
180+
raabro (~> 1.4)
167181
globalid (1.2.1)
168182
activesupport (>= 6.1)
169183
haml (6.3.0)
@@ -318,6 +332,7 @@ GEM
318332
public_suffix (6.0.1)
319333
puma (6.6.0)
320334
nio4r (~> 2.0)
335+
raabro (1.4.0)
321336
racc (1.8.1)
322337
rack (3.1.12)
323338
rack-protection (4.1.1)
@@ -534,7 +549,10 @@ DEPENDENCIES
534549
cssbundling-rails
535550
csv
536551
database_cleaner
552+
delayed_cron_job
553+
delayed_job_active_record
537554
devise
555+
discard (~> 1.4)
538556
dotenv
539557
drb
540558
faker

app/controllers/admin/unified_skills_controller.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,8 @@ def merge_skills(old_skill1, old_skill2)
7171
UnifiedSkill.create!(skill1_attrs: old_skill1.attributes, skill2_attrs: old_skill2.attributes,
7272
unified_skill_attrs: new_skill.attributes)
7373

74-
old_skill1.delete
75-
old_skill2.delete
74+
old_skill1.discard!
75+
old_skill2.discard!
7676

7777
new_skill
7878
end
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
class DepartmentSkillsSnapshotBuilder
2+
# This will create snapshots of all departments with their respective skills and levels.
3+
# Each snapshot is an instance of DepartmentSkillSnapshot which takes a hash of skills.
4+
# This hash has the format:
5+
# { <skill_id1> => [skill_level1, skill_level2], <skill_id2> => [skill_level1, skill_level2] }
6+
# The count of skill_levels per skill_id is equal to the count of people that have rated that
7+
# skill in a given department.
8+
9+
def snapshot_all_departments
10+
Person.where.not(department_id: nil).group_by(&:department_id).each do |department_id, people|
11+
department_skill_levels = PeopleSkill
12+
.where(person_id: people)
13+
.group_by(&:skill_id)
14+
.transform_values { |value| value.pluck(:level) }
15+
16+
DepartmentSkillSnapshot.create!(department_id:, department_skill_levels:)
17+
end
18+
end
19+
end

app/jobs/cron_job.rb

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
class CronJob < ApplicationJob
2+
class_attribute :cron_expression
3+
4+
class << self
5+
def schedule
6+
set(cron: cron_expression).perform_later unless scheduled?
7+
end
8+
9+
def remove
10+
delayed_job.destroy if scheduled?
11+
end
12+
13+
def scheduled?
14+
delayed_job.present?
15+
end
16+
17+
def delayed_job
18+
Delayed::Job
19+
.where('handler LIKE ?', "%job_class: #{name}%")
20+
.first
21+
end
22+
end
23+
end
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
class MonthlyDepartmentSkillsSnapshotJob < CronJob
2+
# Perform job on first day of month at 3am
3+
self.cron_expression = '0 3 1 * *'
4+
5+
def perform
6+
DepartmentSkillsSnapshotBuilder.new.snapshot_all_departments
7+
end
8+
end

app/models/delayed_job.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
class DelayedJob < ApplicationRecord
2+
end
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
class DepartmentSkillSnapshot < ApplicationRecord
2+
belongs_to :department
3+
4+
serialize :department_skill_levels, type: Hash, coder: JSON
5+
end

app/models/skill.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
#
1616

1717
class Skill < ApplicationRecord
18+
include Discard::Model
19+
1820
has_many :people_skills, dependent: :destroy
1921
has_many :people, through: :people_skills
2022
belongs_to :category
@@ -25,6 +27,8 @@ class Skill < ApplicationRecord
2527

2628
validates :title, presence: true, length: { maximum: 100 }, uniqueness: { scope: :category_id }
2729

30+
default_scope { kept }
31+
2832
scope :list, -> { order(:title) }
2933

3034
scope :default_set, -> { where(default_set: true) }

bin/delayed_job

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
#!/usr/bin/env ruby
2+
3+
require File.expand_path(File.join(File.dirname(__FILE__), '..', 'config', 'environment'))
4+
require 'delayed/command'
5+
Delayed::Command.new(ARGV).daemonize

config/application.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ class Application < Rails::Application
2222

2323
config.active_record.verify_foreign_keys_for_fixtures = false
2424

25+
config.active_job.queue_adapter = :delayed_job
26+
2527
# Bullet tries to add finish_at to insert statement, which does not exist anymore
2628
config.active_record.partial_inserts = true
2729

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
class CreateDepartmentSkillSnapshots < ActiveRecord::Migration[8.0]
2+
def change
3+
create_table :department_skill_snapshots do |t|
4+
t.references :department, null: false, foreign_key: true
5+
t.text :department_skill_levels
6+
7+
t.timestamps
8+
end
9+
end
10+
end
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
class AddDiscardedAtToSkills < ActiveRecord::Migration[8.0]
2+
def change
3+
add_column :skills, :discarded_at, :datetime
4+
add_index :skills, :discarded_at
5+
end
6+
end
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
class CreateDelayedJobs < ActiveRecord::Migration[8.0]
2+
def self.up
3+
create_table :delayed_jobs do |table|
4+
table.integer :priority, default: 0, null: false # Allows some jobs to jump to the front of the queue
5+
table.integer :attempts, default: 0, null: false # Provides for retries, but still fail eventually.
6+
table.text :handler, null: false # YAML-encoded string of the object that will do work
7+
table.text :last_error # reason for last failure (See Note below)
8+
table.datetime :run_at # When to run. Could be Time.zone.now for immediately, or sometime in the future.
9+
table.datetime :locked_at # Set when a client is working on this object
10+
table.datetime :failed_at # Set when all retries have failed (actually, by default, the record is deleted instead)
11+
table.string :locked_by # Who is working on this object (if locked)
12+
table.string :queue # The name of the queue this job is in
13+
table.timestamps null: true
14+
end
15+
16+
add_index :delayed_jobs, [:priority, :run_at], name: "delayed_jobs_priority"
17+
end
18+
19+
def self.down
20+
drop_table :delayed_jobs
21+
end
22+
end
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
class AddCronToDelayedJobs < ActiveRecord::Migration[8.0]
2+
def self.up
3+
add_column :delayed_jobs, :cron, :string
4+
end
5+
6+
def self.down
7+
remove_column :delayed_jobs, :cron
8+
end
9+
end

db/schema.rb

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
#
1111
# It's strongly recommended that you check this file into your version control system.
1212

13-
ActiveRecord::Schema[8.0].define(version: 2025_03_25_145055) do
13+
ActiveRecord::Schema[8.0].define(version: 2025_05_12_074440) do
1414
# These are extensions that must be enabled in order to support this database
1515
enable_extension "pg_catalog.plpgsql"
1616

@@ -93,6 +93,30 @@
9393
t.datetime "updated_at", precision: nil, null: false
9494
end
9595

96+
create_table "delayed_jobs", force: :cascade do |t|
97+
t.integer "priority", default: 0, null: false
98+
t.integer "attempts", default: 0, null: false
99+
t.text "handler", null: false
100+
t.text "last_error"
101+
t.datetime "run_at"
102+
t.datetime "locked_at"
103+
t.datetime "failed_at"
104+
t.string "locked_by"
105+
t.string "queue"
106+
t.datetime "created_at"
107+
t.datetime "updated_at"
108+
t.string "cron"
109+
t.index ["priority", "run_at"], name: "delayed_jobs_priority"
110+
end
111+
112+
create_table "department_skill_snapshots", force: :cascade do |t|
113+
t.bigint "department_id", null: false
114+
t.text "department_skill_levels"
115+
t.datetime "created_at", null: false
116+
t.datetime "updated_at", null: false
117+
t.index ["department_id"], name: "index_department_skill_snapshots_on_department_id"
118+
end
119+
96120
create_table "departments", force: :cascade do |t|
97121
t.string "name", null: false
98122
t.datetime "created_at", null: false
@@ -235,7 +259,9 @@
235259
t.datetime "created_at", precision: nil, null: false
236260
t.datetime "updated_at", precision: nil, null: false
237261
t.bigint "category_id"
262+
t.datetime "discarded_at"
238263
t.index ["category_id"], name: "index_skills_on_category_id"
264+
t.index ["discarded_at"], name: "index_skills_on_discarded_at"
239265
end
240266

241267
create_table "unified_skills", force: :cascade do |t|
@@ -247,6 +273,7 @@
247273
end
248274

249275
add_foreign_key "categories", "categories", column: "parent_id"
276+
add_foreign_key "department_skill_snapshots", "departments"
250277
add_foreign_key "language_skills", "people"
251278
add_foreign_key "people", "companies"
252279
add_foreign_key "project_technologies", "projects"

lib/tasks/jobs.rake

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
namespace :db do
2+
desc 'Schedule all cron jobs'
3+
task :schedule_jobs => :environment do
4+
# Need to load all jobs definitions in order to find subclasses
5+
glob = Rails.root.join('app', 'jobs', '**', '*_job.rb')
6+
Dir.glob(glob).each { |file| require file }
7+
CronJob.subclasses.each(&:schedule)
8+
end
9+
end
10+
11+
# invoke schedule_jobs automatically after every migration and schema load.
12+
%w(db:migrate db:schema:load).each do |task|
13+
Rake::Task[task].enhance do
14+
Rake::Task['db:schedule_jobs'].invoke
15+
end
16+
end

spec/controllers/unified_skills_controller_spec.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def check_merged_people_skill_values(merged_people_skill, original_people_skill)
2323
post :create, params: { unified_skill_form: { old_skill_id1: skill1.id, old_skill_id2: skill2.id, checked_conflicts: true, new_skill: new_skill } }
2424

2525
unified_skill = UnifiedSkill.find_by(skill1_attrs: skill1.attributes, skill2_attrs: skill2.attributes)
26-
expect(unified_skill.unified_skill_attrs.excluding('id', 'updated_at', 'created_at')).to eql(new_skill.stringify_keys)
26+
expect(unified_skill.unified_skill_attrs.excluding('id', 'updated_at', 'created_at', 'discarded_at')).to eql(new_skill.stringify_keys)
2727

2828
expect(Skill.find_by(id: skill1.id)).to be_nil
2929
expect(Skill.find_by(id: skill2.id)).to be_nil
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
require 'rails_helper'
2+
3+
describe PeopleSearch do
4+
it 'should create snapshot of all departments that have members' do
5+
DepartmentSkillsSnapshotBuilder.new.snapshot_all_departments
6+
7+
snapshots = DepartmentSkillSnapshot.all
8+
9+
expect(snapshots.count).to eql(Person.distinct.pluck(:department_id).count)
10+
11+
department = departments(:sys)
12+
snapshot = snapshots.find_by(department_id: department)
13+
14+
expect(snapshot).not_to be_nil
15+
16+
department_skill_levels = snapshot.department_skill_levels
17+
people_skills_of_department = PeopleSkill.joins(:person).where(people: {department_id: department.id})
18+
19+
expect(department_skill_levels.keys).to match_array(people_skills_of_department.distinct.pluck(:skill_id).map(&:to_s))
20+
department_skill_levels.each do |k, v|
21+
expect(people_skills_of_department.where(skill_id: k.to_i).pluck(:level)).to match_array(v)
22+
end
23+
end
24+
end

spec/migrations/create_department_spec.rb

Lines changed: 0 additions & 46 deletions
This file was deleted.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
require 'rails_helper'
2+
3+
RSpec.describe DepartmentSkillSnapshot, type: :model do
4+
it 'should correctly serialize department_skill_levels' do
5+
department_skill_snapshot = DepartmentSkillSnapshot.create!(department_id: Department.first.id, department_skill_levels: {"1" => [2, 2, 3], "2" => [3, 3, 1]})
6+
7+
expect(department_skill_snapshot.department_skill_levels.is_a? Hash).to eql(true)
8+
end
9+
end

0 commit comments

Comments
 (0)