Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[CPDNPQ-2225] qualifications API for TRS - part of DQT decommissioning #2115

Merged
merged 16 commits into from
Jan 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -237,15 +237,15 @@ endef
aks-download-tmp-file: get-cluster-credentials
$(SET_APP_ID_FROM_PULL_REQUEST_NUMBER)
$(if $(FILENAME), , $(error Usage: FILENAME=restart.txt make staging aks-download-tmp-file))
kubectl get pods -n ${NAMESPACE} -l app=npq-registration-${APP_ID}-web -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}' | xargs -I {} sh -c 'mkdir -p {}/ && kubectl cp ${NAMESPACE}/{}:/app/tmp/${FILENAME} {}/${FILENAME}'
kubectl get pods -n ${NAMESPACE} -l app=npq-registration-${APP_ID}-worker -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}' | xargs -I {} sh -c 'mkdir -p {}/ && kubectl cp ${NAMESPACE}/{}:/app/tmp/${FILENAME} {}/${FILENAME}'

# uploads the given file to the app/tmp directory of all
# pods in the cluster.
## ie: FILENAME=local_file.txt make staging aks-upload-tmp-file
aks-upload-tmp-file: get-cluster-credentials
$(SET_APP_ID_FROM_PULL_REQUEST_NUMBER)
$(if $(FILENAME), , $(error Usage: FILENAME=restart.txt make staging aks-upload-tmp-file))
kubectl get pods -n ${NAMESPACE} -l app=npq-registration-${APP_ID}-web -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}' | xargs -I {} kubectl cp ${FILENAME} ${NAMESPACE}/{}:/app/tmp/${FILENAME}
kubectl get pods -n ${NAMESPACE} -l app=npq-registration-${APP_ID}-worker -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}' | xargs -I {} kubectl cp ${FILENAME} ${NAMESPACE}/{}:/app/tmp/${FILENAME}

maintenance-image-push: ## Build and push maintenance page image: make production maintenance-image-push GITHUB_TOKEN=x [MAINTENANCE_IMAGE_TAG=y]
$(if ${GITHUB_TOKEN},, $(error Provide a valid Github token with write:packages permissions as GITHUB_TOKEN variable))
Expand Down
4 changes: 4 additions & 0 deletions app/controllers/api/base_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,9 @@ def bad_request_response(exception)
def filter_validation_error_response(exception)
render json: { errors: API::Errors::Response.new(error: I18n.t(:unpermitted_parameters), params: exception.message).call }, status: :unprocessable_entity
end

def api_token_scope
APIToken.scopes[:lead_provider]
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
module API
module TeacherRecordService
module V1
class QualificationsController < BaseController
def show
participant_outcomes = participant_outcome_query

render json: to_json(participant_outcomes)
end

private

def trn
params[:trn]
end

def participant_outcome_query
Qualifications::Query.new.qualifications(trn:)
end

def to_json(participant_outcomes)
QualificationsSerializer.render(trn, root: "data", participant_outcomes:)
end

def api_token_scope
APIToken.scopes[:teacher_record_service]
end
end
end
end
end
2 changes: 1 addition & 1 deletion app/controllers/concerns/api/token_authenticatable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ def authenticate

def authenticate_token
authenticate_with_http_token do |unhashed_token|
@current_api_token = APIToken.find_by_unhashed_token(unhashed_token).tap do |api_token|
@current_api_token = APIToken.find_by_unhashed_token(unhashed_token, scope: api_token_scope).tap do |api_token|
api_token.update!(last_used_at: Time.zone.now) if api_token
end
end
Expand Down
2 changes: 1 addition & 1 deletion app/jobs/stream_api_requests_to_big_query_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def auth_token(auth_header)
return if auth_header.blank?

token, _options = token_and_options(AuthorizationStruct.new(auth_header))
APIToken.find_by_unhashed_token(token)
APIToken.find_by_unhashed_token(token, scope: APIToken.scopes[:lead_provider])
end

