Skip to content

Commit

Permalink
Add Vacancy Poster integration
Browse files Browse the repository at this point in the history
  • Loading branch information
ddippolito committed Oct 22, 2023
1 parent 6dba22c commit f92a59f
Show file tree
Hide file tree
Showing 6 changed files with 338 additions and 0 deletions.
1 change: 1 addition & 0 deletions .env.development
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ VACANCY_SOURCE_FUSION_FEED_URL=http://example.com/feed.json
VACANCY_SOURCE_EVERY_FEED_URL=http://example.com/feed.json
VACANCY_SOURCE_MY_NEW_TERM_FEED_URL=https://example.com
VACANCY_SOURCE_BROADBEAN_FEED_URL=http://example.com/feed.xml
VACANCY_SOURCE_VACANCY_POSTER_FEED_URL=http://example.com/feed.xml
VACANCY_SOURCE_MY_NEW_TERM_API_KEY=api_key
1 change: 1 addition & 0 deletions .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ VACANCY_SOURCE_EVERY_FEED_URL=http://example.com/feed.json
VACANCY_SOURCE_MY_NEW_TERM_FEED_URL=http://example.com
VACANCY_SOURCE_MY_NEW_TERM_API_KEY=test-api-key
VACANCY_SOURCE_BROADBEAN_FEED_URL=http://example.com/feed.xml
VACANCY_SOURCE_VACANCY_POSTER_FEED_URL=http://example.com/feed.xml
BIGQUERY_TABLE_NAME=test_events
BIGQUERY_PROJECT_ID=teacher-vacancy-service
BIGQUERY_DATASET=testing_dataset
Expand Down
1 change: 1 addition & 0 deletions app/jobs/import_from_vacancy_sources_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ class ImportFromVacancySourcesJob < ApplicationJob
VacancySource::Source::Fusion,
VacancySource::Source::MyNewTerm,
VacancySource::Source::UnitedLearning,
VacancySource::Source::VacancyPoster,
VacancySource::Source::Ventrus,
].freeze

Expand Down
128 changes: 128 additions & 0 deletions app/vacancy_sources/vacancy_source/source/vacancy_poster.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
require "cgi"

class VacancySource::Source::VacancyPoster
FEED_URL = ENV.fetch("VACANCY_SOURCE_VACANCY_POSTER_FEED_URL").freeze
VENTRUS_TRUST_UID = "".freeze
SOURCE_NAME = "vacancy_poster".freeze

class FeedItem
def initialize(xml_node)
@xml_node = xml_node
end

def [](key)
@xml_node.xpath(key)&.text&.presence
end
end

include Enumerable

def self.source_name
SOURCE_NAME
end

def each
items.each do |item|
v = Vacancy.find_or_initialize_by(
external_source: SOURCE_NAME,
external_reference: item["reference"],
)

# An external vacancy is by definition always published
v.status = :published
# Consider publish_on date to be the first time we saw this vacancy come through
# (i.e. today, unless it already has a publish on date set)
v.publish_on ||= Date.today

begin
v.assign_attributes(attributes_for(item))
rescue ArgumentError => e
v.errors.add(:base, e)
end

yield v
end
end

def attributes_for(item)
{
job_title: item["jobTitle"],
job_advert: job_advert_for(item),
salary: item["salary"],
expires_at: Time.zone.parse(item["expiresAt"]),
external_advert_url: item["advertUrl"],

job_roles: job_roles_for(item),
ect_status: ect_status_for(item),
key_stages: item["keyStages"].presence&.split(","),
subjects: item["subjects"].presence&.split(","),
working_patterns: item["workingPatterns"].presence&.split(","),
contract_type: item["contractType"].presence,
phases: phase_for(item),
visa_sponsorship_available: false,
}.merge(organisation_fields(item))
end

def job_advert_for(item)
sanitised_text = Rails::Html::WhiteListSanitizer.new.sanitize(item["jobAdvert"], tags: %w[p br])
CGI.unescapeHTML(sanitised_text)
end

def ect_status_for(item)
return unless item["ectSuitable"].presence

item["ectSuitable"] == "yes" ? "ect_suitable" : "ect_unsuitable"
end

def organisation_fields(item)
schools = find_schools(item)
first_school = schools.first

{
organisations: schools,
readable_job_location: first_school&.name,
about_school: first_school&.description,
}
end

def find_schools(item)
multi_academy_trust = SchoolGroup.trusts.find_by(uid: item["trustUID"])

