mumuki-domain 7.5.0 → 7.7.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/app/models/book.rb +13 -0
  3. data/app/models/concerns/{with_content.rb → container.rb} +1 -1
  4. data/app/models/concerns/guide_container.rb +1 -1
  5. data/app/models/concerns/submittable/solvable.rb +8 -2
  6. data/app/models/concerns/topic_container.rb +1 -1
  7. data/app/models/concerns/with_discussions.rb +8 -5
  8. data/app/models/concerns/with_layout.rb +5 -1
  9. data/app/models/concerns/with_progress.rb +4 -0
  10. data/app/models/concerns/with_scoped_queries.rb +9 -9
  11. data/app/models/concerns/with_scoped_queries/page.rb +1 -1
  12. data/app/models/concerns/with_scoped_queries/sort.rb +6 -1
  13. data/app/models/course.rb +18 -7
  14. data/app/models/discussion.rb +47 -9
  15. data/app/models/exam.rb +53 -23
  16. data/app/models/exam/passing_criterion.rb +53 -0
  17. data/app/models/exercise/challenge.rb +1 -1
  18. data/app/models/exercise/problem.rb +6 -0
  19. data/app/models/exercise/reading.rb +4 -0
  20. data/app/models/invitation.rb +7 -1
  21. data/app/models/message.rb +28 -4
  22. data/app/models/organization.rb +16 -0
  23. data/app/models/user.rb +21 -5
  24. data/db/migrate/20200601203033_add_course_to_exam.rb +5 -0
  25. data/db/migrate/20200605161350_add_passing_criterions_to_exam.rb +6 -0
  26. data/db/migrate/20200608132959_add_progressive_display_lookahead_to_organizations.rb +5 -0
  27. data/db/migrate/20200702165503_add_messages_count_to_discussion.rb +6 -0
  28. data/db/migrate/20200728162727_add_not_actually_a_question_field_to_messages.rb +5 -0
  29. data/db/migrate/20200728163038_add_requires_moderator_response_to_discussions.rb +5 -0
  30. data/db/migrate/20200731081757_add_last_moderator_access_fields_to_discussion.rb +6 -0
  31. data/lib/mumuki/domain.rb +2 -0
  32. data/lib/mumuki/domain/area.rb +13 -0
  33. data/lib/mumuki/domain/exceptions.rb +2 -0
  34. data/lib/mumuki/domain/exceptions/disabled_organization_error.rb +2 -0
  35. data/lib/mumuki/domain/exceptions/unprepared_organization_error.rb +2 -0
  36. data/lib/mumuki/domain/factories/course_factory.rb +2 -0
  37. data/lib/mumuki/domain/factories/exam_factory.rb +1 -0
  38. data/lib/mumuki/domain/helpers/organization.rb +6 -1
  39. data/lib/mumuki/domain/helpers/user.rb +6 -6
  40. data/lib/mumuki/domain/locales/activerecord/en.yml +1 -0
  41. data/lib/mumuki/domain/locales/activerecord/es.yml +1 -0
  42. data/lib/mumuki/domain/locales/activerecord/pt.yml +1 -0
  43. data/lib/mumuki/domain/organization/settings.rb +12 -1
  44. data/lib/mumuki/domain/status/discussion/discussion.rb +4 -0
  45. data/lib/mumuki/domain/status/discussion/opened.rb +0 -8
  46. data/lib/mumuki/domain/submission/base.rb +6 -0
  47. data/lib/mumuki/domain/submission/solution.rb +3 -1
  48. data/lib/mumuki/domain/version.rb +1 -1
  49. data/lib/mumuki/domain/workspace.rb +38 -0
  50. metadata +16 -5
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4e4bc7628cef1eb1a3d8ed748fc2fd8c49830ffd9814058360aa324aa6bcde35
4
- data.tar.gz: c32ffaac2a2cef4a4e7f71ded9c4f81f233db86f55a1a0d7a67bdda0792c9489
3
+ metadata.gz: f1efee52b12a7be242fdb13883331838d1ee17e0a396d5b0df95b54c3d209bdb
4
+ data.tar.gz: a6e75dfa224cd6a3642c77d23d1190906d427506b0fb90905b1f636f2c9ecd72
5
5
  SHA512:
6
- metadata.gz: ce2e45ade3b54b53eec50718f7e42ef1f6cc51d9aa39706651c3cbd6e9d156b043c300d9d19e333e4ecf5955b5d2e915fe4aa0162830983e92d37892bca5f952
7
- data.tar.gz: f3b7a44f946871521a943efbccd3d3d24594cfda1eb2493f6be706ab65738ca90334da2b3c1c5521a9936ad9a58c2856bb132c2e71dd0a440a72ed9914832dd1
6
+ metadata.gz: f66e5cb6acf1f40de30d9a1da7056626eeb513db95822a75b4b59fae0fbe0718795fd5ad33c38fd2df1e5a277d3a1b5390c78b87558c4976b4cc72921912f86f
7
+ data.tar.gz: 6f2e61ba1908513448c72c448cb1e0f92582e502304d6756bdb82f4ca4d0a9dfe783fc12669613ea4bf99cd3e1bf2b8397ac6142e28cad8517dd0123175dbfcb
@@ -62,4 +62,17 @@ class Book < Content
62
62
  def structural_parent
63
63
  nil
64
64
  end
65
+
66
+ ## progressive display
67
+
68
+ def enabled_chapters_in(workspace)
69
+ workspace.enabled_containers(chapters)
70
+ end
71
+
72
+ # experimental API - it may change in the future.
73
+ # This method assumes no gaps in the sequences are introduced
74
+ # by enabled_chapters_in
75
+ def chapter_visibilities_in(workspace)
76
+ chapters.zip(enabled_chapters_in(workspace)).map { |chapter, enabled| [chapter, !enabled.nil?] }
77
+ end
65
78
  end
@@ -1,4 +1,4 @@
1
- module WithContent
1
+ module Container
2
2
  extend ActiveSupport::Concern
3
3
 
4
4
  included do
@@ -1,6 +1,6 @@
1
1
  module GuideContainer