def response_hash(response_body, status)
Expand Down
18 changes: 13 additions & 5 deletions app/models/api_token.rb
Original file line number Diff line number Diff line change
@@ -1,21 +1,29 @@
class APIToken < ApplicationRecord
belongs_to :lead_provider
belongs_to :lead_provider, optional: true

enum scope: {
lead_provider: "lead_provider",
teacher_record_service: "teacher_record_service",
}

validates :hashed_token, presence: true
validates :scope, presence: true
validates :lead_provider, presence: true, if: -> { scope == APIToken.scopes[:lead_provider] }

def self.create_with_random_token!(**options)
unhashed_token, hashed_token = Devise.token_generator.generate(APIToken, :hashed_token)
create!(hashed_token:, **options)
unhashed_token
end

def self.find_by_unhashed_token(unhashed_token)
def self.find_by_unhashed_token(unhashed_token, scope:)
hashed_token = Devise.token_generator.digest(APIToken, :hashed_token, unhashed_token)
find_by(hashed_token:)
find_by(hashed_token:, scope:)
end

def self.create_with_known_token!(token, **options)
# only used in specs and seeds
def self.create_with_known_token!(token, scope: scopes[:lead_provider], **options)
hashed_token = Devise.token_generator.digest(APIToken, :hashed_token, token)
find_or_create_by!(hashed_token:, **options)
find_or_create_by!(hashed_token:, scope:, **options)
end
end
4 changes: 4 additions & 0 deletions app/models/legacy_passed_participant_outcome.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
class LegacyPassedParticipantOutcome < ApplicationRecord
# Data imported from the decommissioned DQT service
# https://dfedigital.atlassian.net/browse/CPDNPQ-2116
end
2 changes: 2 additions & 0 deletions app/models/participant_outcome.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ class ParticipantOutcome < ApplicationRecord
validate :completion_date_not_in_the_future

delegate :user, :lead_provider, :course, :application_id, to: :declaration
delegate :trn, to: :user
delegate :short_code, to: :course, prefix: true

