Skip to content

Create models and controllers for lessons #241

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

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
126 commits
Select commit Hold shift + click to select a range
136ec45
Add a schools table
tuzz Feb 1, 2024
17b9d16
Add a School model
tuzz Feb 1, 2024
f628a1a
Add a school_classes table
tuzz Feb 1, 2024
643884c
Add a SchoolClass model
tuzz Feb 1, 2024
8376cc6
Add a class_members table
tuzz Feb 1, 2024
1c75c70
Add a ClassMember model
tuzz Feb 1, 2024
13e7428
Add a school#classes association
tuzz Feb 1, 2024
868d6c0
Add dependent: :destroy to school/classes/members assocations
tuzz Feb 1, 2024
bf6024f
Add a School::Create operation
tuzz Feb 5, 2024
dbc9708
Add a SchoolsController
tuzz Feb 5, 2024
40f9d36
Add address fields to School
tuzz Feb 5, 2024
cabbebb
Add the countries gem
tuzz Feb 5, 2024
2d22484
Validate that country_codes are ISO 3166-1 alpha-2
tuzz Feb 5, 2024
7028088
Respond with either 400 or 422 depending on the request params
tuzz Feb 6, 2024
0767275
Nest params inside a ‘school’ hash
tuzz Feb 6, 2024
6b9c372
Add the validation errors to the School::Create response object
tuzz Feb 6, 2024
1601f84
Fix specs after rebasing against the new User model
tuzz Feb 9, 2024
f9886d2
Change the users.json mock data to include the new school roles
tuzz Feb 9, 2024
a8d7946
Extract methods to stub the hydra/userinfo APIs into spec/support
tuzz Feb 9, 2024
457c218
Rename HydraPublicApiMock -> UserProfileMock
tuzz Feb 9, 2024
cbcac34
Remove support for stubbing Hydra via the Ruby client
tuzz Feb 9, 2024
a7767a5
Only allow school owners to create schools
tuzz Feb 9, 2024
1bbe4b0
Add a school#reference field
tuzz Feb 9, 2024
4de1bfd
Allow users to hold different roles in different organisations
tuzz Feb 10, 2024
fe65edd
Rename Userinfo -> UserInfo
tuzz Feb 10, 2024
adc0495
Add a User#token field for convenience
tuzz Feb 10, 2024
cd9ce91
Create organisations via the ProfileApiClient
tuzz Feb 10, 2024
73e08cc
Add SchoolClass#teacher and #student methods
tuzz Feb 10, 2024
bd078d6
Add a School#owner method
tuzz Feb 10, 2024
068f4e6
Validate the presence of the ‘school-owner’ role on school owners
tuzz Feb 10, 2024
09f9bc4
Validate the presence of the ‘school-teacher’ role on school_class te…
tuzz Feb 10, 2024
c851751
Delegate ClassMember#school to #school_class
tuzz Feb 10, 2024
5fc2195
Validate the presence of the ‘school-student’ role on class_member st…
tuzz Feb 10, 2024
577dfa1
Respond ‘201 Created’ rather than ‘200 OK’ on Schools#create
tuzz Feb 11, 2024
3817b4d
Add additional fields to schools JSON output
tuzz Feb 11, 2024
bf44ba2
Add a Schools#update controller action
tuzz Feb 11, 2024
38fdedf
Add a Schools#show controller action
tuzz Feb 11, 2024
049ce94
Add a Schools#index controller action
tuzz Feb 11, 2024
92c405f
Move features under spec/features/school/
tuzz Feb 11, 2024
d630425
Fix a failing spec
tuzz Feb 11, 2024
26a90fb
Prefer to use CanCanCan’s built-in #accessible_by method
tuzz Feb 11, 2024
4d1dc18
Fix confusing validation error messages
tuzz Feb 11, 2024
71056af
Add a SchoolClasses#create controller action
tuzz Feb 11, 2024
3eb6235
Add unit tests for School::Update
tuzz Feb 11, 2024
6d24b70
Fix foreign keys being integer IDs rather than UUIDs
tuzz Feb 12, 2024
cffd052
Add a SchoolClasses#update controller action
tuzz Feb 12, 2024
9645d6f
Extract the class teacher #update check into ability.rb
tuzz Feb 12, 2024
a9d07e3
Add a SchoolClasses#show controller action
tuzz Feb 12, 2024
d6e4827
Add a SchoolClasses#index controller action
tuzz Feb 12, 2024
ad7e3e1
Make the organisation ID from the profile app the ID of the school
tuzz Feb 12, 2024
136a42d
Remove unused let(:user_id) in spec/features/
tuzz Feb 12, 2024
c9eb4a8
Use better names for methods in UserProfileMock
tuzz Feb 12, 2024
c69b7c9
Add a ClassMembers#create controller action
tuzz Feb 12, 2024
f1e8ed7
Move school_class#students to ClassMember.students
tuzz Feb 12, 2024
49cdfdf
Add a ClassMember.with_students method
tuzz Feb 12, 2024
7616a9a
Add a ClassMember#with_student method
tuzz Feb 12, 2024
6ce4912
Add a ClassMembers#index controller action
tuzz Feb 12, 2024
d2bc85a
Replace SchoolClass#teacher with .teachers, .with_teachers and #with_…
tuzz Feb 12, 2024
673332c
Prefix student attributes with ‘student_’ in the JSON
tuzz Feb 12, 2024
9e350de
Include teacher_* in the JSON responses for SchoolClassesController
tuzz Feb 12, 2024
e71c30b
Remove the School#owner_id field
tuzz Feb 13, 2024
96fc889
Add a SchoolOwners#create controller action
tuzz Feb 13, 2024
b421a7b
Validate email address when inviting school owners
tuzz Feb 14, 2024
b1f2d8f
Add a SchoolTeachers#create controller action
tuzz Feb 14, 2024
1ee60f7
Fix organisations not being merged correctly into User attributes
tuzz Feb 14, 2024
06022fb
Add a SchoolStudents#create controller action
tuzz Feb 14, 2024
704a0f0
Add a SchoolOwners#destroy controller action
tuzz Feb 14, 2024
a549f1c
Respond with empty bodies for some controller actions
tuzz Feb 14, 2024
670b0a8
Add a SchoolTeachers#destroy controller action
tuzz Feb 14, 2024
a1951ab
Add a SchoolStudents#destroy controller action
tuzz Feb 14, 2024
2e73224
Remove some unnecessary UserInfoApi stubs in spec/features/
tuzz Feb 14, 2024
0b85a1e
Remove student ‘nickname’ and ‘picture’ fields from response JSON
tuzz Feb 14, 2024
54a56d1
Add ‘student_username’ to ClassMembers response JSON
tuzz Feb 14, 2024
7991feb
Distribute the ‘can read school’ ability to improve code consistency
tuzz Feb 14, 2024
9c89f1e
Add a SchoolStudents#update controller action
tuzz Feb 14, 2024
381479e
Don’t render the school student in the response after creation
tuzz Feb 14, 2024
e1d07ba
Prevent users being invited from unverified schools
tuzz Feb 15, 2024
001673e
Reorder methods in ProfileApiClient
tuzz Feb 15, 2024
c427e7c
Add a SchoolOwners#index controller action
tuzz Feb 15, 2024
246444c
Add a SchoolTeachers#index controller action
tuzz Feb 15, 2024
2c6ece6
Add a SchoolStudents#index controller action
tuzz Feb 15, 2024
df35bf3
Add a ClassMembers#destroy controller action
tuzz Feb 15, 2024
e25b705
Add a SchoolClasses#destroy controller action
tuzz Feb 15, 2024
ad6ef7d
Add a School#destroy controller action
tuzz Feb 15, 2024
fe278c4
Add the ‘roo’ gem for parsing spreadsheets
tuzz Feb 15, 2024
5e5901d
Add a SchoolStudents#create_batch controller action
tuzz Feb 17, 2024
96987aa
Add a lessons table
tuzz Feb 17, 2024
1516640
Add a Lesson model
tuzz Feb 18, 2024
aade1a7
Add a SchoolClass#lessons association
tuzz Feb 18, 2024
d416f0d
Allow lessons to optionally belong to schools
tuzz Feb 19, 2024
7b8479f
Add a Lessons#create controller action
tuzz Feb 19, 2024
2b15322
Split ability definitions into methods to fix Rubocop warnings
tuzz Feb 19, 2024
a0ea21d
Add Lesson.users, .with_users and #with_user methods
tuzz Feb 20, 2024
f039f84
Make some spec/feature tests slightly more robust
tuzz Feb 20, 2024
896d10a
Include the lesson’s user name in the JSON response
tuzz Feb 20, 2024
6025c03
Allow school-owner users to set the Lesson#user
tuzz Feb 20, 2024
218c1e1
Add a Lessons#show controller action
tuzz Feb 20, 2024
360d242
Validate the a lesson’s user is a school-owner or school-teacher if t…
tuzz Feb 21, 2024
fe5e449
Validate the a lesson’s user is the school-teacher for the school_class
tuzz Feb 21, 2024
97be5fd
Add a Lessons#index controller action
tuzz Feb 21, 2024
fc749de
Add a Lessons#update controller action
tuzz Feb 21, 2024
ef6458b
Add unit tests for Lesson::Create and Lesson::Update
tuzz Feb 21, 2024
95b8ec0
Prevent lessons from being destroyed
tuzz Feb 21, 2024
6532a24
Add Lesson#archive! and #unarchive! methods
tuzz Feb 21, 2024
e25c3f4
Add a Lessons#destroy controller action
tuzz Feb 21, 2024
2f64dfa
Add Lesson.archived and Lesson.unarchived scopes
tuzz Feb 21, 2024
b60eaae
Take archived_at into account when listing lessons
tuzz Feb 21, 2024
0d2280a
Add a lessons.copied_from column
tuzz Feb 21, 2024
cb94b98
Rename lessons.copied_from -> lessons.copied_from_id
tuzz Feb 22, 2024
79e4153
Add Lesson#parent and Lesson#copies associations
tuzz Feb 22, 2024
e171b58
Add a Lessons#create_copy controller action
tuzz Feb 22, 2024
7e6e30e
Reorder lines in the Project model for consistency with other models
tuzz Feb 23, 2024
d63e545
Add a projects.school_id column
tuzz Feb 23, 2024
a4655ef
Add School#projects and Project#school associations
tuzz Feb 23, 2024
160df23
Make the project jbuilder views more consistent with other views
tuzz Feb 23, 2024
73649e0
Add support for creating projects within a school’s library
tuzz Feb 23, 2024
c4e6239
Add a projects.lesson_id column
tuzz Feb 23, 2024
017f427
Add Lesson#projects and Project#lesson associations
tuzz Feb 23, 2024
298a506
Add Project.users, .with_users and #with_user methods
tuzz Feb 23, 2024
e17f1e2
Validate that the Project#user belongs to the same school as the project
tuzz Feb 23, 2024
3162671
Validate that the user association with a project matches the lesson …
tuzz Feb 25, 2024
6393df4
Add authorisation logic for creating projects in a school/lesson
tuzz Feb 25, 2024
cc13ea8
Add partial feature tests for updating a project
tuzz Feb 25, 2024
91a4c63
Merge branch 'main' into issues/239-Create_models_and_controllers_for…
sra405 Mar 5, 2024
fc3a48f
Merge branch 'main' into issues/239-Create_models_and_controllers_for…
sra405 Mar 5, 2024
ee5192f
fix(ability.rb): rubocop'd
Mar 5, 2024
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
103 changes: 103 additions & 0 deletions app/controllers/api/lessons_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# frozen_string_literal: true