2
2
  extend ActiveSupport::Concern
3
- include WithContent
3
+ include Container
4
4
 
5
5
  included do
6
6
  associated_content :guide
@@ -1,6 +1,6 @@
1
1
  module Solvable
2
- def submit_solution!(user, attributes={})
3
- assignment, _ = find_assignment_and_submit! user, attributes[:content].to_mumuki_solution(language)
2
+ def submit_solution!(user, submission_attributes={})
3
+ assignment, _ = find_assignment_and_submit! user, solution_for(submission_attributes)
4
4
  try_solve_discussions(user) if assignment.solved?
5
5
  assignment
6
6
  end
@@ -8,6 +8,12 @@ module Solvable
8
8
  def run_tests!(params)
9
9
  language.run_tests!(params.merge(locale: locale, expectations: expectations, custom_expectations: custom_expectations))
10
10
  end
11
+
12
+ def solution_for(submission_attributes)
13
+ submission_attributes[:content]
14
+ .to_mumuki_solution(language)
15
+ .with_client_result(submission_attributes[:client_result])
16
+ end
11
17
  end
12
18
 
13
19
  class NilClass
@@ -1,6 +1,6 @@
1
1
  module TopicContainer
2
2
  extend ActiveSupport::Concern
3
- include WithContent
3
+ include Container
4
4
 
5
5
  included do
6
6
  associated_content :topic
@@ -7,11 +7,7 @@ module WithDiscussions
7
7
  end
8
8
 
9
9
  def discuss!(user, discussion, organization = Organization.current)
10
- discussion.merge!(initiator_id: user.id, organization: organization)
11
- discussion.merge!(submission: submission_for(user)) if submission_for(user).present?
12
- created_discussion = discussions.create discussion
13
- user.subscribe_to! created_discussion
14
- created_discussion
10
+ new_discussion_for(user, discussion, organization).tap &:save!
15
11
  end
16
12
 
17
13
  def submission_for(_)
@@ -21,4 +17,11 @@ module WithDiscussions
21
17
  def try_solve_discussions(user)
22
18
  discussions.where(initiator: user).map(&:try_solve!)
23
19
  end
20
+
21
+ def new_discussion_for(user, discussion_h = {}, organization = Organization.current)
22
+ discussion_h.merge!(initiator_id: user.id, organization: organization)
23
+ discussion_h.merge!(submission: submission_for(user)) if submission_for(user).present?
24
+ discussions.new discussion_h
25
+ end
26
+
24
27
  end
@@ -2,6 +2,10 @@ module WithLayout
2
2
  extend ActiveSupport::Concern
3
3
 
4
4
  included do
5
- enum layout: [:input_right, :input_bottom, :input_kids]
5
+ enum layout: [:input_right, :input_bottom, :input_primary, :input_kindergarten]
6
+ end
7
+
8
+ def input_kids?
9
+ input_primary? || input_kindergarten?
6
10
  end
7
11
  end
@@ -19,6 +19,10 @@ module WithProgress
19
19
  self
20
20
  end
21
21
 
22
+ def completed_for?(user, organization)
23
+ progress_for(user, organization).completed?
24
+ end
25
+
22
26
  private
23
27
 
24
28
  def structural_children_changed?(old_structural_children)
@@ -16,8 +16,8 @@ module WithScopedQueries
16
16
  end
17
17
 
18
18
  class_methods do
19
- def query_methods
20
- queriable_attributes.keys
19
+ def query_methods(excluded_methods)
20
+ queriable_attributes.keys - excluded_methods.to_a
21
21
  end
22
22
 
23
23
  def scoped_query_module(method)
@@ -28,19 +28,19 @@ module WithScopedQueries
28
28
  queriable_attributes.values.flatten
29
29
  end
30
30
 
31
- def actual_params(params, excluded_param)
32
- params.reject { |it| it == excluded_param.to_s }
31
+ def actual_params(params, excluded_params)
32
+ params.except(*excluded_params)
33
33
  end
34
34
 
35
- def scoped_query_by(params, excluded_param=nil)
36
- query_methods.inject(all) do |scope, method|
37
- valid_params = valid_params_for(method, params, excluded_param)
35
+ def scoped_query_by(params, **options)
36
+ query_methods(options[:excluded_methods]).inject(all) do |scope, method|
37
+ valid_params = valid_params_for(method, params, options[:excluded_params])
38
38
  scoped_query_module(method).query_by valid_params, scope, self
39
39
  end
40
40
  end
41
41
 
42
- def valid_params_for(method, params, excluded_param)
43
- actual_params = actual_params(params, excluded_param)
42
+ def valid_params_for(method, params, excluded_params)
43
+ actual_params = actual_params(params, excluded_params)
44
44
  actual_params.permit queriable_attributes[method]
45
45
  end
46
46
  end
@@ -1,7 +1,7 @@
1
1
  module WithScopedQueries::Page
2
2
  def self.query_by(params, current_scope, _)
3
3
  page_param = params[:page] || 1
4
- current_scope.page(page_param)
4
+ current_scope.page(page_param).per(10)
5
5
  end
6
6
 
7
7
  def self.add_queriable_attributes_to(klass, _)
@@ -34,13 +34,18 @@ module WithScopedQueries::Sort
34
34
  end
35
35
 
36
36
  class_methods do
37
+ def opposite(direction)
38
+ dir = direction.to_s.downcase.to_sym
39
+ [:asc, :desc].find { |it| it != dir }
40
+ end
41
+
37
42
  def sorting_filters
38
43
  sorting_fields.product([:asc, :desc]).map do |it|
39
44
  "#{it.first}_#{it.second}"
40
45
  end
41
46
  end
42
47
 
43
- def sorting_params_allowed?(sort_param, direction_param=nil)
48
+ def sorting_params_allowed?(sort_param, direction_param = nil)
44
49
  sorting_fields.include?(sort_param.to_sym) && [:asc, :desc].include?(direction_param&.to_sym)
45
50
  end
46
51
  end
@@ -1,6 +1,7 @@
1
1
  class Course < ApplicationRecord
2
2
  include Mumuki::Domain::Syncable
