Skip to content

Commit 39f45f7

Browse files
tuzzfloehopper
authored andcommitted
Add support for creating projects within a school’s library
If the user has the school-owner or school-teacher role, they can create a project that is associated with a school (but not a school class). If the user is a school-owner they can assign a teacher to the project in the same was as lessons, otherwise the school-teacher is always set as the project’s owner (user).
1 parent 3c141bb commit 39f45f7

File tree

9 files changed

+168
-23
lines changed

9 files changed

+168
-23
lines changed

app/controllers/api/lessons_controller.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ def lesson_params
7575
# A school owner must specify who the lesson user is.
7676
base_params
7777
else
78-
# A school teacher may only create classes they own.
78+
# A school teacher may only create lessons they own.
7979
base_params.merge(user_id: current_user.id)
8080
end
8181
end

app/controllers/api/projects_controller.rb

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,8 @@ class ProjectsController < ApiController
77
before_action :authorize_user, only: %i[create update index destroy]
88
before_action :load_project, only: %i[show update destroy]
99
before_action :load_projects, only: %i[index]
10-
after_action :pagination_link_header, only: [:index]
10+
after_action :pagination_link_header, only: %i[index]
1111
load_and_authorize_resource
12-
skip_load_resource only: :create
1312

1413
def index
1514
@paginated_projects = @projects.page(params[:page])
@@ -21,25 +20,23 @@ def show
2120
end
2221

2322
def create
24-
project_hash = project_params.merge(user_id: current_user&.id)
25-
result = Project::Create.call(project_hash:)
23+
result = Project::Create.call(project_hash: project_params)
2624

2725
if result.success?
2826
@project = result[:project]
29-
render :show, formats: [:json]
27+
render :show, formats: [:json], status: :created
3028
else
31-
render json: { error: result[:error] }, status: :internal_server_error
29+
render json: { error: result[:error] }, status: :unprocessable_entity
3230
end
3331
end
3432

3533
def update
36-
update_hash = project_params.merge(user_id: current_user&.id)
37-
result = Project::Update.call(project: @project, update_hash:)
34+
result = Project::Update.call(project: @project, update_hash: project_params)
3835

3936
if result.success?
4037
render :show, formats: [:json]
4138
else
42-
render json: { error: result[:error] }, status: :bad_request
39+
render json: { error: result[:error] }, status: :unprocessable_entity
4340
end
4441
end
4542

@@ -60,7 +57,19 @@ def load_projects
6057
end
6158

6259
def project_params
60+
if school_owner?
61+
# A school owner must specify who the project user is.
62+
base_params
63+
else
64+
# A school teacher may only create projects they own.
65+
base_params.merge(user_id: current_user&.id)
66+
end
67+
end
68+
69+
def base_params
6370
params.fetch(:project, {}).permit(
71+
:school_id,
72+
:user_id,
6473
:identifier,
6574
:name,
6675
:project_type,
@@ -72,6 +81,14 @@ def project_params
7281
)
7382
end
7483

84+
def school_owner?
85+
school && current_user.school_owner?(organisation_id: school.id)
86+
end
87+
88+
def school
89+
@school ||= @project&.school || School.find_by(id: base_params[:school_id])
90+
end
91+
7592
def pagination_link_header
7693
pagination_links = []
7794
pagination_links << page_links(first_page, 'first')

app/models/ability.rb

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,34 @@ class Ability
55

66
# rubocop:disable Metrics/AbcSize
77
def initialize(user)
8-
can :show, Project, user_id: nil
9-
can :show, Component, project: { user_id: nil }
8+
# Anyone can view projects not owner by a user or a school.
9+
can :show, Project, user_id: nil, school_id: nil
10+
can :show, Component, project: { user_id: nil, school_id: nil }
11+
12+
# Anyone can read publicly shared lessons.
1013
can :read, Lesson, visibility: 'public'
1114

1215
return unless user
1316

