Skip to content

Allow Experience CS admins to create public projects #549

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
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
5618e23
Add Api::PublicProjectsController#create
floehopper May 21, 2025
0ca3b3d
Strict param parsing for Api::PublicProjectsController
floehopper May 21, 2025
bb435d6
Extract User#parsed_roles method
floehopper May 21, 2025
260e08a
Introduce User#experience_cs_admin? method
floehopper May 21, 2025
88c836d
Introduce permission for creating public projects
floehopper May 21, 2025
d715c7a
Only experience-cs admins can create public projects
floehopper May 21, 2025
37761e6
Rename Project#check_unique_not_null -> generate_identifier
floehopper May 21, 2025
8ddea4d
Simplify example for Project#generate_identifier
floehopper May 22, 2025
97d2460
Allow skipping of identifier generation
floehopper May 22, 2025
dfa6214
Require identifier when creating public project
floehopper May 22, 2025
f509f01
Forbid creation of non-Scratch public projects
floehopper May 22, 2025
cfb532d
Introduce PhraseIdentifier::PATTERN
floehopper May 22, 2025
490e59a
Introduce PublicProject model
floehopper May 22, 2025
06ac4fa
Validate format of identifier for public project
floehopper May 22, 2025
288aeee
Validate presence of name for public project
floehopper May 22, 2025
963d8fa
Ensure Project#user_id is not set on public project
floehopper May 22, 2025
6f20092
Rename project_params in PublicProjectsController
floehopper May 23, 2025
29478e1
Add Api::PublicProjectsController#update
floehopper May 23, 2025
626b2e0
Use ProjectLoader in Api::PublicProjectsController
floehopper May 23, 2025
7a52343
Make updating public projects specs more realistic
floehopper May 23, 2025
f802d9b
Introduce permission for updating public projects
floehopper May 23, 2025
8a7adee
Only experience-cs admins can update public projects
floehopper May 23, 2025
2b6878c
Forbid updating of non-public projects
floehopper May 23, 2025
2651edd
Public project update cannot change certain attributes
floehopper May 23, 2025
1c75c8c
Use PublicProject validations in PublicProject:Update
floehopper May 23, 2025
dd4a009
Add Api::PublicProjectsController#destroy
floehopper May 23, 2025
4d86773
Introduce permission for destroying public projects
floehopper May 23, 2025
0b5c42e
Only experience-cs admins can destroy public projects
floehopper May 23, 2025
0eb3880
Make destroying public projects specs more realistic
floehopper May 23, 2025
adea633
Forbid destroying of non-public projects
floehopper May 23, 2025
1b08bbd
Prevent destruction of public project with remixes
floehopper May 23, 2025
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
80 changes: 80 additions & 0 deletions app/controllers/api/public_projects_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# frozen_string_literal: true

module Api
class PublicProjectsController < ApiController
before_action :authorize_user
before_action :restrict_project_type, only: %i[create]
before_action :load_project, only: %i[update destroy]
before_action :restrict_to_public_projects, only: %i[update destroy]
before_action :prevent_destruction_of_public_project_with_remixes, only: %i[destroy]

def create
authorize! :create, :public_project
result = PublicProject::Create.call(project_hash: create_params)

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

def update
authorize! :update, :public_project
result = PublicProject::Update.call(project: @project, update_hash: update_params)

if result.success?
@project = result[:project]
render 'api/projects/show', formats: [:json]
else
render json: { error: result[:error] }, status: :unprocessable_entity
end
end

def destroy
authorize! :update, :public_project

if @project.destroy
head :ok
else
head :unprocessable_entity
end
end

private

def load_project
loader = ProjectLoader.new(params[:id], [params[:locale]])
@project = loader.load
raise ActiveRecord::RecordNotFound if @project.blank?
end

def create_params
params.require(:project).permit(:identifier, :locale, :project_type, :name)
end

def update_params
params.require(:project).permit(:identifier, :name)
end

def restrict_project_type
project_type = create_params[:project_type]
return if project_type == Project::Types::SCRATCH

raise CanCan::AccessDenied.new("#{project_type} not yet supported", :create, :public_project)
end

def restrict_to_public_projects
return if @project.user_id.blank?

raise CanCan::AccessDenied.new('Cannot update non-public project', :update, :public_project)
end

def prevent_destruction_of_public_project_with_remixes
return if @project.remixes.none?

raise CanCan::AccessDenied.new('Cannot destroy public project with remixes', :update, :public_project)
end
end
end
10 changes: 10 additions & 0 deletions app/models/ability.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ def initialize(user)
define_school_teacher_abilities(user:, school:) if user.school_teacher?(school)
define_school_owner_abilities(school:) if user.school_owner?(school)
end

define_experience_cs_admin_abilities(user)
end

private
Expand Down Expand Up @@ -100,6 +102,14 @@ def define_school_student_abilities(user:, school:)
can(%i[show_finished set_finished], SchoolProject, project: { user_id: user.id, lesson_id: nil }, school_id: school.id)
end

def define_experience_cs_admin_abilities(user)
return unless user.experience_cs_admin?

can :create, :public_project
can :update, :public_project
can :destroy, :public_project
end