3
3
  include Mumuki::Domain::Helpers::Course
4
+ include Mumuki::Domain::Area
4
5
 
5
6
  validates_presence_of :slug, :shifts, :code, :days, :period, :description, :organization_id
6
7
  validates_uniqueness_of :slug
@@ -13,7 +14,7 @@ class Course < ApplicationRecord
13
14
  resource_fields :slug, :shifts, :code, :days, :period, :description
14
15
 
15
16
  def current_invitation
16
- invitations.where('expiration_date > ?', Time.now).take
17
+ invitations.where('expiration_date > ?', Time.now).first
17
18
  end
18
19
 
19
20
  def import_from_resource_h!(resource_h)
@@ -21,10 +22,8 @@ class Course < ApplicationRecord
21
22
  end
22
23
 
23
24
  def slug=(slug)
24
- s = Mumukit::Auth::Slug.parse(slug)
25
-
26
- self[:slug] = slug
27
- self[:code] = s.course
25
+ s = slug.to_mumukit_slug
26
+ self[:slug] = slug.to_s
28
27
  self[:organization_id] = Organization.locate!(s.organization).id
29
28
  end
30
29
 
@@ -41,11 +40,23 @@ class Course < ApplicationRecord
41
40
  end
42
41
 
43
42
  def generate_invitation!(expiration_date)
44
- invitation = invitations.build expiration_date: expiration_date, course: self
45
- invitation.save_and_notify!
43
+ invitations.create expiration_date: expiration_date, course: self
44
+ current_invitation
46
45
  end
47
46
 
48
47
  def self.sync_key_id_field
49
48
  :slug
50
49
  end
50
+
51
+ def to_organization
52
+ organization
53
+ end
54
+
55
+ def to_s
56
+ slug.to_s
57
+ end
58
+
59
+ def self.allowed_for(user, organization = Organization.current)
60
+ where(organization: organization).select { |course| user.teacher_of? course.slug }
61
+ end
51
62
  end
@@ -4,25 +4,29 @@ class Discussion < ApplicationRecord
4
4
  belongs_to :item, polymorphic: true
5
5
  has_many :messages, -> { order(:created_at) }, dependent: :destroy
6
6
  belongs_to :initiator, class_name: 'User'
7
+ belongs_to :last_moderator_access_by, class_name: 'User', optional: true
7
8
  belongs_to :exercise, foreign_type: :exercise, foreign_key: 'item_id'
8
9
  belongs_to :organization
9
10
  has_many :subscriptions
10
11
  has_many :upvotes
11
12
 
12
13
  scope :by_language, -> (language) { includes(:exercise).joins(exercise: :language).where(languages: {name: language}) }
14
+ scope :order_by_responses_count, -> (direction) { reorder(validated_messages_count: direction, messages_count: opposite(direction)) }
15
+ scope :by_requires_moderator_response, -> (boolean) { where(requires_moderator_response: boolean.to_boolean) }
13
16
 
14
- before_save :capitalize_title
15
- validates_presence_of :title
17
+ after_create :subscribe_initiator!
16
18
 
17
19
  markdown_on :description
18
20
 
19
- sortable :created_at, :upvotes_count, default: :created_at_desc
20
- filterable :status, :language
21
+ sortable :responses_count, :upvotes_count, :created_at, default: :created_at_desc
22
+ filterable :status, :language, :requires_moderator_response
21
23
  pageable
22
24
 
23
25
  delegate :language, to: :item
24
26
  delegate :to_discussion_status, to: :status
25
27
 
28
+ MODERATOR_REVIEW_AVERAGE_TIME = 10.minutes
29
+
26
30
  scope :for_user, -> (user) do
27
31
  if user.try(:moderator_here?)
28
32
  all
@@ -37,10 +41,6 @@ class Discussion < ApplicationRecord
37
41
  end
38
42
  end
39
43
 
40
- def capitalize_title
41
- title.capitalize!
42
- end
43
-
44
44
  def used_in?(organization)
45
45
  organization == self.organization
46
46
  end
@@ -66,7 +66,7 @@ class Discussion < ApplicationRecord
66
66
  end
67
67
 
68
68
  def friendly
69
- title
69
+ initiator.name
70
70
  end
71
71
 
72
72
  def subscription_for(user)
@@ -125,14 +125,52 @@ class Discussion < ApplicationRecord
125
125
  responses_count > 0
126
126
  end
127
127
 
128
+ def subscribe_initiator!
129
+ initiator.subscribe_to! self
130
+ end
131
+
128
132
  def extra_preview_html
129
133
  # FIXME this is buggy, because the extra
130
134
  # may have changed since the submission of this discussion
131
135
  exercise.assignment_for(initiator).extra_preview_html
132
136
  end
133
137
 
138
+ def update_counters!
139
+ messages_query = messages_by_updated_at
140
+ validated_messages = messages_query.select &:validated?
141
+ requires_moderator_response = messages_query.find { |it| it.validated? || it.question? }&.from_initiator?
142
+ update! messages_count: messages_query.count,
143
+ validated_messages_count: validated_messages.count,
144
+ requires_moderator_response: requires_moderator_response
145
+ end
146
+
147
+ def update_last_moderator_access!(user)
148
+ if user&.moderator_here? && !last_moderator_access_visible_for?(user)
149
+ update! last_moderator_access_at: Time.now,
150
+ last_moderator_access_by: user
151
+ end
152
+ end
153
+
154
+ def being_accessed_by_moderator?
155
+ last_moderator_access_at.present? && last_moderator_access_at > Time.now - MODERATOR_REVIEW_AVERAGE_TIME
156
+ end
157
+
158
+ def last_moderator_access_visible_for?(user)
159
+ show_last_moderator_access_for?(user) && being_accessed_by_moderator?
160
+ end
161
+
162
+ def show_last_moderator_access_for?(user)
163
+ user&.moderator_here? && last_moderator_access_by != user
164
+ end
165
+
134
166
  def self.debatable_for(klazz, params)
135
167
  debatable_id = params[:"#{klazz.underscore}_id"]
136
168
  klazz.constantize.find(debatable_id)
137
169
  end