module Api
class LessonsController < ApiController
before_action :authorize_user, except: %i[index show]
before_action :verify_school_class_belongs_to_school, only: :create
load_and_authorize_resource :lesson

def index
scope = params[:include_archived] == 'true' ? Lesson : Lesson.unarchived
@lessons_with_users = scope.accessible_by(current_ability).with_users
render :index, formats: [:json], status: :ok
end

def show
@lesson_with_user = @lesson.with_user
render :show, formats: [:json], status: :ok
end

def create
result = Lesson::Create.call(lesson_params:)

if result.success?
@lesson_with_user = result[:lesson].with_user
render :show, formats: [:json], status: :created
else
render json: { error: result[:error] }, status: :unprocessable_entity
end
end

def create_copy
result = Lesson::CreateCopy.call(lesson: @lesson, lesson_params:)

if result.success?
@lesson_with_user = result[:lesson].with_user
render :show, formats: [:json], status: :created
else
render json: { error: result[:error] }, status: :unprocessable_entity
end
end

def update
result = Lesson::Update.call(lesson: @lesson, lesson_params:)

if result.success?
@lesson_with_user = result[:lesson].with_user
render :show, formats: [:json], status: :ok
else
render json: { error: result[:error] }, status: :unprocessable_entity
end
end

def destroy
operation = params[:undo] == 'true' ? Lesson::Unarchive : Lesson::Archive
result = operation.call(lesson: @lesson)

