mumuki-domain 7.4.1 → 7.7.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/app/models/assignment.rb +1 -1
  3. data/app/models/avatar.rb +5 -0
  4. data/app/models/book.rb +13 -0
  5. data/app/models/concerns/{with_content.rb → container.rb} +1 -1
  6. data/app/models/concerns/contextualization.rb +53 -2
  7. data/app/models/concerns/disabling.rb +37 -0
  8. data/app/models/concerns/guide_container.rb +1 -1
  9. data/app/models/concerns/submittable/solvable.rb +8 -2
  10. data/app/models/concerns/topic_container.rb +1 -1
  11. data/app/models/concerns/with_discussions.rb +8 -5
  12. data/app/models/concerns/with_editor.rb +1 -1
  13. data/app/models/concerns/with_layout.rb +5 -1
  14. data/app/models/concerns/with_progress.rb +4 -0
  15. data/app/models/concerns/with_scoped_queries.rb +9 -9
  16. data/app/models/concerns/with_scoped_queries/page.rb +1 -1
  17. data/app/models/concerns/with_scoped_queries/sort.rb +6 -1
  18. data/app/models/course.rb +18 -7
  19. data/app/models/discussion.rb +47 -9
  20. data/app/models/exam.rb +53 -23
  21. data/app/models/exam/passing_criterion.rb +53 -0
  22. data/app/models/exercise.rb +3 -3
  23. data/app/models/exercise/challenge.rb +1 -1
  24. data/app/models/exercise/problem.rb +6 -0
  25. data/app/models/exercise/reading.rb +4 -0
  26. data/app/models/guide.rb +1 -1
  27. data/app/models/invitation.rb +7 -1
  28. data/app/models/message.rb +28 -4
  29. data/app/models/organization.rb +16 -0
  30. data/app/models/user.rb +48 -3
  31. data/db/migrate/20200508191543_create_avatars.rb +8 -0
  32. data/db/migrate/20200518135658_add_avatar_to_users.rb +5 -0
  33. data/db/migrate/20200527180729_add_disabled_at_to_users.rb +6 -0
  34. data/db/migrate/20200601203033_add_course_to_exam.rb +5 -0
  35. data/db/migrate/20200605161350_add_passing_criterions_to_exam.rb +6 -0
  36. data/db/migrate/20200608132959_add_progressive_display_lookahead_to_organizations.rb +5 -0
  37. data/db/migrate/20200702165503_add_messages_count_to_discussion.rb +6 -0
  38. data/db/migrate/20200728162727_add_not_actually_a_question_field_to_messages.rb +5 -0
  39. data/db/migrate/20200728163038_add_requires_moderator_response_to_discussions.rb +5 -0
  40. data/db/migrate/20200731081757_add_last_moderator_access_fields_to_discussion.rb +6 -0
  41. data/lib/mumuki/domain.rb +2 -0
  42. data/lib/mumuki/domain/area.rb +13 -0
  43. data/lib/mumuki/domain/exceptions.rb +3 -0
  44. data/lib/mumuki/domain/exceptions/disabled_error.rb +2 -0
  45. data/lib/mumuki/domain/exceptions/disabled_organization_error.rb +2 -0
  46. data/lib/mumuki/domain/exceptions/unprepared_organization_error.rb +2 -0
  47. data/lib/mumuki/domain/extensions/hash.rb +11 -2
  48. data/lib/mumuki/domain/extensions/string.rb +44 -0
  49. data/lib/mumuki/domain/factories/book_factory.rb +2 -2
  50. data/lib/mumuki/domain/factories/chapter_factory.rb +2 -2
  51. data/lib/mumuki/domain/factories/course_factory.rb +2 -0
  52. data/lib/mumuki/domain/factories/exam_factory.rb +2 -1
  53. data/lib/mumuki/domain/factories/guide_factory.rb +2 -2
  54. data/lib/mumuki/domain/factories/invitation_factory.rb +1 -1
  55. data/lib/mumuki/domain/factories/topic_factory.rb +2 -2
  56. data/lib/mumuki/domain/factories/user_factory.rb +1 -0
  57. data/lib/mumuki/domain/helpers/organization.rb +6 -1
  58. data/lib/mumuki/domain/helpers/user.rb +7 -7
  59. data/lib/mumuki/domain/locales/activerecord/en.yml +1 -0
  60. data/lib/mumuki/domain/locales/activerecord/es.yml +1 -0
  61. data/lib/mumuki/domain/locales/activerecord/pt.yml +1 -0
  62. data/lib/mumuki/domain/organization/settings.rb +12 -1
  63. data/lib/mumuki/domain/status/discussion/discussion.rb +4 -0
  64. data/lib/mumuki/domain/status/discussion/opened.rb +0 -8
  65. data/lib/mumuki/domain/submission/base.rb +6 -0
  66. data/lib/mumuki/domain/submission/solution.rb +3 -1
  67. data/lib/mumuki/domain/version.rb +1 -1
  68. data/lib/mumuki/domain/workspace.rb +38 -0
  69. metadata +24 -7