170
+
171
+ private
172
+
173
+ def messages_by_updated_at(direction = :desc)
174
+ messages.reorder(updated_at: direction)
175
+ end
138
176
  end
@@ -1,19 +1,28 @@
1
1
  class Exam < ApplicationRecord
2
+
2
3
  include GuideContainer
3
4
  include FriendlyName
4
-
5
- validates_presence_of :start_time, :end_time
5
+ include TerminalNavigation
6
6
 
7
7
  belongs_to :organization
8
+ belongs_to :course, optional: true
8
9
 
9
10
  has_many :authorizations, class_name: 'ExamAuthorization', dependent: :destroy
10
11
  has_many :users, through: :authorizations
11
12
 
13
+ enum passing_criterion_type: [:none, :percentage, :passed_exercises], _prefix: :passing_criterion
14
+
15
+ validates_presence_of :start_time, :end_time
16
+ validates_numericality_of :max_problem_submissions, :max_choice_submissions, greater_than_or_equal_to: 1, allow_nil: true
17
+
18
+ before_save :set_default_criterion_type!
19
+ before_save :ensure_valid_passing_criterion!
20
+
21
+ before_create :set_classroom_id!
22
+
12
23
  after_destroy { |record| Usage.destroy_usages_for record }
13
24
  after_create :reindex_usages!
14
25
 
15
- include TerminalNavigation
16
-
17
26
  def used_in?(organization)
18
27
  organization == self.organization
19
28
  end
@@ -116,10 +125,41 @@ class Exam < ApplicationRecord
116
125
  index_usage! organization
117
126
  end
118
127
 
128
+ def attempts_left_for(assignment)
129
+ max_attempts_for(assignment.exercise) - (assignment.attempts_count || 0)
130
+ end
131
+
132
+ def limited_for?(exercise)
133
+ max_attempts_for(exercise).present?
134
+ end
135
+
136
+ def results_hidden_for?(exercise)
137
+ exercise.choice? && results_hidden_for_choices?
138
+ end
139
+
140
+ def resettable?
141
+ false
142
+ end
143
+
144
+ def set_classroom_id!
145
+ self.classroom_id ||= SecureRandom.hex(8)
146
+ end
147
+
148
+ def passing_criterion
149
+ @passing_criterion ||= Exam::PassingCriterion.parse(passing_criterion_type, passing_criterion_value)
150
+ end
151
+
152
+ def ensure_valid_passing_criterion!
153
+ passing_criterion.ensure_valid!
154
+ end
155
+
156
+ def set_default_criterion_type!
157
+ self.passing_criterion_type ||= :none
158
+ end
159
+
119
160
  def self.import_from_resource_h!(json)
120
161
  exam_data = json.with_indifferent_access
121
- organization = Organization.locate! exam_data[:organization]
122
- organization.switch!
162
+ Organization.locate!(exam_data[:organization].to_s).switch!
123
163
  adapt_json_values exam_data
124
164
  remove_previous_version exam_data[:eid], exam_data[:guide_id]
125
165
  exam = where(classroom_id: exam_data[:eid]).update_or_create!(whitelist_attributes(exam_data))
@@ -141,8 +181,13 @@ class Exam < ApplicationRecord
141
181
  def self.adapt_json_values(exam)
142
182
  exam[:guide_id] = Guide.locate!(exam[:slug]).id
143
183
  exam[:organization_id] = Organization.current.id
184
+ exam[:course_id] = Course.locate!(exam[:course].to_s).id
144
185
  exam[:users] = User.where(uid: exam[:uids])
145
- [:start_time, :end_time].each { |param| exam[param] = exam[param].to_time }
186
+ exam[:start_time] = exam[:start_time].to_time
187
+ exam[:end_time] = exam[:end_time].to_time
188
+ exam[:classroom_id] = exam[:eid] if exam[:eid].present?
189
+ exam[:passing_criterion_type] = exam.dig(:passing_criterion, :type)
190
+ exam[:passing_criterion_value] = exam.dig(:passing_criterion, :value)
146
191
  end
147
192
 
148
193
  def self.remove_previous_version(eid, guide_id)
@@ -153,25 +198,10 @@ class Exam < ApplicationRecord
153
198
  end
154
199
  end
155
200
 
156
- def attempts_left_for(assignment)
157
- max_attempts_for(assignment.exercise) - (assignment.attempts_count || 0)
158
- end
159
-
160
- def limited_for?(exercise)
161
- max_attempts_for(exercise).present?
162
- end
163
-
164
- def results_hidden_for?(exercise)
165
- exercise.choice? && results_hidden_for_choices?
166
- end
167
-
168
- def resettable?
169
- false
170
- end
171
-
172
201
  private
173
202
 
174
203
  def max_attempts_for(exercise)
175
204
  exercise.choice? ? max_choice_submissions : max_problem_submissions
176
205
  end
206
+
177
207
  end
@@ -0,0 +1,53 @@
1
+ class Exam::PassingCriterion
2
+
3
+ attr_reader :value
4
+
5
+ def initialize(value)
6
+ @value = value
7
+ end
8
+
9
+ def type
10
+ self.class.name.demodulize.underscore
11
+ end
12
+
13
+ def as_json
14
+ {type: type, value: value}
15
+ end
16
+
17
+ def ensure_valid!
18
+ raise "Invalid criterion value #{value} for #{type}" unless valid_passing_grade?
19
+ end
20
+
21
+ def self.parse(type, value)
22
+ parse_criterion_type(type, value)
23
+ end
24
+
25
+ def self.parse_criterion_type(type, value)
26
+ "Exam::PassingCriterion::#{type.camelize}".constantize.new(value)
27
+ rescue
28
+ raise "Invalid criterion type #{type}"
29
+ end
30
+
31
+ end
32
+
33
+ class Exam::PassingCriterion::None < Exam::PassingCriterion
34
+ def initialize(_)
35
+ @value = nil
36
+ end
37
+
38
+ def valid_passing_grade?
39
+ !value
40
+ end
41
+ end
42
+
43
+ class Exam::PassingCriterion::Percentage < Exam::PassingCriterion
44
+ def valid_passing_grade?
45
+ value.between? 0, 100
46
+ end
47
+ end
48
+
49
+ class Exam::PassingCriterion::PassedExercises < Exam::PassingCriterion
50
+ def valid_passing_grade?
51
+ value >= 0
52
+ end
53
+ end
@@ -20,6 +20,6 @@ class Challenge < Exercise
20
20
 