if result.success?
head :no_content
else
render json: { error: result[:error] }, status: :unprocessable_entity
end
end

private

def verify_school_class_belongs_to_school
return if base_params[:school_class_id].blank?
return if school&.classes&.pluck(:id)&.include?(base_params[:school_class_id])

raise ParameterError, 'school_class_id does not correspond to school_id'
end

def lesson_params
if school_owner?
# A school owner must specify who the lesson user is.
base_params
else
# A school teacher may only create lessons they own.
base_params.merge(user_id: current_user.id)
end
end

def base_params
params.fetch(:lesson, {}).permit(
:school_id,
:school_class_id,
:user_id,
:name,
:description,
:visibility,
:due_date
)
end

def school_owner?
school && current_user.school_owner?(organisation_id: school.id)
end

def school
@school ||= @lesson&.school || School.find_by(id: base_params[:school_id])
end
end
end
44 changes: 35 additions & 9 deletions app/controllers/api/projects_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ class ProjectsController < ApiController
before_action :authorize_user, only: %i[create update index destroy]
before_action :load_project, only: %i[show update destroy]
before_action :load_projects, only: %i[index]
after_action :pagination_link_header, only: [:index]
load_and_authorize_resource
skip_load_resource only: :create
before_action :verify_lesson_belongs_to_school, only: :create
after_action :pagination_link_header, only: %i[index]