def school_teacher_can_manage_lesson?(user:, school:, lesson:)
is_my_lesson = lesson.school_id == school.id && lesson.user_id == user.id
is_my_class = lesson.school_class&.teacher_ids&.include?(user.id)
Expand Down
6 changes: 4 additions & 2 deletions app/models/project.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ module Types
SCRATCH = 'scratch'
end

attr_accessor :skip_identifier_generation

belongs_to :school, optional: true
belongs_to :lesson, optional: true
belongs_to :parent, optional: true, class_name: :Project, foreign_key: :remixed_from_id, inverse_of: :remixes
Expand All @@ -20,7 +22,7 @@ module Types

accepts_nested_attributes_for :components

before_validation :check_unique_not_null, on: :create
before_validation :generate_identifier, on: :create, unless: :skip_identifier_generation
before_validation :create_school_project_if_needed

validates :identifier, presence: true, uniqueness: { scope: :locale }
Expand Down Expand Up @@ -81,7 +83,7 @@ def media

private

def check_unique_not_null
def generate_identifier
self.identifier ||= PhraseIdentifier.generate
end

Expand Down
40 changes: 40 additions & 0 deletions app/models/public_project.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# frozen_string_literal: true

class PublicProject
include ActiveModel::Validations

attr_reader :project

delegate :identifier, :name, to: :project

validates :identifier, format: { with: PhraseIdentifier::PATTERN, allow_blank: true }
validates :name, presence: true

def initialize(project)
@project = project
end

def valid?
valid = super
project_valid = project.valid?
errors.merge!(project.errors)
valid && project_valid
end

def save!
raise_validation_error unless valid?

project.save!
end

private

def merge_errors
project.errors.details.each do |attribute, details_array|
details_array.each_with_index do |details, index|
message = project.errors[attribute][index]
errors.add(attribute, message, **details)
end
end
end
end
10 changes: 9 additions & 1 deletion app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,15 @@ def student?
end

def admin?
(roles&.to_s&.split(',')&.map(&:strip) || []).include?('editor-admin')
parsed_roles.include?('editor-admin')
end

def experience_cs_admin?
parsed_roles.include?('experience-cs-admin')
end

def parsed_roles
roles&.to_s&.split(',')&.map(&:strip) || []
end

def ==(other)
Expand Down
2 changes: 2 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
resource :images, only: %i[show create], controller: 'projects/images'
end

resources :public_projects, only: %i[create update destroy]

resource :project_errors, only: %i[create]

resource :school, only: [:show], controller: 'my_school'
Expand Down
25 changes: 25 additions & 0 deletions lib/concepts/public_project/operations/create.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# frozen_string_literal: true

class PublicProject
class Create
class << self
def call(project_hash:)
response = OperationResponse.new

public_project = PublicProject.new(
Project.new(project_hash).tap do |p|
p.skip_identifier_generation = true
end
)
public_project.save!

response[:project] = public_project.project
response
rescue StandardError => e
Sentry.capture_exception(e)
response[:error] = "Error creating project: #{e}"
response
end
end
end
end
22 changes: 22 additions & 0 deletions lib/concepts/public_project/operations/update.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

class PublicProject
class Update
class << self
def call(project:, update_hash:)
response = OperationResponse.new

project.assign_attributes(update_hash)
public_project = PublicProject.new(project)
public_project.save!

response[:project] = public_project.project
response
rescue StandardError => e
Sentry.capture_exception(e)
response[:error] = "Error updating project: #{e}"
response
end
end
end
end
2 changes: 2 additions & 0 deletions lib/phrase_identifier.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# frozen_string_literal: true

class PhraseIdentifier
PATTERN = /\A[a-z0-9]+(-[a-z0-9]+)*\z/

class Error < RuntimeError
end

Expand Down
84 changes: 84 additions & 0 deletions spec/concepts/public_project/create_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe PublicProject::Create, type: :unit do
describe '.call' do
subject(:create_project) { described_class.call(project_hash:) }

let(:identifier) { 'foo-bar-baz' }
let(:name) { 'Foo bar baz' }
let(:project_hash) do
{
identifier:,
locale: 'en',
project_type: Project::Types::SCRATCH,
name:
}
end

context 'with valid content' do
it 'returns success' do
expect(create_project.success?).to be(true)
end

it 'returns project with identifier' do
new_project = create_project[:project]
expect(new_project.identifier).to eq(identifier)
end
end

context 'when creation fails' do
let(:project_hash) { {} }

before do
allow(Sentry).to receive(:capture_exception)
end

it 'returns failure' do
expect(create_project.failure?).to be(true)
end

it 'sent the exception to Sentry' do
create_project
expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError))
end
end

context 'when identifier is blank' do
let(:identifier) { nil }

it 'returns failure' do
expect(create_project.failure?).to be(true)
end

it 'returns error message' do
expect(create_project[:error]).to eq("Error creating project: Validation failed: Identifier can't be blank")
end
end

context 'when identifier is in invalid format' do
let(:identifier) { 'FooBarBaz' }

it 'returns failure' do
expect(create_project.failure?).to be(true)
end

it 'returns error message' do
expect(create_project[:error]).to eq('Error creating project: Validation failed: Identifier is invalid')
end
end

context 'when name is blank' do
let(:name) { '' }

it 'returns failure' do
expect(create_project.failure?).to be(true)
end

it 'returns error message' do
expect(create_project[:error]).to eq("Error creating project: Validation failed: Name can't be blank")
end
end
end
end
Loading