21
21
  def defaults
22
22
  super
23
- self.layout = self.class.default_layout
23
+ self.layout ||= self.class.default_layout
24
24
  end
25
25
  end
@@ -45,6 +45,12 @@ class Problem < QueriableChallenge
45
45
  own_expectations.present? || own_custom_expectations.present?
46
46
  end
47
47
 
48
+ # Sets the layout. This method accepts input_kids as a synonym of input_primary
49
+ # for historical reasons
50
+ def layout=(layout)
51
+ self[:layout] = layout.like?(:input_kids) ? :input_primary : layout
52
+ end
53
+
48
54
  private
49
55
 
50
56
  def ensure_evaluation_criteria
@@ -11,6 +11,10 @@ class Reading < Exercise
11
11
  false
12
12
  end
13
13
 
14
+ def layout=(layout)
15
+ raise 'can not set a layout different to input_bottom on readings' unless layout.like? :input_bottom
16
+ end
17
+
14
18
  def queriable?
15
19
  false
16
20
  end
@@ -2,12 +2,18 @@ class Invitation < ApplicationRecord
2
2
  include Mumuki::Domain::Syncable
3
3
 
4
4
  belongs_to :course
5
+
6
+ validate :ensure_not_expired, on: :create
5
7
  validates_uniqueness_of :code
6
8
 
7
9
  defaults do
8
10
  self.code ||= self.class.generate_code
9
11
  end
10
12
 
13
+ def ensure_not_expired
14
+ errors.add(:base, :invitation_expired) if expired?
15
+ end
16
+
11
17
  def import_from_resource_h!(json)
12
18
  update! json.merge(course: Course.locate!(json[:course]))
13
19
  end
@@ -25,7 +31,7 @@ class Invitation < ApplicationRecord
25
31
  end
26
32
 
27
33
  def to_resource_h
28
- { code: code, course: course_slug, expiration_date: expiration_date }
34
+ {code: code, course: course_slug, expiration_date: expiration_date}
29
35
  end
30
36
 
31
37
  def navigation_end?
@@ -6,6 +6,10 @@ class Message < ApplicationRecord
6
6
 
7
7
  validates_presence_of :content, :sender
8
8
  validates_presence_of :submission_id, :unless => :discussion_id?
9
+
10
+ after_save :update_counters_cache!
11
+ after_destroy :update_counters_cache!
12
+
9
13
  markdown_on :content
10
14
 
11
15
  def notify!
@@ -16,6 +20,10 @@ class Message < ApplicationRecord
16
20
  sender_user == discussion&.initiator
17
21
  end
18
22
 
23
+ def from_moderator?
24
+ sender_user.moderator_here?
25
+ end
26
+
19
27
  def from_user?(user)
20
28
  sender_user == user
21
29
  end
@@ -33,9 +41,9 @@ class Message < ApplicationRecord
33
41
  end
34
42
 
35
43
  def to_resource_h