@@ -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
@@ -132,7 +132,7 @@ class Exercise < ApplicationRecord
132
132
  .merge(settings: self[:settings])
133
133
  .merge(RANDOMIZED_FIELDS.map { |it| [it, self[it]] }.to_h)
134
134
  .symbolize_keys
135
- .tap { |it| it.markdownify!(:hint, :corollary, :description, :teacher_info) if options[:markdownified] }
135
+ .tap { |it| it.markdownified!(:hint, :corollary, :description, :teacher_info) if options[:markdownified] }
136
136
  end
137
137
 
138
138
  def reset!
@@ -166,7 +166,7 @@ class Exercise < ApplicationRecord
166
166
  end
167
167
 
168
168
  def description_context
169
- Mumukit::ContentType::Markdown.to_html splitted_description.first
169
+ splitted_description.first.markdownified
170
170
  end
171
171
 
172
172
  def splitted_description
@@ -174,7 +174,7 @@ class Exercise < ApplicationRecord
174
174
  end
175
175
 
176
176
  def description_task
177
- Mumukit::ContentType::Markdown.to_html splitted_description.drop(1).join("\n")
177
+ splitted_description.drop(1).join("\n").markdownified
178
178
  end
179
179
 
180
180
  def custom?
@@ -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
@@ -104,7 +104,7 @@ class Guide < Content
104
104
  .merge(super)
105
105
  .merge(exercises: exercises.map { |it| it.to_resource_h(options) })
106
106
  .merge(language: language.to_embedded_resource_h)
107
- .tap { |it| it.markdownify!(:corollary, :description, :teacher_info) if options[:markdownified] }
107
+ .tap { |it| it.markdownified!(:corollary, :description, :teacher_info) if options[:markdownified] }
108
108
  end
109
109
 
110
110
  def to_markdownified_resource_h
@@ -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
@@ -4,6 +4,7 @@ class User < ApplicationRecord
4
4
  WithUserNavigation,
5
5
  WithReminders,
6
6
  WithDiscussionCreation,
7
+ Disabling,
7
8
  Mumuki::Domain::Helpers::User
8
9
 
9
10
  serialize :permissions, Mumukit::Auth::Permissions
@@ -31,12 +32,14 @@ class User < ApplicationRecord
31
32
 
32
33
  after_initialize :init
33
34
 
34
- enum gender: %i(female male other)
35
+ enum gender: %i(female male other unspecified)
36
+
37
+ belongs_to :avatar, optional: true
35
38
 
36
39
  before_validation :set_uid!
37
40
  validates :uid, presence: true
38
41
 
39
- resource_fields :uid, :social_id, :image_url, :email, :permissions, :verified_first_name, :verified_last_name, *profile_fields
42
+ resource_fields :uid, :social_id, :email, :permissions, :verified_first_name, :verified_last_name, *profile_fields
40
43
 
41
44
  def last_lesson
42
45
  last_guide.try(:lesson)
@@ -105,6 +108,10 @@ class User < ApplicationRecord
105
108
  update! self.class.slice_resource_h json
106
109
  end
107
110
 
111
+ def to_resource_h
112
+ super.merge(image_url: profile_picture)
113
+ end
114
+
108
115
  def verify_name!
109
116
  self.verified_first_name ||= first_name
110
117
  self.verified_last_name ||= last_name
@@ -138,6 +145,34 @@ class User < ApplicationRecord
138
145
  exams.any? { |e| e.in_progress_for? self }
139
146
  end
140
147
 
148
+ def profile_picture
149
+ avatar&.image_url || image_url
150
+ end
151
+
152
+ def bury!
153
+ # TODO change avatar
154
+ update! self.class.buried_profile.merge(accepts_reminders: false, gender: nil, birthdate: nil)
155
+ end
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
+
141
176
  private
142
177
 
143
178
  def set_uid!
@@ -145,7 +180,7 @@ class User < ApplicationRecord
145
180
  end
146
181
 
147
182
  def init
148
- self.image_url ||= "user_shape.png"
183
+ self.avatar = Avatar.sample unless profile_picture.present?
149
184
  end
150
185
 
151
186
  def self.sync_key_id_field
@@ -160,4 +195,14 @@ class User < ApplicationRecord
160
195
  user[:uid] ||= user[:email]
161
196
  where(uid: user[:uid]).first_or_create(user)
162
197
  end
198
+
199
+ # Call this method once as part of application initialization
200
+ # in order to enable user profile override as part of disabling process
201
+ def self.configure_buried_profile!(profile)
202
+ @buried_profile = profile
203
+ end
204
+
205
+ def self.buried_profile
206
+ (@buried_profile || {}).slice(:first_name, :last_name, :email)
207
+ end
163
208
  end