multi_academy_trust&.schools&.where(urn: item["schoolUrns"]).presence ||
Organisation.where(urn: item["schoolUrns"]).presence ||
Array(multi_academy_trust)
end

def job_roles_for(item)
role = item["jobRole"]&.strip
return [] if role.blank?

# Translate legacy senior/middle leader into all the granular roles split from them
return Vacancy::SENIOR_LEADER_JOB_ROLES if role.include? "senior_leader"
return Vacancy::MIDDLE_LEADER_JOB_ROLES if role.include? "middle_leader"

Array.wrap(role.gsub("deputy_headteacher_principal", "deputy_headteacher")
.gsub("assistant_headteacher_principal", "assistant_headteacher")
.gsub("headteacher_principal", "headteacher")
.gsub(/head_of_year_or_phase|head_of_year/, "head_of_year_or_phase")
.gsub(/learning_support|other_support|science_technician/, "education_support")
.gsub(/\s+/, ""))
end

def phase_for(item)
return if item["phase"].blank?

item["phase"].strip
.parameterize(separator: "_")
.gsub("through_school", "through")
.gsub(/16-19|16_19/, "sixth_form_or_college")
end

def items
feed.xpath("//item").map { |fi| FeedItem.new(fi) }
end

def feed
@feed ||= Nokogiri::XML(HTTParty.get(FEED_URL).body)
end
end
26 changes: 26 additions & 0 deletions spec/fixtures/files/vacancy_sources/vacancy_poster.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<channel>
<title>ATS Integration RSS Feed</title>
<link>https://example.com</link>
<description>Ats integration RSS Feed</description>
<item>
<reference><![CDATA[coventrycc/TP/123/1234]]></reference>
<advertUrl><![CDATA[https://jobs.test.gov.uk/members/?j=1234]]></adverturl>
<publishOn>2023-06-06 00:00:00</publishOn>
<expiresAt>2023-07-05 00:00:00</expiresAt>
<jobTitle><![CDATA[Teacher (SEN) - Castle Wood School]]></jobTitle>
<jobAdvert><![CDATA[&lt;b&gt;What is the job role?&lt;/b&gt;&lt;br/&gt;&lt;p&gt;Castle Wood is an outstanding special school.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;]]></jobAdvert>
<salary><![CDATA[TMS and UPS with SEN allowance]]></salary>
<additionalAllowances><![CDATA[TMS and UPS with SEN allowance]]></additionalAllowances>
<startDate>2023-06-06 00:00:00</startDate>
<schoolUrns><![CDATA[111111]]></schoolUrns>
<trustUID><![CDATA[]]></trustUID>
<jobRole><![CDATA[sendco]]></jobRole>
<ectSuitable><![CDATA[false]]></ectSuitable>
<workingPatterns><![CDATA[part_time]]></workingPatterns>
<contractType><![CDATA[fixed_term]]></contractType>
<phase><![CDATA[primary]]></phase>
<keyStages><![CDATA[ks3]]></keyStages>
<subjects><![CDATA[]]></subjects>
</item>
</channel>
181 changes: 181 additions & 0 deletions spec/vacancy_sources/vacancy_source/source/vancancy_poster_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
require "rails_helper"

RSpec.describe VacancySource::Source::VacancyPoster do
let(:response_body) { file_fixture("vacancy_sources/vacancy_poster.xml").read }
let(:response) { double("VacancyPosterHttpResponse", success?: true, body: response_body) }

let!(:school1) { create(:school, name: "Test School", urn: "111111", phase: :primary) }
let(:schools) { [school1] }

describe "enumeration" do
let(:vacancy) { subject.first }
let(:job_role) { "sendco" }

let(:expected_vacancy) do
{
job_title: "Teacher (SEN) - Castle Wood School",
job_advert: "<b>What is the job role?</b><br/><p>Castle Wood is an outstanding special school.</em></strong></p>",
salary: "TMS and UPS with SEN allowance",
job_roles: [job_role],
key_stages: ["ks3"],
working_patterns: %w[part_time],
contract_type: "fixed_term",
phases: %w[primary],
visa_sponsorship_available: false,
}
end

before do
expect(HTTParty).to receive(:get).with("http://example.com/feed.xml").and_return(response)
end

it "has the correct number of vacancies" do
expect(subject.count).to eq(1)
end

it "yields vacancies with correct attributes" do
expect { |b| subject.each(&b) }.to yield_with_args(an_instance_of(Vacancy))
end

it "yield a newly built vacancy the correct vacancy information" do
expect(vacancy).not_to be_persisted
expect(vacancy).to be_changed
end

it "assigns correct attributes from the feed" do
expect(vacancy).to have_attributes(expected_vacancy)
end

it "assigns the vacancy to the correct school and organisation" do
expect(vacancy.organisations.first).to eq(school1)

expect(vacancy.external_source).to eq("vacancy_poster")
expect(vacancy.external_advert_url).to eq("https://jobs.test.gov.uk/members/?j=1234")
expect(vacancy.external_reference).to eq("coventrycc/TP/123/1234")

expect(vacancy.organisations).to eq(schools)
end

describe "job roles mapping" do
let(:response_body) { super().gsub("sendco", job_role) }

%w[deputy_headteacher_principal deputy_headteacher].each do |role|
context "when the source role is '#{role}'" do
let(:job_role) { role }

it "maps the source role to '[deputy_headteacher]' in the vacancy" do
expect(vacancy.job_roles).to eq(["deputy_headteacher"])
end
end
end

%w[assistant_headteacher_principal assistant_headteacher].each do |role|
context "when the source role is '#{role}'" do
let(:job_role) { role }

it "maps the source role to '[assistant_headteacher]' in the vacancy" do
expect(vacancy.job_roles).to eq(["assistant_headteacher"])
end
end
end

%w[headteacher_principal headteacher].each do |role|
context "when the source role is '#{role}'" do
let(:job_role) { role }

it "maps the source role to '[headteacher]' in the vacancy" do
expect(vacancy.job_roles).to eq(["headteacher"])
end
end
end

context "when the source role is 'senior_leader'" do
let(:job_role) { "senior_leader" }

it "maps the source role to '[headteacher, assistant_headteacher, deputy_headteacher]' in the vacancy" do
expect(vacancy.job_roles).to contain_exactly("headteacher", "assistant_headteacher", "deputy_headteacher")
end
end

%w[head_of_year_or_phase head_of_year].each do |role|
context "when the source role is '#{role}'" do
let(:job_role) { role }

it "maps the source role to '[head_of_year_or_phase]' in the vacancy" do
expect(vacancy.job_roles).to eq(["head_of_year_or_phase"])
end
end
end

context "when the source role is 'head_of_department_or_curriculum'" do
let(:job_role) { "head_of_department_or_curriculum" }

it "maps the source role to '[head_of_department_or_curriculum]' in the vacancy" do
expect(vacancy.job_roles).to eq(["head_of_department_or_curriculum"])
end
end

context "when the source role is 'middle_leader'" do
let(:job_role) { "middle_leader" }

it "maps the source role to '[head_of_year_or_phase, head_of_department_or_curriculum]' in the vacancy" do
expect(vacancy.job_roles).to contain_exactly("head_of_year_or_phase", "head_of_department_or_curriculum")
end
end

%w[learning_support other_support science_technician].each do |role|
context "when the source role is '#{role}'" do
let(:job_role) { role }

it "maps the source role to '[education_support]' in the vacancy" do
expect(vacancy.job_roles).to eq(["education_support"])
end
end
end
end

describe "phase mapping" do
let(:response_body) { super().gsub("primary", phase) }

%w[16-19 16_19].each do |phase|
context "when the phase is '#{phase}'" do
let(:phase) { phase }

it "maps the phase to '[sixth_form_or_college]' in the vacancy" do
expect(vacancy.phases).to eq(["sixth_form_or_college"])
end
end
end

context "when the phase is 'through_school'" do
let(:phase) { "through_school" }

it "maps the phase to '[through]' in the vacancy" do
expect(vacancy.phases).to eq(["through"])
end
end
end

context "when the same vacancy has been imported previously" do
let!(:existing_vacancy) do
create(
:vacancy,
:external,
phases: %w[primary],
external_source: "vacancy_poster",
external_reference: "coventrycc/TP/123/1234",
organisations: schools,
job_title: "Out of date",
)
end

it "yields the existing vacancy with updated information" do
expect(vacancy.id).to eq(existing_vacancy.id)
expect(vacancy).to be_persisted
expect(vacancy).to be_changed

expect(vacancy.job_title).to eq("Teacher (SEN) - Castle Wood School")
end
end
end
end

0 comments on commit f92a59f

Please sign in to comment.