14-
can %i[read create update destroy], Project, user_id: user.id
15-
can %i[read create update destroy], Component, project: { user_id: user.id }
17+
# Any authenticated user can create projects not owned by a school.
18+
can :create, Project, user_id: user.id, school_id: nil
19+
can :create, Component, project: { user_id: user.id, school_id: nil }
20+
21+
# Any authenticated user can manage their own projects.
22+
can %i[read update destroy], Project, user_id: user.id
23+
can %i[read update destroy], Component, project: { user_id: user.id }
24+
25+
# Any authenticated user can create a school. They agree to become the school-owner.
26+
can :create, School
27+
28+
# Any authenticated user can create a lesson, to support a RPF library of public lessons.
29+
can :create, Lesson, school_id: nil, school_class_id: nil
30+
31+
# Any authenticated user can create a copy of a publicly shared lesson.
32+
can :create_copy, Lesson, visibility: 'public'
1633

17-
can :create, School # The user agrees to become a school-owner by creating a school.
18-
can :create, Lesson, school_id: nil, school_class_id: nil # Users can create shared lessons.
19-
can :create_copy, Lesson, visibility: 'public' # Users can create a copy of any public lesson.
20-
can %i[read create_copy update destroy], Lesson, user_id: user.id # Users can manage their own lessons.
34+
# Any authenticated user can manage their own lessons.
35+
can %i[read create_copy update destroy], Lesson, user_id: user.id
2136

2237
user.organisation_ids.each do |organisation_id|
2338
define_school_owner_abilities(organisation_id:) if user.school_owner?(organisation_id:)
@@ -38,6 +53,7 @@ def define_school_owner_abilities(organisation_id:)
3853
can(%i[read create create_batch update destroy], :school_student)
3954
can(%i[create create_copy], Lesson, school_id: organisation_id)
4055
can(%i[read update destroy], Lesson, school_id: organisation_id, visibility: %w[teachers students public])
56+
can(%i[create], Project, school_id: organisation_id)
4157
end
4258

4359
def define_school_teacher_abilities(user:, organisation_id:)
@@ -50,6 +66,7 @@ def define_school_teacher_abilities(user:, organisation_id:)
5066
can(%i[read create create_batch update], :school_student)
5167
can(%i[create destroy], Lesson) { |lesson| school_teacher_can_manage_lesson?(user:, organisation_id:, lesson:) }
5268
can(%i[read create_copy], Lesson, school_id: organisation_id, visibility: %w[teachers students])
69+
can(%i[create], Project, school_id: organisation_id, user_id: user.id)
5370
end
5471

5572
# rubocop:disable Layout/LineLength

app/models/project.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ class Project < ApplicationRecord
1818

1919
scope :internal_projects, -> { where(user_id: nil) }
2020

21+
# Work around a CanCanCan issue with accepts_nested_attributes_for.
22+
# https://github.com/CanCanCommunity/cancancan/issues/774
23+
def components=(array)
24+
super(array.map { |o| o.is_a?(Hash) ? Component.new(o) : o })
25+
end
26+
2127
private
2228

2329
def check_unique_not_null

spec/features/lesson/creating_a_lesson_spec.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
require 'rails_helper'
44

5-
RSpec.describe 'Creating a public lesson', type: :request do
5+
RSpec.describe 'Creating a lesson', type: :request do
66
before do
77
stub_hydra_public_api
88
stub_user_info_api

spec/features/lesson/showing_a_lesson_spec.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@
123123
expect(response).to have_http_status(:ok)
124124
end
125125

126-
it "responds 403 Forbidden when the user is not a school-student within the lesson's class" do
126+
it "responds 403 Forbidden when the user is a school-student but isn't within the lesson's class" do
127127
stub_hydra_public_api(user_index: user_index_by_role('school-student'))
128128