enum state: {
passed: "passed",
Expand Down
16 changes: 16 additions & 0 deletions app/serializers/api/qualifications_serializer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
module API
class QualificationsSerializer < Blueprinter::Base
field :trn do |object, _options|
object
end
rwrrll marked this conversation as resolved.
Show resolved Hide resolved

class AttributesSerializer < Blueprinter::Base
field :completion_date, name: :award_date, datetime_format: "%Y-%m-%d"
field :course_short_code, name: :npq_type
end

association :qualifications, blueprint: AttributesSerializer do |_object, options|
options[:participant_outcomes]
end
end
end
24 changes: 24 additions & 0 deletions app/services/qualifications/query.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
module Qualifications
class Query
def qualifications(trn:)
(participant_outcomes(trn:) + legacy_outcomes(trn:)).uniq { |outcome|
[outcome.trn, outcome.completion_date, outcome.course_short_code]
}.sort_by(&:completion_date).reverse
end

private

def participant_outcomes(trn:)
ParticipantOutcome
.includes(declaration: [application: %i[course user]])
.where(state: "passed")
.joins(declaration: [application: :user])
.where("users.trn": trn)
.order(completion_date: :desc)
end

def legacy_outcomes(trn:)
LegacyPassedParticipantOutcome.where(trn:)
end
end
end
7 changes: 7 additions & 0 deletions config/analytics.yml
Original file line number Diff line number Diff line change
Expand Up @@ -259,3 +259,10 @@ shared:
- email_updates_unsubscribe_key
- archived_at
- significantly_updated_at
:legacy_passed_participant_outcomes:
- id
- trn
- course_short_code
- completion_date
- created_at
- updated_at
1 change: 1 addition & 0 deletions config/analytics_blocklist.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ shared:
- last_used_at
- created_at
- updated_at
- scope
:admins:
- id
- email
Expand Down
6 changes: 6 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,12 @@

resources :statements, only: %i[index show], param: :ecf_id
end

namespace :teacher_record_service, path: "teacher-record-service", defaults: { format: :json } do
namespace :v1 do
resources :qualifications, only: %i[show], param: :trn
end
end
end

namespace :npq_separation, path: "npq-separation" do
Expand Down
19 changes: 19 additions & 0 deletions db/migrate/20241104164154_add_scope_to_api_tokens.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
class AddScopeToAPITokens < ActiveRecord::Migration[7.1]
def change
create_enum :api_token_scopes, %w[lead_provider teacher_record_service]

add_column :api_tokens, :scope, :enum, enum_type: "api_token_scopes", default: "lead_provider"
change_column_null :api_tokens, :lead_provider_id, true

add_check_constraint(
:api_tokens,
"(lead_provider_id IS NOT NULL AND scope = 'lead_provider') OR (lead_provider_id IS NULL AND scope <> 'lead_provider')",
)

reversible do |direction|
direction.down do
APIToken.where(lead_provider: nil).destroy_all
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
class CreateLegacyPassedParticipantOutcomes < ActiveRecord::Migration[7.1]
def change
create_table :legacy_passed_participant_outcomes do |t|
t.string :trn, null: false
t.string :course_short_code, null: false
t.date :completion_date, null: false
t.timestamps
end
add_index :legacy_passed_participant_outcomes, :trn
end
end
14 changes: 13 additions & 1 deletion db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

# Custom types defined in this database.
# Note that some types may not work with other database engines. Be careful if changing database.
create_enum "api_token_scopes", ["lead_provider", "teacher_record_service"]
create_enum "application_statuses", ["active", "deferred", "withdrawn"]
create_enum "declaration_state_reasons", ["duplicate"]
create_enum "declaration_states", ["submitted", "eligible", "payable", "paid", "voided", "ineligible", "awaiting_clawback", "clawed_back"]
Expand Down Expand Up @@ -70,13 +71,15 @@
end

create_table "api_tokens", force: :cascade do |t|
t.bigint "lead_provider_id", null: false
t.bigint "lead_provider_id"
alkesh marked this conversation as resolved.
Show resolved Hide resolved
t.string "hashed_token", null: false
t.datetime "last_used_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.enum "scope", default: "lead_provider", enum_type: "api_token_scopes"
t.index ["hashed_token"], name: "index_api_tokens_on_hashed_token", unique: true
t.index ["lead_provider_id"], name: "index_api_tokens_on_lead_provider_id"
t.check_constraint "lead_provider_id IS NOT NULL AND scope = 'lead_provider'::api_token_scopes OR lead_provider_id IS NULL AND scope <> 'lead_provider'::api_token_scopes"
end

create_table "application_states", force: :cascade do |t|
Expand Down Expand Up @@ -337,6 +340,15 @@
t.index ["ecf_id"], name: "index_lead_providers_on_ecf_id", unique: true
end

create_table "legacy_passed_participant_outcomes", force: :cascade do |t|
t.string "trn", null: false
t.string "course_short_code", null: false
t.date "completion_date", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["trn"], name: "index_legacy_passed_participant_outcomes_on_trn"
end

create_table "local_authorities", force: :cascade do |t|
t.text "ukprn"
t.text "name"
Expand Down
2 changes: 2 additions & 0 deletions db/seeds/base/add_api_tokens.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@
lead_provider = LeadProvider.where("name LIKE ?", "#{name}%").first!
APIToken.create_with_known_token!(token, lead_provider:)
end

APIToken.create_with_known_token!("trs-token", scope: "teacher_record_service")
52 changes: 52 additions & 0 deletions docs/qualifications_api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
[< Back to Navigation](../README.md)

# Qualifications API

## Overview

The Teaching Regulation Agency (TRA) is decommissioning the Database of Qualified Teachers (DQT) which currently holds NPQ qualifications data.
The replacement, the Teacher Record Service (TRS), will not include NPQ qualification data.

NPQ Registration has an API that replaces this - the Qualifications API.
This will return NPQ qualifications for a given user, based upon their Teacher Reference Number (TRN).
It returns both qualifications that are currently held in NPQ registration and those that were previously stored in DQT.

## API usage

To get qualifications for a TRN:

`GET /api/teacher-record-service/v1/qualifications/1000207`

Example response:

``` json
{"data":
{"trn":"1000207",
"qualifications":[
{"award_date":"2023-10-01","npq_type":"NPQEYL"},
{"award_date":"2023-10-01","npq_type":"NPQSL"},
{"award_date":"2021-10-01","npq_type":"NPQLBC"},
{"award_date":"2021-10-01","npq_type":"NPQSENCO"},
{"award_date":"2020-10-01","npq_type":"NPQSL"}
]
}
}
```
note: there can be multiple passed qualifications for the same npq_type (in the above example, NPQSL has been passed twice).

If no qualifications are found for the TRN, the response will be:

``` json
{"data":
{"trn":"1000207",
"qualifications":[]
}
}
```

## Authentication

This API uses bearer token authentication. The bearer token is passed in the `Authorization` header:
```
Authorization: Bearer <token>
```
11 changes: 11 additions & 0 deletions lib/tasks/api_token.rake
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace :api_token do
namespace :teacher_record_service do
desc "Generate a new API token for the Teacher Record Service"
task generate_token: :environment do
scope = APIToken.scopes[:teacher_record_service]
logger = Logger.new($stdout)
token = APIToken.create_with_random_token!(scope:)
logger.info "API Token created: #{token}"
end
end
end
54 changes: 54 additions & 0 deletions lib/tasks/one_off/load_legacy_participant_outcomes.rake
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
require "csv"

namespace :one_off do
namespace :legacy_participant_outcomes do
desc "Import DQT data from CSV file (has header: trn,npq_type,awarded_date and the date is in format m/d/Y)"
task :import, %i[file_path truncate dry_run] => :environment do |_t, args|
logger = Logger.new($stdout)
file_path = args[:file_path]
dry_run = args[:dry_run] != "false"
truncate = args[:truncate] == "true"
unless File.exist?(file_path)
logger.error "File not found: #{file_path}"
exit 1
end

ActiveRecord::Base.transaction do
if truncate
logger.info "Removing #{LegacyPassedParticipantOutcome.count} old records"
LegacyPassedParticipantOutcome.destroy_all
end

dqt_npq_type_id_to_npq_short_code = {
"389040001" => "NPQH",
"389040004" => "NPQSL",
"389040005" => "NPQML", # this short code does not exist in NPQ (NPQ for Middle Leadership)
"389040006" => "NPQEL",
"389040007" => "NPQLT",
"389040008" => "NPQLTD",
"389040009" => "NPQLBC",
"389040010" => "NPQEYL",
"389040011" => "NPQLL",
}

logger.info "Importing file #{file_path}"
CSV.foreach(File.expand_path(file_path), headers: true, header_converters: :symbol) do |row|
course_short_code = dqt_npq_type_id_to_npq_short_code[row[:npq_type]]
raise "Unknown NPQ type: #{row[:npq_type]}" unless course_short_code

LegacyPassedParticipantOutcome.create!(
trn: row[:trn],
course_short_code: course_short_code,
completion_date: Date.strptime(row[:awarded_date], "%m/%d/%Y"),
)
end

logger.info "Rows loaded, now committing transaction (may take a few minutes)" unless dry_run
raise ActiveRecord::Rollback if dry_run
end

logger.info "Import finished"
logger.info "#{LegacyPassedParticipantOutcome.count} records imported"
end
end
end
Loading
Loading