def index
@paginated_projects = @projects.page(params[:page])
Expand All @@ -21,25 +21,23 @@ def show
end

def create
project_hash = project_params.merge(user_id: current_user&.id)
result = Project::Create.call(project_hash:)
result = Project::Create.call(project_hash: project_params)

if result.success?
@project = result[:project]
render :show, formats: [:json]
render :show, formats: [:json], status: :created
else
render json: { error: result[:error] }, status: :internal_server_error
render json: { error: result[:error] }, status: :unprocessable_entity
end
end

def update
update_hash = project_params.merge(user_id: current_user&.id)
result = Project::Update.call(project: @project, update_hash:)
result = Project::Update.call(project: @project, update_hash: project_params)

if result.success?
render :show, formats: [:json]
else
render json: { error: result[:error] }, status: :bad_request
render json: { error: result[:error] }, status: :unprocessable_entity
end
end

Expand All @@ -50,6 +48,13 @@ def destroy

private

def verify_lesson_belongs_to_school
return if base_params[:lesson_id].blank?
return if school&.lessons&.pluck(:id)&.include?(base_params[:lesson_id])

raise ParameterError, 'lesson_id does not correspond to school_id'
end

def load_project
project_loader = ProjectLoader.new(params[:id], [params[:locale]])
@project = project_loader.load
Expand All @@ -60,7 +65,20 @@ def load_projects
end