129129
get("/api/lessons/#{lesson.id}", headers:)
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# frozen_string_literal: true
2+
3+
require 'rails_helper'
4+
5+
RSpec.describe 'Creating a project', type: :request do
6+
before do
7+
stub_hydra_public_api
8+
mock_phrase_generation
9+
end
10+
11+
let(:headers) { { Authorization: UserProfileMock::TOKEN } }
12+
13+
let(:params) do
14+
{
15+
project: {
16+
name: 'Test Project',
17+
components: [
18+
{ name: 'main', extension: 'py', content: 'print("hi")' }
19+
]
20+
}
21+
}
22+
end
23+
24+
it 'responds 201 Created' do
25+
post('/api/projects', headers:, params:)
26+
expect(response).to have_http_status(:created)
27+
end
28+
29+
it 'responds with the project JSON' do
30+
post('/api/projects', headers:, params:)
31+
data = JSON.parse(response.body, symbolize_names: true)
32+
33+
expect(data[:name]).to eq('Test Project')
34+
end
35+
36+
it 'responds with the components JSON' do
37+
post('/api/projects', headers:, params:)
38+
data = JSON.parse(response.body, symbolize_names: true)
39+
40+
expect(data[:components].first[:content]).to eq('print("hi")')
41+
end
42+
43+
it 'responds 422 Unprocessable Entity when params are invalid' do
44+
post('/api/projects', headers:, params: { project: { components: [{ name: ' ' }] } })
45+
expect(response).to have_http_status(:unprocessable_entity)
46+
end
47+
48+
it 'responds 401 Unauthorized when no token is given' do
49+
post('/api/projects', params:)
50+
expect(response).to have_http_status(:unauthorized)
51+
end
52+
53+
context 'when the project is associated with a school (library)' do
54+
let(:school) { create(:school) }
55+
let(:teacher_index) { user_index_by_role('school-teacher') }
56+
let(:teacher_id) { user_id_by_index(teacher_index) }
57+
58+
let(:params) do
59+
{
60+
project: {
61+
name: 'Test Project',
62+
components: [],
63+
school_id: school.id,
64+
user_id: teacher_id
65+
}
66+
}
67+
end
68+
69+
it 'responds 201 Created' do
70+
post('/api/projects', headers:, params:)
71+
expect(response).to have_http_status(:created)
72+
end
73+
74+
it 'responds 201 Created when the user is a school-teacher for the school' do
75+
stub_hydra_public_api(user_index: user_index_by_role('school-teacher'))
76+
77+
post('/api/projects', headers:, params:)
78+
expect(response).to have_http_status(:created)
79+
end
80+
81+
it 'sets the lesson user to the specified user for school-owner users' do
82+
post('/api/projects', headers:, params:)
83+
data = JSON.parse(response.body, symbolize_names: true)
84+
85+
expect(data[:user_id]).to eq(teacher_id)
86+
end
87+
88+
it 'sets the project user to the current user for school-teacher users' do
89+
stub_hydra_public_api(user_index: teacher_index)
90+
new_params = { project: params[:project].merge(user_id: 'ignored') }
91+
92+
post('/api/projects', headers:, params: new_params)
93+
data = JSON.parse(response.body, symbolize_names: true)
94+
95+
expect(data[:user_id]).to eq(teacher_id)
96+
end
97+
98+
it 'responds 403 Forbidden when the user is a school-owner for a different school' do
99+
school.update!(id: SecureRandom.uuid)
100+
101+
post('/api/projects', headers:, params:)
102+
expect(response).to have_http_status(:forbidden)
103+
end
104+
end
105+
end

spec/requests/projects/create_spec.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
it 'returns success' do
2121
post('/api/projects', headers:)
2222

23-
expect(response).to have_http_status(:ok)
23+
expect(response).to have_http_status(:created)
2424
end
2525
end
2626

@@ -36,7 +36,7 @@
3636
it 'returns error' do
3737
post('/api/projects', headers:)
3838

39-
expect(response).to have_http_status(:internal_server_error)
39+
expect(response).to have_http_status(:unprocessable_entity)
4040
end
4141
end
4242
end

spec/requests/projects/update_spec.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@
7171

7272
it 'returns error response' do
7373
put("/api/projects/#{project.identifier}", params:, headers:)
74-
expect(response).to have_http_status(:bad_request)
74+
expect(response).to have_http_status(:unprocessable_entity)
7575
end
7676
end
7777
end

0 commit comments

Comments
 (0)