diff --git a/app/controllers/v0/experiments_controller.rb b/app/controllers/v0/experiments_controller.rb new file mode 100644 index 00000000..13b5df41 --- /dev/null +++ b/app/controllers/v0/experiments_controller.rb @@ -0,0 +1,59 @@ +module V0 + class ExperimentsController < ApplicationController + + before_action :check_if_authorized!, only: [:create, :update, :destroy] + + def index + raise_ransack_errors_as_bad_request do + @q = Experiment.ransack(params[:q]) + @q.sorts = 'id asc' if @q.sorts.empty? + @experiments = @q.result(distinct: true) + @experiments = paginate @experiments + end + end + + def show + @experiment = Experiment.find(params[:id]) + authorize @experiment + end + + def create + @experiment = Experiment.new(experiment_params) + @experiment.owner ||= current_user + authorize @experiment + if @experiment.save + render :show, status: :created + else + raise Smartcitizen::UnprocessableEntity.new @experiment.errors + end + end + + def update + @experiment = Experiment.find(params[:id]) + authorize @experiment + if @experiment.update(experiment_params) + render :show, status: :ok + else + raise Smartcitizen::UnprocessableEntity.new @experiment.errors + end + end + + def destroy + @experiment = Experiment.find(params[:id]) + authorize @experiment + if @experiment.destroy! + render json: {message: 'OK'}, status: :ok + else + raise Smartcitizen::UnprocessableEntity.new @experiment.errors + end + end + + private + + def experiment_params + params.permit( + :name, :description, :active, :is_test, :starts_at, :ends_at, device_ids: [] + ) + end + end +end diff --git a/app/models/device.rb b/app/models/device.rb index bcfdb98b..449560bb 100644 --- a/app/models/device.rb +++ b/app/models/device.rb @@ -26,6 +26,8 @@ class Device < ActiveRecord::Base has_many :sensors, through: :components has_one :postprocessing, dependent: :destroy + has_and_belongs_to_many :experiments + accepts_nested_attributes_for :postprocessing, update_only: true validates_presence_of :name diff --git a/app/models/experiment.rb b/app/models/experiment.rb new file mode 100644 index 00000000..0d8ec197 --- /dev/null +++ b/app/models/experiment.rb @@ -0,0 +1,11 @@ +class Experiment < ApplicationRecord + belongs_to :owner, class_name: "User" + has_and_belongs_to_many :devices + + validates_presence_of :name, :owner + validates_inclusion_of :is_test, in: [true, false] + + def self.ransackable_attributes(auth_object = nil) + ["created_at", "description", "ends_at", "id", "is_test", "name", "owner_id", "starts_at", "status", "updated_at"] + end +end diff --git a/app/policies/experiment_policy.rb b/app/policies/experiment_policy.rb new file mode 100644 index 00000000..57688ffa --- /dev/null +++ b/app/policies/experiment_policy.rb @@ -0,0 +1,13 @@ +class ExperimentPolicy < ApplicationPolicy + def update? + user.try(:is_admin?) || user == record.owner + end + + def create? + user + end + + def destroy? + update? + end +end diff --git a/app/views/v0/devices/_device.jbuilder b/app/views/v0/devices/_device.jbuilder index 59e5edee..0bd9cc9d 100644 --- a/app/views/v0/devices/_device.jbuilder +++ b/app/views/v0/devices/_device.jbuilder @@ -55,4 +55,6 @@ end json.data device.formatted_data if local_assigns[:with_data] +json.experiment_ids device.experiment_ids + diff --git a/app/views/v0/experiments/_experiment.jbuilder b/app/views/v0/experiments/_experiment.jbuilder new file mode 100644 index 00000000..009f02a1 --- /dev/null +++ b/app/views/v0/experiments/_experiment.jbuilder @@ -0,0 +1,3 @@ +json.(experiment, + :id, :name, :description, :owner_id, :active, :is_test, :starts_at, :ends_at, :device_ids, :created_at, :updated_at +) diff --git a/app/views/v0/experiments/index.jbuilder b/app/views/v0/experiments/index.jbuilder new file mode 100644 index 00000000..4de10185 --- /dev/null +++ b/app/views/v0/experiments/index.jbuilder @@ -0,0 +1 @@ +json.array! @experiments, partial: 'experiment', as: :experiment diff --git a/app/views/v0/experiments/show.jbuilder b/app/views/v0/experiments/show.jbuilder new file mode 100644 index 00000000..6ed5ccf6 --- /dev/null +++ b/app/views/v0/experiments/show.jbuilder @@ -0,0 +1 @@ +json.partial! "experiment", experiment: @experiment diff --git a/config/routes.rb b/config/routes.rb index e0a8cec5..f7f5ca2e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -25,6 +25,7 @@ resources :sensors, except: [:destroy] resources :components, only: [:show, :index] resources :sessions, only: :create + resources :experiments resources :uploads, path: 'avatars' do post 'uploaded' => 'uploads#uploaded', on: :collection diff --git a/db/migrate/20240718054447_create_experiments.rb b/db/migrate/20240718054447_create_experiments.rb new file mode 100644 index 00000000..b5a2e85d --- /dev/null +++ b/db/migrate/20240718054447_create_experiments.rb @@ -0,0 +1,22 @@ +class CreateExperiments < ActiveRecord::Migration[6.1] + def change + create_table :experiments do |t| + t.string :name, null: false + t.string :description + t.belongs_to :owner, index: true + t.boolean :active, null: false, default: true + t.boolean :is_test, null: false, default: false + t.datetime :starts_at + t.datetime :ends_at + t.timestamps + end + add_foreign_key :experiments, :users, column: :owner_id + + create_table :devices_experiments, id: false do |t| + t.belongs_to :device, index: true + t.belongs_to :experiment, index: true + end + add_foreign_key :devices_experiments, :devices, column: :device_id + add_foreign_key :devices_experiments, :experiments, column: :experiment_id + end +end diff --git a/db/schema.rb b/db/schema.rb index 4f1bb703..cd3b4404 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2024_06_24_175242) do +ActiveRecord::Schema.define(version: 2024_07_18_054447) do # These are extensions that must be enabled in order to support this database enable_extension "adminpack" @@ -116,6 +116,13 @@ t.index ["workflow_state"], name: "index_devices_on_workflow_state" end + create_table "devices_experiments", id: false, force: :cascade do |t| + t.bigint "device_id" + t.bigint "experiment_id" + t.index ["device_id"], name: "index_devices_experiments_on_device_id" + t.index ["experiment_id"], name: "index_devices_experiments_on_experiment_id" + end + create_table "devices_inventory", id: :serial, force: :cascade do |t| t.jsonb "report", default: {} t.datetime "created_at" @@ -129,6 +136,19 @@ t.index ["device_id", "tag_id"], name: "index_devices_tags_on_device_id_and_tag_id", unique: true end + create_table "experiments", force: :cascade do |t| + t.string "name", null: false + t.string "description" + t.bigint "owner_id" + t.boolean "active", default: true, null: false + t.boolean "is_test", default: false, null: false + t.datetime "starts_at" + t.datetime "ends_at" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["owner_id"], name: "index_experiments_on_owner_id" + end + create_table "friendly_id_slugs", id: :serial, force: :cascade do |t| t.string "slug", null: false t.integer "sluggable_id", null: false @@ -313,8 +333,11 @@ add_foreign_key "api_tokens", "users", column: "owner_id" add_foreign_key "components", "devices" add_foreign_key "components", "sensors" + add_foreign_key "devices_experiments", "devices" + add_foreign_key "devices_experiments", "experiments" add_foreign_key "devices_tags", "devices" add_foreign_key "devices_tags", "tags" + add_foreign_key "experiments", "users", column: "owner_id" add_foreign_key "postprocessings", "devices" add_foreign_key "sensors", "measurements" add_foreign_key "uploads", "users" diff --git a/spec/factories/experiment.rb b/spec/factories/experiment.rb new file mode 100644 index 00000000..0d9b8d3b --- /dev/null +++ b/spec/factories/experiment.rb @@ -0,0 +1,9 @@ +FactoryBot.define do + factory :experiment do + sequence("name") { |n| "experiment#{n}"} + description { "my experiment" } + association :owner, factory: :user + active { true } + is_test { false } + end +end diff --git a/spec/requests/v0/devices_spec.rb b/spec/requests/v0/devices_spec.rb index 280f0464..ad0bdd6f 100644 --- a/spec/requests/v0/devices_spec.rb +++ b/spec/requests/v0/devices_spec.rb @@ -24,7 +24,7 @@ expect(json.length).to eq(2) # expect(json[0]['name']).to eq(first.name) # expect(json[1]['name']).to eq(second.name) - expect(json[0].keys).to eq(%w(id uuid name description state system_tags user_tags last_reading_at created_at updated_at notify device_token postprocessing location data_policy hardware owner data)) + expect(json[0].keys).to eq(%w(id uuid name description state system_tags user_tags last_reading_at created_at updated_at notify device_token postprocessing location data_policy hardware owner data experiment_ids)) end describe "when not logged in" do diff --git a/spec/requests/v0/experiments_spec.rb b/spec/requests/v0/experiments_spec.rb new file mode 100644 index 00000000..71f451da --- /dev/null +++ b/spec/requests/v0/experiments_spec.rb @@ -0,0 +1,179 @@ +require 'rails_helper' + +describe V0::ExperimentsController do + + let(:application) { create :application } + + let(:citizen_user) { create :user } + let(:citizen_token) { create :access_token, application: application, resource_owner_id: citizen_user.id } + + let(:admin_user) { create :admin } + let(:admin_token) { create :access_token, application: application, resource_owner_id: admin_user.id } + + let(:owner_user) { create :user } + let(:owner_token) { create :access_token, application: application, resource_owner_id: owner_user.id } + + let(:experiment) { + create(:experiment, name: "Existing experiment", owner: owner_user) + } + + let(:device) { create(:device) } + let(:valid_params) { + { + name: "test experiment", + description: "a test experiment", + is_test: false, + active: true, + starts_at: "2024-01-01T00:00:00Z", + ends_at: "2024-06-30T23:59:59Z", + device_ids: [ device.id ] + } + } + + describe "GET /experiments" do + it "lists all experiments" do + first = create(:experiment, name: "first experiment") + second = create(:experiment, name: "second experiment") + j = api_get 'experiments' + + expect(j.length).to eq(2) + expect(j[0]['name']).to eq('first experiment') + expect(j[1]['name']).to eq('second experiment') + expect(response.status).to eq(200) + end + end + + describe "POST /experiments" do + context "When no user is logged in" do + it "does not create an experiment" do + before_count = Experiment.count + api_post "experiments", valid_params + + expect(response.status).to eq(401) + expect(Experiment.count).to eq(before_count) + end + end + + context "When a user is logged in" do + it "creates an experiment" do + before_count = Experiment.count + api_post "experiments", valid_params.merge(access_token: citizen_token.token) + + expect(response.status).to eq(201) + expect(Experiment.count).to eq(before_count + 1) + created = Experiment.last + expect(created.name).to eq(valid_params[:name]) + expect(created.description).to eq(valid_params[:description]) + expect(created.is_test).to eq(valid_params[:is_test]) + expect(created.active).to eq(valid_params[:active]) + expect(created.starts_at).to eq(Time.parse(valid_params[:starts_at])) + expect(created.ends_at).to eq(Time.parse(valid_params[:ends_at])) + expect(created.device_ids).to eq(valid_params[:device_ids]) + end + end + end + + describe "GET /experiments/:id" do + + it "returns the experiment" do + json = api_get "experiments/#{experiment.id}" + + expect(response.status).to eq(200) + expect(json["name"]).to eq experiment.name + end + end + + describe "PUT /experiments/:id" do + context "When no user is logged in" do + it "does not update the experiment" do + json = api_put "experiments/#{experiment.id}", valid_params + + updated = Experiment.find(experiment.id) + expect(response.status).to eq(401) + expect(updated).to eq(experiment) + end + end + + context "When the experiment owner is logged in" do + it "updates the experiment" do + json = api_put "experiments/#{experiment.id}", valid_params.merge(access_token: owner_token.token) + + updated = Experiment.find(experiment.id) + expect(updated.name).to eq(valid_params[:name]) + expect(updated.description).to eq(valid_params[:description]) + expect(updated.is_test).to eq(valid_params[:is_test]) + expect(updated.active).to eq(valid_params[:active]) + expect(updated.starts_at).to eq(Time.parse(valid_params[:starts_at])) + expect(updated.ends_at).to eq(Time.parse(valid_params[:ends_at])) + expect(updated.device_ids).to eq(valid_params[:device_ids]) + end + end + + context "When a different user is logged in" do + it "does not update the experiment" do + json = api_put "experiments/#{experiment.id}", valid_params.merge(access_token: citizen_token.token) + + expect(response.status).to eq(403) + updated = Experiment.find(experiment.id) + expect(updated).to eq(experiment) + end + end + + context "When an admin is logged in" do + it "updates the experiment" do + json = api_put "experiments/#{experiment.id}", valid_params.merge(access_token: admin_token.token) + + updated = Experiment.find(experiment.id) + expect(updated.name).to eq(valid_params[:name]) + expect(updated.description).to eq(valid_params[:description]) + expect(updated.is_test).to eq(valid_params[:is_test]) + expect(updated.active).to eq(valid_params[:active]) + expect(updated.starts_at).to eq(Time.parse(valid_params[:starts_at])) + expect(updated.ends_at).to eq(Time.parse(valid_params[:ends_at])) + expect(updated.device_ids).to eq(valid_params[:device_ids]) + end + end + end + + describe "DELETE /experiments/:id" do + context "When no user is logged in" do + it "does not delete the experiment" do + json = api_delete "experiments/#{experiment.id}" + + updated = Experiment.where(id: experiment.id).first + expect(response.status).to eq(401) + expect(updated).not_to be(nil) + end + end + + context "When the experiment owner is logged in" do + it "deletes the experiment" do + json = api_delete "experiments/#{experiment.id}", access_token: owner_token.token + + updated = Experiment.where(id: experiment.id).first + expect(response.status).to eq(200) + expect(updated).to be(nil) + end + end + + context "When a different user is logged in" do + it "does not delete the experiment" do + json = api_delete "experiments/#{experiment.id}", access_token: citizen_token.token + + updated = Experiment.where(id: experiment.id).first + expect(response.status).to eq(403) + expect(updated).not_to be(nil) + end + end + + context "When an admin is logged in" do + it "deletes the experiment" do + json = api_delete "experiments/#{experiment.id}", access_token: admin_token.token + + updated = Experiment.where(id: experiment.id).first + expect(response.status).to eq(200) + expect(updated).to be(nil) + end + end + end +end