def project_params
if school_owner?
# A school owner must specify who the project user is.
base_params
else
# A school teacher may only create projects they own.
base_params.merge(user_id: current_user&.id)
end
end

def base_params
params.fetch(:project, {}).permit(
:school_id,
:lesson_id,
:user_id,
:identifier,
:name,
:project_type,
Expand All @@ -72,6 +90,14 @@ def project_params
)
end

def school_owner?
school && current_user.school_owner?(organisation_id: school.id)
end

def school
@school ||= @project&.school || School.find_by(id: base_params[:school_id])
end

def pagination_link_header
pagination_links = []
pagination_links << page_links(first_page, 'first')
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/api/school_classes_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ def destroy

def school_class_params
if school_owner?
# The school owner must specify who the class teacher is.
# A school owner must specify who the class teacher is.
params.require(:school_class).permit(:teacher_id, :name)
else
# A school teacher may only create classes they own.
Expand Down
19 changes: 13 additions & 6 deletions app/controllers/api_controller.rb
Original file line number Diff line number Diff line change
@@ -1,29 +1,36 @@
# frozen_string_literal: true

class ApiController < ActionController::API
class ::ParameterError < StandardError; end

include Identifiable

unless Rails.application.config.consider_all_requests_local
rescue_from ActionController::ParameterMissing, with: -> { bad_request }
rescue_from ActiveRecord::RecordNotFound, with: -> { not_found }
rescue_from CanCan::AccessDenied, with: -> { denied }
rescue_from ParameterError, with: -> { unprocessable }
end

private

def bad_request
head :bad_request # 400 status
end

def authorize_user
head :unauthorized unless current_user
head :unauthorized unless current_user # 401 status
end

def bad_request
head :bad_request
def denied
head :forbidden # 403 status
end

def not_found
head :not_found
head :not_found # 404 status
end

def denied
head :forbidden
def unprocessable
head :unprocessable_entity # 422 status
end
end
109 changes: 79 additions & 30 deletions app/models/ability.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,43 +3,92 @@
class Ability
include CanCan::Ability

# rubocop:disable Metrics/AbcSize, Layout/LineLength
# rubocop:disable Metrics/AbcSize
def initialize(user)
can :show, Project, user_id: nil
can :show, Component, project: { user_id: nil }
# Anyone can view projects not owner by a user or a school.
can :show, Project, user_id: nil, school_id: nil
can :show, Component, project: { user_id: nil, school_id: nil }

# Anyone can read publicly shared lessons.
can :read, Lesson, visibility: 'public'

return unless user

can %i[read create update destroy], Project, user_id: user.id
can %i[read create update destroy], Component, project: { user_id: user.id }
# Any authenticated user can create projects not owned by a school.
can :create, Project, user_id: user.id, school_id: nil
can :create, Component, project: { user_id: user.id, school_id: nil }

# Any authenticated user can manage their own projects.
can %i[read update destroy], Project, user_id: user.id
can %i[read update destroy], Component, project: { user_id: user.id }

# Any authenticated user can create a school. They agree to become the school-owner.
can :create, School

# Any authenticated user can create a lesson, to support a RPF library of public lessons.
can :create, Lesson, school_id: nil, school_class_id: nil

# Any authenticated user can create a copy of a publicly shared lesson.
can :create_copy, Lesson, visibility: 'public'

can %i[create], School # The user agrees to become a school-owner by creating a school.
# Any authenticated user can manage their own lessons.
can %i[read create_copy update destroy], Lesson, user_id: user.id

user.organisation_ids.each do |organisation_id|
if user.school_owner?(organisation_id:)
can(%i[read update destroy], School, id: organisation_id)
can(%i[read create update destroy], SchoolClass, school: { id: organisation_id })
can(%i[read create destroy], ClassMember, school_class: { school: { id: organisation_id } })
can(%i[read create destroy], :school_owner)
can(%i[read create destroy], :school_teacher)
can(%i[read create create_batch update destroy], :school_student)
end

