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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 68dc581849f150d3c0a958f3a1df2b25b4959b3ee71a595364cf2ac7533fbd25
4
- data.tar.gz: dc6a6e467c80e08d035ccc70c7ace81ffdabb41088564350220f76706738332a
3
+ metadata.gz: 9983a4a1b2d29d329606c4ccfe991bd979a5717a8562678316e143638857622b
4
+ data.tar.gz: 3115a2b010be3a7536bb835e36751595d510cfbc009093d6ac77c08e973de5e8
5
5
  SHA512:
6
- metadata.gz: d852df438a75e07817d1b01769559faf0a68b7c892a4423b7ee0a97a04153439388904b544a19562597015c2f613596a8236d87205aedaf86304d9141f7992ea
7
- data.tar.gz: aeb1db5771206c8de35da5e9d03395f5029ce7dcec4d5b643d65365f4d1ef01e5159ffc66038bc056300bc9f9f103549ff029bcbb7b22c08fc2b47270b7aecb8
6
+ metadata.gz: 46cb4964c07e81cb2568d10a96cc83ca3ab29417abc3012ea7898dfd0712e375ec4a37f7852bd827725e73a94dcc153766948c06cc91e2f1b8f3d91629fcb2d4
7
+ data.tar.gz: 97bc43d01513919f1a3ec4b053261c7f43590399710f3ba0c7ce026daa243547e0bda2a17508372d5ef5a33fa1b36b9c06e90f31a2fe5adb2faed791a22b2933
@@ -168,7 +168,7 @@ class Assignment < Progress
168
168
  language: {only: [:name]}},
169
169
  },
170
170
  exercise: {only: [:name, :number]},
171
- submitter: {only: [:email, :image_url, :social_id, :uid], methods: [:name]}}).
171
+ submitter: {only: [:email, :social_id, :uid], methods: [:name, :profile_picture]}}).
172
172
  deep_merge(
173
173
  'organization' => Organization.current.name,
174
174
  'sid' => submission_id,
@@ -0,0 +1,5 @@
1
+ class Avatar < ApplicationRecord
2
+ def self.sample
3
+ Avatar.order('RANDOM()').first
4
+ end
5
+ end
@@ -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
@@ -35,12 +35,34 @@ module Contextualization
35
35
  end
36
36
  end
37
37
 
38
+ # deprecated: this method does hidden assumptions about the UI not wanting
39
+ # non-empty titles to not be displayed. Also it incorrectly uses the term `visual` instead of `visible`
38
40
  def single_visual_result?
39
- test_results.size == 1 && test_results.first[:title].blank? && visible_success_output?
41
+ warn 'use single_visible_test_result? instead'
42
+ single_visible_test_result? && first_test_result[:title].blank?
40
43
  end
41
44
 
45
+ # deprecated: this method does not validate nor depends on any `visible` condition
46
+ # Also, it incorrectly uses the term `visual` instead of `visible`
42
47
  def single_visual_result_html
43
- output_content_type.to_html test_results.first[:result]
48
+ warn 'use first_test_result_html intead'
49
+ first_test_result_html
50
+ end
51
+
52
+ def single_visible_test_result?
53
+ test_results.size == 1 && visible_success_output?
54
+ end
55
+
56
+ def first_test_result
57
+ test_results.first
58
+ end
59
+
60
+ def first_test_result_html
61
+ test_result_html first_test_result
62
+ end
63
+
64
+ def test_result_html(test_result)
65
+ output_content_type.to_html test_result[:result]
44
66
  end
45
67
 
46
68
  def results_body_hidden?
@@ -76,6 +98,7 @@ module Contextualization
76
98
  end
77
99
 
78
100
  def humanized_expectation_results
101
+ warn "Don't use humanized_expectation_results. Use affable_expectation_results, which also handles markdown and sanitization"
79
102
  visible_expectation_results.map do |it|
80
103
  {
81
104
  result: it[:result],
@@ -83,4 +106,32 @@ module Contextualization
83
106
  }
84
107
  end
85
108
  end
109
+
110
+ ####################
111
+ ## Affable results
112
+ ####################
113
+
114
+ def affable_expectation_results
115
+ visible_expectation_results.map do |it|
116
+ {
117
+ result: it[:result],
118
+ explanation: Mulang::Expectation.parse(it).translate(inspection_keywords).affable
119
+ }
120
+ end
121
+ end
122
+
123
+ def affable_tips
124
+ tips.map(&:affable)
125
+ end
126
+
127
+ def affable_test_results
128
+ test_results.to_a.map do |it|
129
+ { summary: it.dig(:summary, :message).affable }
130
+ .compact
131
+ .merge(
132
+ title: it[:title].affable,
133
+ result: it[:result].sanitized,
134
+ status: it[:status])
135
+ end
136
+ end
86
137
  end
@@ -0,0 +1,37 @@
1
+ # The disposable module is a soft-delete helper that:
2
+ #
3
+ # * adds `disable!` method that set a `disabled_at` attribute and then _buries_ the object
4
+ # * adds a `bury!` hook method that allows further modification when disabling
5
+ # * aliases `destroy!` and `destroy` to `disable!`, but still keeps `delete` and friends
6
+ #
7
+ module Disabling
8
+ extend ActiveSupport::Concern
9
+
10
+ def disable!
11
+ transaction do
12
+ update_attribute :disabled_at, Time.current
13
+ bury!
14
+ end
15
+ end
16
+
17
+ def disabled?
18
+ disabled_at.present?
19
+ end
20
+
21
+ def enabled?
22
+ !disabled?
23
+ end
24
+
25
+ # override to perform additional
26
+ # post-disable actions
27
+ def bury!
28
+ end
29
+
30
+ def ensure_enabled!
31
+ raise Mumuki::Domain::DisabledError if disabled?
32
+ end
33
+
34
+ alias_method :destroy!, :disable!
35
+ alias_method :destroy, :disable!
36
+ end
37
+
@@ -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
@@ -19,7 +19,7 @@ module WithEditor
19
19
  struct id: "content_choice_#{index}",
20
20
  index: index,
21
21
  value: choice,
22
- text: Mumukit::ContentType::Markdown.to_html(choice_text(choice))
22
+ text: choice_text(choice).markdownified
23
23
  end
24
24
  end
25
25
 
@@ -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
+ unless 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