diff --git a/app/controllers/exercises_controller.rb b/app/controllers/exercises_controller.rb index 44090726..8bf43a03 100644 --- a/app/controllers/exercises_controller.rb +++ b/app/controllers/exercises_controller.rb @@ -7,6 +7,7 @@ class ExercisesController < ApplicationController before_action :set_execution_environments, only: [:create, :edit, :new, :update] before_action :set_exercise, only: MEMBER_ACTIONS + [:clone, :implement, :run, :statistics, :submit] before_action :set_file_types, only: [:create, :edit, :new, :update] + before_action :set_teams, only: [:create, :edit, :new, :update] def authorize! authorize(@exercise || @exercises) @@ -49,7 +50,7 @@ class ExercisesController < ApplicationController end def exercise_params - params[:exercise].permit(:description, :execution_environment_id, :file_id, :instructions, :public, :title, files_attributes: file_attributes).merge(user_id: current_user.id, user_type: current_user.class.name) + params[:exercise].permit(:description, :execution_environment_id, :file_id, :instructions, :public, :team_id, :title, files_attributes: file_attributes).merge(user_id: current_user.id, user_type: current_user.class.name) end private :exercise_params @@ -114,6 +115,11 @@ class ExercisesController < ApplicationController end private :set_file_types + def set_teams + @teams = Team.all.order(:name) + end + private :set_teams + def show end diff --git a/app/controllers/teams_controller.rb b/app/controllers/teams_controller.rb new file mode 100644 index 00000000..f94ca9dc --- /dev/null +++ b/app/controllers/teams_controller.rb @@ -0,0 +1,69 @@ +class TeamsController < ApplicationController + before_action :set_team, only: MEMBER_ACTIONS + + def authorize! + authorize(@team || @teams) + end + private :authorize! + + def create + @team = Team.new(team_params) + authorize! + respond_to do |format| + if @team.save + format.html { redirect_to(team_path(@team.id), notice: t('shared.object_created', model: Team.model_name.human)) } + format.json { render(:show, location: @team, status: :created) } + else + format.html { render(:new) } + format.json { render(json: @team.errors, status: :unprocessable_entity) } + end + end + end + + def destroy + @team.destroy + respond_to do |format| + format.html { redirect_to(teams_path, notice: t('shared.object_destroyed', model: Team.model_name.human)) } + format.json { head(:no_content) } + end + end + + def edit + end + + def index + @teams = Team.all.order(:name) + authorize! + end + + def new + @team = Team.new + authorize! + end + + def set_team + @team = Team.find(params[:id]) + authorize! + end + private :set_team + + def show + end + + def team_params + params[:team].permit(:name, internal_user_ids: []) + end + private :team_params + + def update + respond_to do |format| + if @team.update(team_params) + format.html { redirect_to(team_path(@team.id), notice: t('shared.object_updated', model: Team.model_name.human)) } + format.json { render(:show, location: @team, status: :ok) } + else + format.html { render(:edit) } + format.json { render(json: @team.errors, status: :unprocessable_entity) } + end + end + end +end diff --git a/app/models/exercise.rb b/app/models/exercise.rb index 9492f525..f3461f9d 100644 --- a/app/models/exercise.rb +++ b/app/models/exercise.rb @@ -7,6 +7,7 @@ class Exercise < ActiveRecord::Base belongs_to :execution_environment has_many :submissions + belongs_to :team has_many :users, source_type: ExternalUser, through: :submissions scope :with_submissions, -> { where('id IN (SELECT exercise_id FROM submissions)') } diff --git a/app/models/internal_user.rb b/app/models/internal_user.rb index b469d4e1..3f88d46d 100644 --- a/app/models/internal_user.rb +++ b/app/models/internal_user.rb @@ -3,6 +3,8 @@ class InternalUser < ActiveRecord::Base authenticates_with_sorcery! + has_and_belongs_to_many :teams + validates :email, presence: true, uniqueness: true validates :password, confirmation: true, on: :update, presence: true, unless: :activated? validates :role, inclusion: {in: ROLES} diff --git a/app/models/team.rb b/app/models/team.rb new file mode 100644 index 00000000..a0dcb8d6 --- /dev/null +++ b/app/models/team.rb @@ -0,0 +1,10 @@ +class Team < ActiveRecord::Base + has_and_belongs_to_many :internal_users + alias_method :members, :internal_users + + validates :name, presence: true + + def to_s + name + end +end diff --git a/app/policies/team_policy.rb b/app/policies/team_policy.rb new file mode 100644 index 00000000..a03d63b7 --- /dev/null +++ b/app/policies/team_policy.rb @@ -0,0 +1,14 @@ +class TeamPolicy < ApplicationPolicy + [:create?, :index?, :new?].each do |action| + define_method(action) { @user.internal? } + end + + [:destroy?, :edit?, :show?, :update?].each do |action| + define_method(action) { admin? || member? } + end + + def member? + @record.members.include?(@user) + end + private :member? +end diff --git a/app/views/application/_navigation.html.slim b/app/views/application/_navigation.html.slim index 49b8be86..01f2811e 100644 --- a/app/views/application/_navigation.html.slim +++ b/app/views/application/_navigation.html.slim @@ -5,7 +5,7 @@ = t('shared.administration') span.caret ul.dropdown-menu role='menu' - - models = [ExecutionEnvironment, Exercise, Consumer, ExternalUser, FileType, InternalUser, Submission].sort_by { |model| model.model_name.human(count: 2) } + - models = [ExecutionEnvironment, Exercise, Consumer, ExternalUser, FileType, InternalUser, Submission, Team].sort_by { |model| model.model_name.human(count: 2) } - models.each do |model| - if policy(model).index? li = link_to(model.model_name.human(count: 2), send(:"#{model.model_name.collection}_path")) diff --git a/app/views/exercises/_form.html.slim b/app/views/exercises/_form.html.slim index 2f65d1bb..366295e4 100644 --- a/app/views/exercises/_form.html.slim +++ b/app/views/exercises/_form.html.slim @@ -13,6 +13,9 @@ = f.label(:instructions) = f.hidden_field(:instructions) .form-control.markdown + .form-group + = f.label(:team_id) + = f.collection_select(:team_id, @teams, :id, :name, {include_blank: true}, class: 'form-control') .checkbox label = f.check_box(:public) diff --git a/app/views/exercises/show.html.slim b/app/views/exercises/show.html.slim index 39fef6f5..b52df66b 100644 --- a/app/views/exercises/show.html.slim +++ b/app/views/exercises/show.html.slim @@ -11,6 +11,7 @@ h1 = row(label: 'exercise.description', value: @exercise.description) = row(label: 'exercise.execution_environment', value: link_to(@exercise.execution_environment, @exercise.execution_environment)) = row(label: 'exercise.instructions', value: render_markdown(@exercise.instructions)) += row(label: 'exercise.team', value: @exercise.team ? link_to(@exercise.team, @exercise.team) : nil) = row(label: 'exercise.maximum_score', value: @exercise.maximum_score) = row(label: 'exercise.public', value: @exercise.public?) = row(label: 'exercise.embedding_parameters') do diff --git a/app/views/teams/_form.html.slim b/app/views/teams/_form.html.slim new file mode 100644 index 00000000..47f547dd --- /dev/null +++ b/app/views/teams/_form.html.slim @@ -0,0 +1,9 @@ += form_for(@team) do |f| + = render('shared/form_errors', object: @team) + .form-group + = f.label(:name) + = f.text_field(:name, class: 'form-control', required: true) + .form-group + = f.label(:internal_user_ids) + = f.collection_select(:internal_user_ids, InternalUser.all.order(:name), :id, :name, {}, {class: 'form-control', multiple: true}) + .actions = render('shared/submit_button', f: f, object: @team) diff --git a/app/views/teams/edit.html.slim b/app/views/teams/edit.html.slim new file mode 100644 index 00000000..0c1f4dac --- /dev/null +++ b/app/views/teams/edit.html.slim @@ -0,0 +1,3 @@ +h1 = @hint + += render('form') diff --git a/app/views/teams/index.html.slim b/app/views/teams/index.html.slim new file mode 100644 index 00000000..cffa8739 --- /dev/null +++ b/app/views/teams/index.html.slim @@ -0,0 +1,19 @@ +h1 = Team.model_name.human(count: 2) + +.table-responsive + table.table + thead + tr + th = t('activerecord.attributes.team.name') + th = t('activerecord.attributes.team.internal_user_ids') + th colspan=3 = t('shared.actions') + tbody + - @teams.each do |team| + tr + td = team.name + td = team.members.count + td = link_to(t('shared.show'), team_path(team.id)) + td = link_to(t('shared.edit'), edit_team_path(team.id)) + td = link_to(t('shared.destroy'), team_path(team.id), data: {confirm: t('shared.confirm_destroy')}, method: :delete) + +p = render('shared/new_button', model: Team, path: new_team_path) diff --git a/app/views/teams/new.html.slim b/app/views/teams/new.html.slim new file mode 100644 index 00000000..8c8f2aab --- /dev/null +++ b/app/views/teams/new.html.slim @@ -0,0 +1,3 @@ +h1 = t('shared.new_model', model: Team.model_name.human) + += render('form') diff --git a/app/views/teams/show.html.slim b/app/views/teams/show.html.slim new file mode 100644 index 00000000..1b37d931 --- /dev/null +++ b/app/views/teams/show.html.slim @@ -0,0 +1,9 @@ +h1 + = @team + = render('shared/edit_button', object: @team, path: edit_team_path(@team.id)) + += row(label: 'team.name', value: @team.name) += row(label: 'team.internal_user_ids') do + ul.list-unstyled + - @team.members.order(:name).each do |internal_user| + li = link_to(internal_user, internal_user) diff --git a/config/locales/de.yml b/config/locales/de.yml index ebc52610..2999eb1f 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -27,6 +27,8 @@ de: maximum_score: Erreichbare Punktzahl public: Öffentlich reference_implementation: Reference Implementation + team: Team + team_id: Team template_code: Template Code template_test_code: Template Test Code test_code: Test Code @@ -77,6 +79,9 @@ de: files: Dateien score: Punktzahl user: Autor + team: + internal_user_ids: Mitglieder + name: Name models: consumer: one: Konsument @@ -108,6 +113,9 @@ de: submission: one: Abgabe other: Abgaben + team: + one: Team + other: Teams errors: messages: together: 'muss zusammen mit %{attribute} definiert werden' diff --git a/config/locales/en.yml b/config/locales/en.yml index c17f6bfc..d2e64b3a 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -27,6 +27,8 @@ en: maximum_score: Maximum Score public: Public reference_implementation: Reference Implementation + team: Team + team_id: Team template_code: Template Code template_test_code: Template Test Code test_code: Test Code @@ -77,6 +79,9 @@ en: files: Files score: Score user: Author + team: + internal_user_ids: Members + name: Name models: consumer: one: Consumer @@ -108,6 +113,9 @@ en: submission: one: Submission other: Submissions + team: + one: Team + other: Teams errors: messages: together: 'has to be set along with %{attribute}' diff --git a/config/routes.rb b/config/routes.rb index f1ed70ec..24810b64 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -61,4 +61,6 @@ Rails.application.routes.draw do get 'test/:filename', as: :test, constraints: {filename: FILENAME_REGEXP}, to: :test end end + + resources :teams end diff --git a/db/migrate/20150128083123_create_teams.rb b/db/migrate/20150128083123_create_teams.rb new file mode 100644 index 00000000..cdb0da35 --- /dev/null +++ b/db/migrate/20150128083123_create_teams.rb @@ -0,0 +1,8 @@ +class CreateTeams < ActiveRecord::Migration + def change + create_table :teams do |t| + t.string :name + t.timestamps + end + end +end diff --git a/db/migrate/20150128084834_create_internal_users_teams.rb b/db/migrate/20150128084834_create_internal_users_teams.rb new file mode 100644 index 00000000..8fe51717 --- /dev/null +++ b/db/migrate/20150128084834_create_internal_users_teams.rb @@ -0,0 +1,8 @@ +class CreateInternalUsersTeams < ActiveRecord::Migration + def change + create_table :internal_users_teams do |t| + t.belongs_to :internal_user, index: true + t.belongs_to :team, index: true + end + end +end diff --git a/db/migrate/20150128093003_add_team_id_to_exercises.rb b/db/migrate/20150128093003_add_team_id_to_exercises.rb new file mode 100644 index 00000000..7911bdf7 --- /dev/null +++ b/db/migrate/20150128093003_add_team_id_to_exercises.rb @@ -0,0 +1,5 @@ +class AddTeamIdToExercises < ActiveRecord::Migration + def change + add_reference :exercises, :team + end +end diff --git a/db/schema.rb b/db/schema.rb index 1edc0778..3c8ff948 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20141031161603) do +ActiveRecord::Schema.define(version: 20150128093003) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -57,6 +57,7 @@ ActiveRecord::Schema.define(version: 20141031161603) do t.boolean "public" t.string "user_type" t.string "token" + t.integer "team_id" end create_table "external_users", force: true do |t| @@ -138,6 +139,14 @@ ActiveRecord::Schema.define(version: 20141031161603) do add_index "internal_users", ["remember_me_token"], name: "index_internal_users_on_remember_me_token", using: :btree add_index "internal_users", ["reset_password_token"], name: "index_internal_users_on_reset_password_token", using: :btree + create_table "internal_users_teams", force: true do |t| + t.integer "internal_user_id" + t.integer "team_id" + end + + add_index "internal_users_teams", ["internal_user_id"], name: "index_internal_users_teams_on_internal_user_id", using: :btree + add_index "internal_users_teams", ["team_id"], name: "index_internal_users_teams_on_team_id", using: :btree + create_table "submissions", force: true do |t| t.integer "exercise_id" t.float "score" @@ -148,4 +157,10 @@ ActiveRecord::Schema.define(version: 20141031161603) do t.string "user_type" end + create_table "teams", force: true do |t| + t.string "name" + t.datetime "created_at" + t.datetime "updated_at" + end + end diff --git a/spec/controllers/teams_controller_spec.rb b/spec/controllers/teams_controller_spec.rb new file mode 100644 index 00000000..8c817b2c --- /dev/null +++ b/spec/controllers/teams_controller_spec.rb @@ -0,0 +1,93 @@ +require 'rails_helper' + +describe TeamsController do + let(:team) { FactoryGirl.create(:team) } + let(:user) { FactoryGirl.create(:admin) } + before(:each) { allow(controller).to receive(:current_user).and_return(user) } + + describe 'POST #create' do + context 'with a valid team' do + let(:request) { Proc.new { post :create, team: FactoryGirl.attributes_for(:team) } } + before(:each) { request.call } + + expect_assigns(team: Team) + + it 'creates the team' do + expect { request.call }.to change(Team, :count).by(1) + end + + expect_redirect + end + + context 'with an invalid team' do + before(:each) { post :create, team: {} } + + expect_assigns(team: Team) + expect_status(200) + expect_template(:new) + end + end + + describe 'DELETE #destroy' do + before(:each) { delete :destroy, id: team.id } + + expect_assigns(team: Team) + + it 'destroys the team' do + team = FactoryGirl.create(:team) + expect { delete :destroy, id: team.id }.to change(Team, :count).by(-1) + end + + expect_redirect(:teams) + end + + describe 'GET #edit' do + before(:each) { get :edit, id: team.id } + + expect_assigns(team: Team) + expect_status(200) + expect_template(:edit) + end + + describe 'GET #index' do + let!(:teams) { FactoryGirl.create_pair(:team) } + before(:each) { get :index } + + expect_assigns(teams: Team.all) + expect_status(200) + expect_template(:index) + end + + describe 'GET #new' do + before(:each) { get :new } + + expect_assigns(team: Team) + expect_status(200) + expect_template(:new) + end + + describe 'GET #show' do + before(:each) { get :show, id: team.id } + + expect_assigns(team: :team) + expect_status(200) + expect_template(:show) + end + + describe 'PUT #update' do + context 'with a valid team' do + before(:each) { put :update, team: FactoryGirl.attributes_for(:team), id: team.id } + + expect_assigns(team: Team) + expect_redirect + end + + context 'with an invalid team' do + before(:each) { put :update, team: {name: ''}, id: team.id } + + expect_assigns(team: Team) + expect_status(200) + expect_template(:edit) + end + end +end diff --git a/spec/factories/team.rb b/spec/factories/team.rb new file mode 100644 index 00000000..d883293e --- /dev/null +++ b/spec/factories/team.rb @@ -0,0 +1,6 @@ +FactoryGirl.define do + factory :team do + internal_users { build_list :teacher, 10 } + name 'A-Team' + end +end diff --git a/spec/models/team_spec.rb b/spec/models/team_spec.rb new file mode 100644 index 00000000..40725c15 --- /dev/null +++ b/spec/models/team_spec.rb @@ -0,0 +1,9 @@ +require 'rails_helper' + +describe Team do + let(:team) { Team.create } + + it 'validates the presence of a name' do + expect(team.errors[:name]).to be_present + end +end diff --git a/spec/policies/team_policy_spec.rb b/spec/policies/team_policy_spec.rb new file mode 100644 index 00000000..f3802a2c --- /dev/null +++ b/spec/policies/team_policy_spec.rb @@ -0,0 +1,41 @@ +require 'rails_helper' + +describe TeamPolicy do + subject { TeamPolicy } + + let(:team) { FactoryGirl.build(:team) } + + [:create?, :index?, :new?].each do |action| + permissions(action) do + it 'grants access to admins' do + expect(subject).to permit(FactoryGirl.build(:admin), team) + end + + it 'grants access to teachers' do + expect(subject).to permit(FactoryGirl.build(:teacher), team) + end + + it 'does not grant access to external users' do + expect(subject).not_to permit(FactoryGirl.build(:external_user), team) + end + end + end + + [:destroy?, :edit?, :show?, :update?].each do |action| + permissions(action) do + it 'grants access to admins' do + expect(subject).to permit(FactoryGirl.build(:admin), team) + end + + it 'grants access to members' do + expect(subject).to permit(team.members.last, team) + end + + it 'does not grant access to all other users' do + [:external_user, :teacher].each do |factory_name| + expect(subject).not_to permit(FactoryGirl.build(factory_name), team) + end + end + end + end +end