if user.school_teacher?(organisation_id:)
can(%i[read], School, id: organisation_id)
can(%i[create], SchoolClass, school: { id: organisation_id })
can(%i[read update destroy], SchoolClass, school: { id: organisation_id }, teacher_id: user.id)
can(%i[read create destroy], ClassMember, school_class: { school: { id: organisation_id }, teacher_id: user.id })
can(%i[read], :school_owner)
can(%i[read], :school_teacher)
can(%i[read create create_batch update], :school_student)
end

if user.school_student?(organisation_id:)
can(%i[read], School, id: organisation_id)
can(%i[read], SchoolClass, school: { id: organisation_id }, members: { student_id: user.id })
end
define_school_owner_abilities(organisation_id:) if user.school_owner?(organisation_id:)
define_school_teacher_abilities(user:, organisation_id:) if user.school_teacher?(organisation_id:)
define_school_student_abilities(user:, organisation_id:) if user.school_student?(organisation_id:)
end
end
# rubocop:enable Metrics/AbcSize, Layout/LineLength
# rubocop:enable Metrics/AbcSize

private

def define_school_owner_abilities(organisation_id:)
can(%i[read update destroy], School, id: organisation_id)
can(%i[read create update destroy], SchoolClass, school: { id: organisation_id })
can(%i[read create destroy], ClassMember, school_class: { school: { id: organisation_id } })
can(%i[read create destroy], :school_owner)
can(%i[read create destroy], :school_teacher)
can(%i[read create create_batch update destroy], :school_student)
can(%i[create create_copy], Lesson, school_id: organisation_id)
can(%i[read update destroy], Lesson, school_id: organisation_id, visibility: %w[teachers students public])
can(%i[create], Project, school_id: organisation_id)
end

def define_school_teacher_abilities(user:, organisation_id:)
can(%i[read], School, id: organisation_id)
can(%i[create], SchoolClass, school: { id: organisation_id })
can(%i[read update destroy], SchoolClass, school: { id: organisation_id }, teacher_id: user.id)
can(%i[read create destroy], ClassMember, school_class: { school: { id: organisation_id }, teacher_id: user.id })
can(%i[read], :school_owner)
can(%i[read], :school_teacher)
can(%i[read create create_batch update], :school_student)
can(%i[create destroy], Lesson) { |lesson| school_teacher_can_manage_lesson?(user:, organisation_id:, lesson:) }
can(%i[read create_copy], Lesson, school_id: organisation_id, visibility: %w[teachers students])
can(%i[create], Project) { |project| school_teacher_can_manage_project?(user:, organisation_id:, project:) }
end

# rubocop:disable Layout/LineLength
def define_school_student_abilities(user:, organisation_id:)
can(%i[read], School, id: organisation_id)
can(%i[read], SchoolClass, school: { id: organisation_id }, members: { student_id: user.id })
can(%i[read], Lesson, school_id: organisation_id, visibility: 'students', school_class: { members: { student_id: user.id } })
can(%i[create], Project, school_id: organisation_id, user_id: user.id, lesson_id: nil)
end
# rubocop:enable Layout/LineLength

def school_teacher_can_manage_lesson?(user:, organisation_id:, lesson:)
is_my_lesson = lesson.school_id == organisation_id && lesson.user_id == user.id
is_my_class = lesson.school_class && lesson.school_class.teacher_id == user.id

is_my_lesson && (is_my_class || !lesson.school_class)
end

def school_teacher_can_manage_project?(user:, organisation_id:, project:)
is_my_project = project.school_id == organisation_id && project.user_id == user.id
is_my_lesson = project.lesson && project.lesson.user_id == user.id

is_my_project && (is_my_lesson || !project.lesson)
end
end
4 changes: 2 additions & 2 deletions app/models/class_member.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ def self.students
end

def self.with_students
users = students.index_by(&:id)
all.map { |instance| [instance, users[instance.student_id]] }
by_id = students.index_by(&:id)
all.map { |instance| [instance, by_id[instance.student_id]] }
end

def with_student
Expand Down
Loading