36
- as_json(except: [:id, :type, :discussion_id, :approved],
44
+ as_json(except: [:id, :type, :discussion_id, :approved, :not_actually_a_question],
37
45
  include: {exercise: {only: [:bibliotheca_id]}})
38
- .merge(organization: Organization.current.name)
46
+ .merge(organization: Organization.current.name)
39
47
  end
40
48
 
41
49
  def read!
@@ -46,11 +54,27 @@ class Message < ApplicationRecord
46
54
  toggle! :approved
47
55
  end
48
56
 
57
+ def toggle_not_actually_a_question!
58
+ toggle! :not_actually_a_question
59
+ end
60
+
61
+ def validated?
62
+ approved? || from_moderator?
63
+ end
64
+
65
+ def update_counters_cache!
66
+ discussion&.update_counters!
67
+ end
68
+
69
+ def question?
70
+ from_initiator? && !not_actually_a_question?
71
+ end
72
+
49
73
  def self.parse_json(json)
50
74
  message = json.delete 'message'
51
75
  json
52
- .except('uid', 'exercise_id')
53
- .merge(message)
76
+ .except('uid', 'exercise_id')
77
+ .merge(message)
54
78
  end
55
79
 
56
80
  def self.read_all!
@@ -1,6 +1,7 @@
1
1
  class Organization < ApplicationRecord
2
2
  include Mumuki::Domain::Syncable
3
3
  include Mumuki::Domain::Helpers::Organization
4
+ include Mumuki::Domain::Area
4
5
 
5
6
  include Mumukit::Login::OrganizationHelpers
6
7
 
@@ -11,6 +12,7 @@ class Organization < ApplicationRecord
11
12
  markdown_on :description
12
13
 
13
14
  validate :ensure_consistent_public_login
15
+ validate :ensure_valid_activity_range
14
16
 
15
17
  belongs_to :book
16
18
  has_many :usages
@@ -126,12 +128,26 @@ class Organization < ApplicationRecord
126
128
  super.merge(book: book.slug)
127
129
  end
128
130
 
131
+ def to_organization
132
+ self
133
+ end
134
+
135
+ def enable_progressive_display!(lookahead: 1)
136
+ update! progressive_display_lookahead: lookahead
137
+ end
138
+
129
139
  private
130
140
 
131
141
  def ensure_consistent_public_login
132
142
  errors.add(:base, :consistent_public_login) if settings.customized_login_methods? && public?
133
143
  end
134
144
 
145
+ def ensure_valid_activity_range
146
+ if in_preparation_until.present? && disabled_from.present?
147
+ errors.add(:base, :invalid_activity_range) if in_preparation_until.to_datetime >= disabled_from.to_datetime
148
+ end
149
+ end
150
+
135
151
  def notify_assignments!(assignments)
136
152
  assignments.each { |assignment| assignment.notify! }
137
153
  end
@@ -32,7 +32,7 @@ class User < ApplicationRecord
32
32
 
33
33
  after_initialize :init
34
34
 
35
- enum gender: %i(female male other)
35
+ enum gender: %i(female male other unspecified)
36
36
 
37
37
  belongs_to :avatar, optional: true
38
38
 
@@ -154,6 +154,25 @@ class User < ApplicationRecord
154
154
  update! self.class.buried_profile.merge(accepts_reminders: false, gender: nil, birthdate: nil)
155
155
  end
156
156
 
157
+ # Takes a didactic - ordered - sequence of content containers
158
+ # and returns those that have been completed
159
+ def completed_containers(sequence, organization)
160
+ sequence.take_while { |it| it.content.completed_for?(self, organization) }
161
+ end
162
+
163
+ # Like `completed_containers`, returns a slice of the completed containers
164
+ # in the sequence, but adding a configurable number of trailing, non-completed contaienrs
165
+ def completed_containers_with_lookahead(sequence, organization, lookahead: 1)
166
+ raise 'invalid lookahead' if lookahead < 1
167
+
168
+ count = completed_containers(sequence, organization).size
169
+ sequence[0..count + lookahead - 1]
170
+ end
171
+
172
+ def name_initials
173
+ name.split.map(&:first).map(&:capitalize).join(' ')
174
+ end
175
+
157
176
  private
158
177
 
159
178
  def set_uid!
@@ -161,10 +180,7 @@ class User < ApplicationRecord
161
180
  end
162
181
 
163
182
  def init
164
- # Temporarily keep using image_url until avatars are created
165
- # self.avatar = Avatar.sample unless profile_picture.present?
166
-
167
- self.image_url ||= "user_shape.png"
183
+ self.avatar = Avatar.sample unless profile_picture.present?
168
184
  end
169
185
 
170
186
  def self.sync_key_id_field
@@ -0,0 +1,5 @@
1
+ class AddCourseToExam < ActiveRecord::Migration[5.1]
2
+ def change
3
+ add_reference :exams, :course
4
+ end
5
+ end
@@ -0,0 +1,6 @@
1
+ class AddPassingCriterionsToExam < ActiveRecord::Migration[5.1]
2
+ def change
3
+ add_column :exams, :passing_criterion_type, :integer, default: 0
4
+ add_column :exams, :passing_criterion_value, :integer
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ class AddProgressiveDisplayLookaheadToOrganizations < ActiveRecord::Migration[5.1]
2
+ def change
3
+ add_column :organizations, :progressive_display_lookahead, :integer
4
+ end
5
+ end
@@ -0,0 +1,6 @@
1
+ class AddMessagesCountToDiscussion < ActiveRecord::Migration[5.1]
2
+ def change
3
+ add_column :discussions, :messages_count, :integer, default: 0
4
+ add_column :discussions, :validated_messages_count, :integer, default: 0
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ class AddNotActuallyAQuestionFieldToMessages < ActiveRecord::Migration[5.1]
2
+ def change
3
+ add_column :messages, :not_actually_a_question, :boolean, default: false
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ class AddRequiresModeratorResponseToDiscussions < ActiveRecord::Migration[5.1]
2
+ def change
3
+ add_column :discussions, :requires_moderator_response, :boolean, default: true
4
+ end
5
+ end
@@ -0,0 +1,6 @@
1
+ class AddLastModeratorAccessFieldsToDiscussion < ActiveRecord::Migration[5.1]
2
+ def change
3
+ add_column :discussions, :last_moderator_access_by_id, :string
4
+ add_column :discussions, :last_moderator_access_at, :datetime
5
+ end
6
+ end
@@ -25,6 +25,7 @@ Mumukit::Platform.configure do |config|
25
25
  config.organization_class_name = 'Organization'
26
26
  end
27
27
 
28
+ require_relative './domain/area'
28
29
  require_relative './domain/evaluation'
29
30
  require_relative './domain/submission'
30
31
  require_relative './domain/status'
@@ -32,6 +33,7 @@ require_relative './domain/exceptions'
32
33
  require_relative './domain/file'
33
34
  require_relative './domain/extensions'
34
35
  require_relative './domain/organization'
36
+ require_relative './domain/workspace'
35
37
  require_relative './domain/helpers'
36
38
  require_relative './domain/syncable'
37
39
  require_relative './domain/store'
@@ -0,0 +1,13 @@
1
+ module Mumuki::Domain::Area
2
+ extend ActiveSupport::Concern
3
+
4
+ def to_mumukit_grant
5
+ slug.to_mumukit_grant
6
+ end
7
+
8
+ def to_mumukit_slug
9
+ slug
10
+ end
11
+
12
+ required :to_organization
13
+ end
@@ -4,3 +4,5 @@ require_relative './exceptions/not_found_error'
4
4
  require_relative './exceptions/unauthorized_error'
5
5
  require_relative './exceptions/disabled_error'
6
6
  require_relative './exceptions/blocked_forum_error'
7
+ require_relative './exceptions/unprepared_organization_error'
8
+ require_relative './exceptions/disabled_organization_error'
@@ -0,0 +1,2 @@
1
+ class Mumuki::Domain::DisabledOrganizationError < StandardError
2
+ end
@@ -0,0 +1,2 @@
1
+ class Mumuki::Domain::UnpreparedOrganizationError < StandardError
2
+ end
@@ -2,8 +2,10 @@ FactoryBot.define do
2
2
  factory :course do
3
3
  period { '2016' }
4
4
  shifts { %w(morning) }
5
+ code { "k1234-#{SecureRandom.uuid}" }
5
6
  days { %w(monday wednesday) }
6
7
  description { 'test' }
7
8
  organization_id { 1 }
9
+ slug { "#{Organization.current.name}/#{period}-#{code}" }
8
10
  end
9
11
  end
@@ -3,6 +3,7 @@ FactoryBot.define do
3
3
  factory :exam, traits: [:guide_container] do
4
4
  duration { Faker::Number.between(from: 10, to:60).minutes }
5
5
  organization { Organization.current }
6
+ course { create(:course) }
6
7
  start_time { 5.minutes.ago }
7
8
  end_time { 10.minutes.since }
8
9
  end
@@ -4,7 +4,7 @@ module Mumuki::Domain::Helpers::Organization
4
4
 
5
5
  included do
6
6
  delegate *Mumuki::Domain::Organization::Theme.accessors, to: :theme
7
- delegate *Mumuki::Domain::Organization::Settings.accessors, :private?, :login_settings, to: :settings
7
+ delegate *Mumuki::Domain::Organization::Settings.accessors, :private?, :login_settings, :in_preparation?, :disabled?, to: :settings
8
8
  delegate *Mumuki::Domain::Organization::Profile.accessors, :locale_json, to: :profile
9
9
  end
10
10
 
@@ -48,6 +48,11 @@ module Mumuki::Domain::Helpers::Organization
48
48
  Mumukit::Platform.application.organic_domain(name)
49
49
  end
50
50
 
51
+ def validate_active!
52
+ raise Mumuki::Domain::DisabledOrganizationError if disabled?
53
+ raise Mumuki::Domain::UnpreparedOrganizationError if in_preparation?
54
+ end
55
+
51
56
  ## API Exposure
52
57
 
53
58
  def to_param
@@ -28,9 +28,9 @@ module Mumuki::Domain::Helpers::User
28
28
  role_here = "#{role}_here?"
29
29
 
30
30
  # Tells whether this user has #{role} permissions in
31
- # the given organization
32
- define_method role_of do |organization|
33
- has_permission? role, organization.slug
31
+ # the given `slug_like`
32
+ define_method role_of do |slug_like|
33
+ has_permission? role, slug_like.to_mumukit_slug
34
34
  end
35
35
 
36
36
  # Tells whether this user has #{role} permissions in
@@ -49,9 +49,9 @@ module Mumuki::Domain::Helpers::User
49
49
  (Mumukit::Auth::Roles::ROLES - [:owner]).each do |role|
50
50
 
51
51
  # Assignes the #{role} role to this user
52
- # for the given slug
53
- define_method "make_#{role}_of!" do |slug|
54
- add_permission! role, slug
52
+ # for the given `grant_like`
53
+ define_method "make_#{role}_of!" do |grant_like|
54
+ add_permission! role, grant_like.to_mumukit_grant
55
55
  end
56
56
  end
57
57
 
@@ -27,3 +27,4 @@ en:
27
27
  attributes:
28
28
  base:
29
29
  consistent_public_login: 'A public organization can not restrict login methods'
30
+ invalid_activity_range: 'The organization activity range are not valid'
@@ -44,6 +44,7 @@ es:
44
44
  attributes:
45
45
  base:
46
46
  consistent_public_login: 'Una organización pública no puede restringir los métodos de login'
47
+ invalid_activity_range: 'La fecha de deshabilitación no puede ser anterior a la de inicio del recorrido'
47
48
  models:
48
49
  exercise:
49
50
  one: Ejercicio
@@ -27,3 +27,4 @@ pt:
27
27
  attributes:
28
28
  base:
29
29
  consistent_public_login: 'Uma organização pública não pode restringir métodos de login'
30
+ invalid_activity_range: 'O intervalo de atividades da organização não é válido'
@@ -11,7 +11,10 @@ class Mumuki::Domain::Organization::Settings < Mumukit::Platform::Model
11
11
  :embeddable?,
12
12
  :immersive?,
13
13
  :forum_enabled?,
14
- :report_issue_enabled?
14
+ :report_issue_enabled?,
15
+ :disabled_from,
16
+ :in_preparation_until,
17
+ :gamification_enabled?
15
18
 
16
19
  def private?
17
20
  !public?
@@ -24,4 +27,12 @@ class Mumuki::Domain::Organization::Settings < Mumukit::Platform::Model
24
27
  def forum_discussions_minimal_role
25
28
  (@forum_discussions_minimal_role || 'student').to_sym
26
29
  end
30
+
31
+ def disabled?
32
+ disabled_from.present? && disabled_from.to_datetime < DateTime.now
33
+ end
34
+
35
+ def in_preparation?
36
+ in_preparation_until.present? && in_preparation_until.to_datetime > DateTime.now
37
+ end
27
38
  end
@@ -41,4 +41,8 @@ module Mumuki::Domain::Status::Discussion
41
41
  def allowed_statuses_for(user, discussion)
42
42
  STATUSES.select { |it| it.allowed_for?(user, discussion) }
43
43
  end
44
+
45
+ def as_json(_options={})
46
+ to_s
47
+ end
44
48
  end
@@ -5,14 +5,6 @@ module Mumuki::Domain::Status::Discussion::Opened
5
5
  true
6
6
  end
7
7
 
8
- def self.reachable_statuses_for_initiator(discussion)
9
- if discussion.has_responses?
10
- [Mumuki::Domain::Status::Discussion::PendingReview]
11
- else
12
- [Mumuki::Domain::Status::Discussion::Closed]
13
- end
14
- end
15
-
16
8
  def self.reachable_statuses_for_moderator(discussion)
17
9
  if discussion.has_responses?
18
10
  [Mumuki::Domain::Status::Discussion::Closed, Mumuki::Domain::Status::Discussion::Solved]
@@ -9,6 +9,7 @@ class Mumuki::Domain::Submission::Base
9
9
  :submission_id, :queries, :query_results, :manual_evaluation_comment]
10
10
 
11
11
  attr_accessor *ATTRIBUTES
12
+ attr_accessor :client_result
12
13
 
13
14
  def self.from_attributes(*args)
14
15
  new ATTRIBUTES.zip(args).to_h
@@ -26,6 +27,11 @@ class Mumuki::Domain::Submission::Base
26
27
  results
27
28
  end
28
29
 
30
+ def with_client_result(result)
31
+ self.client_result = result if result.present?
32
+ self
33
+ end
34
+
29
35
  def evaluate!(assignment)
30
36
  try_evaluate! assignment
31
37
  rescue => e
@@ -2,6 +2,8 @@ class Mumuki::Domain::Submission::Solution < Mumuki::Domain::Submission::Persist
2
2
  attr_accessor :content
3
3
 
4
4
  def try_evaluate!(assignment)
5
- assignment.run_tests!(content: content).except(:response_type)
5
+ assignment
6
+ .run_tests!({client_result: client_result}.compact.merge(content: content))
7
+ .except(:response_type)
6
8
  end
7
9
  end
@@ -1,5 +1,5 @@
1
1
  module Mumuki
2
2
  module Domain
3
- VERSION = '7.5.0'
3
+ VERSION = '7.7.1'
4
4
  end
5
5
  end
@@ -0,0 +1,38 @@
1
+ class Mumuki::Domain::Workspace
2
+ attr_accessor :user, :area
3
+
4
+ # area is a organization-like or course-like object
5
+ # that can be converted into slugs, has content and access-rules information
6
+ def initialize(user, area)
7
+ @user = user
8
+ @area = area
9
+ end
10
+
11
+ def annonymous?
12
+ user.nil?
13
+ end
14
+
15
+ def teacher?
16
+ user.teacher_of? area
17
+ end
18
+
19
+ # Takes a didactic sequence of containers and retuns the enabled containers
20
+ # for this user in this workspace.
21
+ #
22
+ # This method does not check the user is actually member of the area,
23
+ # you should check that before sending this message
24
+ #
25
+ def enabled_containers(sequence)
26
+ return sequence if annonymous? || teacher?
27
+
28
+ # TODO refactor when introducing access rules
29
+ if area.progressive_display_lookahead
30
+ user.completed_containers_with_lookahead(
31
+ sequence,
32
+ area.to_organization,
33
+ lookahead: area.progressive_display_lookahead)
34
+ else
35
+ sequence
36
+ end
37
+ end
38
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mumuki-domain
3
3
  version: !ruby/object:Gem::Version
4
- version: 7.5.0
4
+ version: 7.7.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Franco Leonardo Bulgarelli
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-06-02 00:00:00.000000000 Z
11
+ date: 2020-07-31 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -252,6 +252,7 @@ files:
252
252
  - app/models/chapter.rb
253
253
  - app/models/complement.rb
254
254
  - app/models/concerns/assistable.rb
255
+ - app/models/concerns/container.rb
255
256
  - app/models/concerns/contextualization.rb
256
257
  - app/models/concerns/disabling.rb
257
258
  - app/models/concerns/friendly_name.rb
@@ -268,7 +269,6 @@ files:
268
269
  - app/models/concerns/topic_container.rb
269
270
  - app/models/concerns/with_assignments.rb
270
271
  - app/models/concerns/with_case_insensitive_search.rb
271
- - app/models/concerns/with_content.rb
272
272
  - app/models/concerns/with_description.rb
273
273
  - app/models/concerns/with_discussion_creation.rb
274
274
  - app/models/concerns/with_discussion_creation/subscription.rb
@@ -299,6 +299,7 @@ files:
299
299
  - app/models/discussion.rb
300
300
  - app/models/event.rb
301
301
  - app/models/exam.rb
302
+ - app/models/exam/passing_criterion.rb
302
303
  - app/models/exam_authorization.rb
303
304
  - app/models/exercise.rb
304
305
  - app/models/exercise/challenge.rb
@@ -604,7 +605,15 @@ files:
604
605
  - db/migrate/20200508191543_create_avatars.rb
605
606
  - db/migrate/20200518135658_add_avatar_to_users.rb
606
607
  - db/migrate/20200527180729_add_disabled_at_to_users.rb
608
+ - db/migrate/20200601203033_add_course_to_exam.rb
609
+ - db/migrate/20200605161350_add_passing_criterions_to_exam.rb
610
+ - db/migrate/20200608132959_add_progressive_display_lookahead_to_organizations.rb
611
+ - db/migrate/20200702165503_add_messages_count_to_discussion.rb
612
+ - db/migrate/20200728162727_add_not_actually_a_question_field_to_messages.rb
613
+ - db/migrate/20200728163038_add_requires_moderator_response_to_discussions.rb
614
+ - db/migrate/20200731081757_add_last_moderator_access_fields_to_discussion.rb
607
615
  - lib/mumuki/domain.rb
616
+ - lib/mumuki/domain/area.rb
608
617
  - lib/mumuki/domain/engine.rb
609
618
  - lib/mumuki/domain/evaluation.rb
610
619
  - lib/mumuki/domain/evaluation/automated.rb
@@ -612,10 +621,12 @@ files:
612
621
  - lib/mumuki/domain/exceptions.rb
613
622
  - lib/mumuki/domain/exceptions/blocked_forum_error.rb
614
623
  - lib/mumuki/domain/exceptions/disabled_error.rb
624
+ - lib/mumuki/domain/exceptions/disabled_organization_error.rb
615
625
  - lib/mumuki/domain/exceptions/forbidden_error.rb
616
626
  - lib/mumuki/domain/exceptions/gone_error.rb
617
627
  - lib/mumuki/domain/exceptions/not_found_error.rb
618
628
  - lib/mumuki/domain/exceptions/unauthorized_error.rb
629
+ - lib/mumuki/domain/exceptions/unprepared_organization_error.rb
619
630
  - lib/mumuki/domain/extensions.rb
620
631
  - lib/mumuki/domain/extensions/array.rb
621
632
  - lib/mumuki/domain/extensions/hash.rb
@@ -690,6 +701,7 @@ files:
690
701
  - lib/mumuki/domain/syncable.rb
691
702
  - lib/mumuki/domain/syncable/with_resource_fields.rb
692
703
  - lib/mumuki/domain/version.rb
704
+ - lib/mumuki/domain/workspace.rb
693
705
  homepage: https://mumuki.org
694
706
  licenses:
695
707
  - AGPL-3.0
@@ -709,8 +721,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
709
721
  - !ruby/object:Gem::Version
710
722
  version: '0'
711
723
  requirements: []
712
- rubyforge_project:
713
- rubygems_version: 2.7.7
724
+ rubygems_version: 3.0.8
714
725
  signing_key:
715
726
  specification_version: 4
716
727
  summary: Mumuki Platform's Domain Model