mumuki-classroom 8.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (68) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +661 -0
  3. data/README.md +371 -0
  4. data/Rakefile +29 -0
  5. data/lib/mumuki/classroom.rb +56 -0
  6. data/lib/mumuki/classroom/collection.rb +10 -0
  7. data/lib/mumuki/classroom/engine.rb +8 -0
  8. data/lib/mumuki/classroom/event.rb +4 -0
  9. data/lib/mumuki/classroom/event/user_changed.rb +54 -0
  10. data/lib/mumuki/classroom/locales/en.yml +3 -0
  11. data/lib/mumuki/classroom/locales/es-CL.yml +3 -0
  12. data/lib/mumuki/classroom/locales/es.yml +3 -0
  13. data/lib/mumuki/classroom/locales/pt.yml +3 -0
  14. data/lib/mumuki/classroom/models.rb +22 -0
  15. data/lib/mumuki/classroom/models/assignment.rb +148 -0
  16. data/lib/mumuki/classroom/models/concerns/course_member.rb +53 -0
  17. data/lib/mumuki/classroom/models/concerns/extensions.rb +90 -0
  18. data/lib/mumuki/classroom/models/concerns/with_failed_submission_reprocess.rb +74 -0
  19. data/lib/mumuki/classroom/models/concerns/with_submission_process.rb +148 -0
  20. data/lib/mumuki/classroom/models/document.rb +12 -0
  21. data/lib/mumuki/classroom/models/exercise.rb +7 -0
  22. data/lib/mumuki/classroom/models/failed_submission.rb +16 -0
  23. data/lib/mumuki/classroom/models/follower.rb +19 -0
  24. data/lib/mumuki/classroom/models/guide_progress.rb +57 -0
  25. data/lib/mumuki/classroom/models/last_assignment.rb +7 -0
  26. data/lib/mumuki/classroom/models/message.rb +32 -0
  27. data/lib/mumuki/classroom/models/notification.rb +52 -0
  28. data/lib/mumuki/classroom/models/reporting.rb +24 -0
  29. data/lib/mumuki/classroom/models/searching.rb +60 -0
  30. data/lib/mumuki/classroom/models/searching/guide_progress.rb +90 -0
  31. data/lib/mumuki/classroom/models/sorting.rb +92 -0
  32. data/lib/mumuki/classroom/models/sorting/guide_progress.rb +147 -0
  33. data/lib/mumuki/classroom/models/sorting/student.rb +45 -0
  34. data/lib/mumuki/classroom/models/sorting/total_stats_sort_by.rb +5 -0
  35. data/lib/mumuki/classroom/models/student.rb +88 -0
  36. data/lib/mumuki/classroom/models/submission.rb +62 -0
  37. data/lib/mumuki/classroom/models/suggestion.rb +35 -0
  38. data/lib/mumuki/classroom/models/teacher.rb +3 -0
  39. data/lib/mumuki/classroom/permissions_diff.rb +83 -0
  40. data/lib/mumuki/classroom/reports.rb +4 -0
  41. data/lib/mumuki/classroom/reports/formats.rb +35 -0
  42. data/lib/mumuki/classroom/sinatra.rb +301 -0
  43. data/lib/mumuki/classroom/sinatra/courses.rb +157 -0
  44. data/lib/mumuki/classroom/sinatra/errors.rb +13 -0
  45. data/lib/mumuki/classroom/sinatra/exams.rb +71 -0
  46. data/lib/mumuki/classroom/sinatra/followers.rb +29 -0
  47. data/lib/mumuki/classroom/sinatra/guides.rb +79 -0
  48. data/lib/mumuki/classroom/sinatra/manual_evaluation.rb +16 -0
  49. data/lib/mumuki/classroom/sinatra/massive.rb +206 -0
  50. data/lib/mumuki/classroom/sinatra/messages.rb +52 -0
  51. data/lib/mumuki/classroom/sinatra/notifications.rb +29 -0
  52. data/lib/mumuki/classroom/sinatra/organization.rb +7 -0
  53. data/lib/mumuki/classroom/sinatra/pagination.rb +13 -0
  54. data/lib/mumuki/classroom/sinatra/permissions.rb +9 -0
  55. data/lib/mumuki/classroom/sinatra/ping.rb +7 -0
  56. data/lib/mumuki/classroom/sinatra/searching.rb +27 -0
  57. data/lib/mumuki/classroom/sinatra/students.rb +111 -0
  58. data/lib/mumuki/classroom/sinatra/suggestions.rb +17 -0
  59. data/lib/mumuki/classroom/sinatra/teachers.rb +14 -0
  60. data/lib/mumuki/classroom/version.rb +5 -0
  61. data/lib/mumuki/profile.rb +17 -0
  62. data/lib/mumuki/views/threads.html.erb +43 -0
  63. data/lib/tasks/mumuki/messages.rake +20 -0
  64. data/lib/tasks/mumuki/resubmissions.rake +15 -0
  65. data/lib/tasks/mumuki/students.rake +31 -0
  66. data/lib/tasks/mumuki/submissions.rake +17 -0
  67. data/lib/tasks/mumuki/user_permissions.rake +17 -0
  68. metadata +291 -0
@@ -0,0 +1,62 @@
1
+ require 'net/http'
2
+
3
+ class Mumuki::Classroom::Submission < Mumuki::Classroom::Document
4
+
5
+ extend WithSubmissionProcess
6
+
7
+ field :sid, type: String
8
+ field :content
9
+ field :created_at, type: Time
10
+ field :expectation_results, type: Array
11
+ field :feedback, type: String
12
+ field :result, type: String
13
+ field :status, type: String
14
+ field :submissions_count, type: Integer
15
+ field :test_results, type: Array
16
+ field :comments, type: Array
17
+ field :manual_evaluation, type: String
18
+ field :origin_ip, type: String
19
+
20
+ embeds_many :messages, class_name: 'Mumuki::Classroom::Message'
21
+
22
+ embedded_in :assignment, class_name: 'Mumuki::Classroom::Assignment'
23
+ embedded_in :suggestion, class_name: 'Mumuki::Classroom::Suggestion'
24
+ embedded_in :last_assignment, class_name: 'Mumuki::Classroom::LastAssignment'
25
+
26
+ def evaluate_manually!(comment, status)
27
+ self.status = status
28
+ self.manual_evaluation = comment
29
+ end
30
+
31
+ def add_message!(message)
32
+ self.messages << Mumuki::Classroom::Message.new(message.as_json)
33
+ end
34
+
35
+ def expectation_results
36
+ self[:expectation_results]&.map do |expectation|
37
+ {html: Mulang::Expectation.parse(expectation).translate, result: expectation['result']} # TODO translate with keywords
38
+ end
39
+ end
40
+
41
+ def thread(language)
42
+ {
43
+ status: status,
44
+ content: Mumukit::ContentType::Markdown.to_html(Mumukit::ContentType::Markdown.highlighted_code language, content || ''),
45
+ messages: messages,
46
+ created_at: created_at
47
+ } if messages.present?
48
+ end
49
+
50
+ def manual_evaluation
51
+ Mumukit::ContentType::Markdown.to_html(self[:manual_evaluation]) if self[:manual_evaluation]
52
+ end
53
+
54
+ def with_full_messages(user)
55
+ self.tap do |submission|
56
+ submission[:messages] = messages.map do |message|
57
+ message.with_full_messages user
58
+ end
59
+ end
60
+ end
61
+
62
+ end
@@ -0,0 +1,35 @@
1
+ class Mumuki::Classroom::Suggestion < Mumuki::Classroom::Document
2
+ include Mongoid::Timestamps
3
+
4
+ field :sender, type: String
5
+ field :email, type: String
6
+ field :content, type: String
7
+ field :date, type: String
8
+ field :guide_slug, type: String
9
+ field :times_used, type: Integer
10
+
11
+ before_save :update_times_used
12
+
13
+ embeds_one :exercise
14
+ embeds_many :submissions, class_name: 'Mumuki::Classroom::Submission'
15
+
16
+ create_index({'guide_slug': 1, 'exercise.eid': 1})
17
+
18
+ def add_submission!(submission)
19
+ update_attributes! submissions: submissions + [submission]
20
+ end
21
+
22
+ def content_html
23
+ Mumukit::ContentType::Markdown.to_html content
24
+ end
25
+
26
+ def self.create_from(message, assignment)
27
+ create message.merge(guide_slug: assignment.guide['slug'], exercise: assignment.exercise)
28
+ end
29
+
30
+ private
31
+
32
+ def update_times_used
33
+ self.times_used = submissions.size
34
+ end
35
+ end
@@ -0,0 +1,3 @@
1
+ class Mumuki::Classroom::Teacher < Mumuki::Classroom::Document
2
+ include CourseMember
3
+ end
@@ -0,0 +1,83 @@
1
+ class Hash
2
+ def to_mumukit_permissions
3
+ Mumukit::Auth::Permissions.parse self
4
+ end
5
+ end
6
+
7
+ class Mumukit::Auth::Permissions
8
+ def to_mumukit_permissions
9
+ self
10
+ end
11
+
12
+ def grants_for(role)
13
+ scope_for(role).grants
14
+ end
15
+ end
16
+
17
+ class Mumukit::Auth::Permissions
18
+ class Change
19
+ attr_accessor :role, :grant, :type
20
+
21
+ def initialize(role, grant, change_type)
22
+ @role = role
23
+ @grant = grant
24
+ @type = change_type
25
+ end
26
+
27
+ def description
28
+ "#{role}_#{type}"
29
+ end
30
+
31
+ def organization
32
+ granted_slug.organization
33
+ end
34
+
35
+ def granted_slug
36
+ grant.to_mumukit_slug
37
+ end
38
+
39
+ def as_json(options = {})
40
+ {role: @role, grant: @grant, type: @type}.as_json options
41
+ end
42
+ end
43
+
44
+ class Diff
45
+ attr_accessor :changes
46
+
47
+ def initialize
48
+ @changes = []
49
+ end
50
+
51
+ def changes_by_organization
52
+ changes.group_by(&:organization).with_indifferent_access
53
+ end
54
+
55
+ def empty?
56
+ changes.empty?
57
+ end
58
+
59
+ def compare_grants!(role, some_permissions, another_permissions, change_type)
60
+ some_permissions
61
+ .grants_for(role)
62
+ .select { |grant| !another_permissions.role_allows?(role, grant) }
63
+ .each { |grant| changes << Change.new(role, grant, change_type) }
64
+ end
65
+
66
+ def self.diff(old_permissions, new_permissions)
67
+ return Mumukit::Auth::Permissions::Diff.new if new_permissions.nil?
68
+
69
+ old_permissions = old_permissions.to_mumukit_permissions
70
+ new_permissions = new_permissions.to_mumukit_permissions
71
+ new.tap do |it|
72
+ Mumukit::Auth::Roles::ROLES.each do |role|
73
+ it.compare_grants! role, old_permissions, new_permissions, :removed
74
+ it.compare_grants! role, new_permissions, old_permissions, :added
75
+ end
76
+ end
77
+ end
78
+
79
+ def as_json(options = {})
80
+ {changes: @changes}.as_json options
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,4 @@
1
+ module Mumuki::Classroom::Reports
2
+ end
3
+
4
+ require_relative './reports/formats'
@@ -0,0 +1,35 @@
1
+ class Mumuki::Classroom::Reports::Formats
2
+ module Json
3
+ def self.format_report(stats)
4
+ stats.to_json
5
+ end
6
+ end
7
+
8
+ module Csv
9
+ def self.format_report(stats)
10
+ stats.to_csv
11
+ end
12
+ end
13
+
14
+ module Table
15
+ def self.format_report(stats)
16
+ return '<no data>' if stats.empty?
17
+
18
+ header = stats.first.keys.join(' | ')
19
+ body = stats.map { |it| it.values.join(' | ') }.join("\n")
20
+ <<EOF
21
+ #{header}
22
+ #{header.size.times.map { '-' }.join}
23
+ #{body}
24
+ EOF
25
+ end
26
+ end
27
+
28
+ def self.format_for(key)
29
+ "Mumuki::Classroom::Reports::Formats::#{key.capitalize}".constantize
30
+ end
31
+
32
+ def self.format_report(key, stats)
33
+ format_for(key).format_report(stats)
34
+ end
35
+ end
@@ -0,0 +1,301 @@
1
+ require 'sinatra/base'
2
+ require 'sinatra/namespace'
3
+ require 'sinatra/cross_origin'
4
+
5
+ class Mumuki::Classroom::App < Sinatra::Application
6
+ configure do
7
+ enable :cross_origin
8
+ set :allow_methods, [:get, :put, :post, :options, :delete]
9
+ set :show_exceptions, false
10
+
11
+ set :app_name, 'classroom'
12
+ set :static, true
13
+ set :public_folder, 'public'
14
+
15
+ use ::Rack::CommonLogger, Rails.logger
16
+ end
17
+
18
+ helpers do
19
+ Mumukit::Login.configure_controller! self
20
+
21
+ def authenticate!
22
+ halt 401 unless current_user?
23
+ end
24
+
25
+ def json_body
26
+ @json_body ||= JSON.parse(request.body.read).with_indifferent_access rescue nil
27
+ end
28
+
29
+ def with_organization(hash = {})
30
+ {organization: organization}.merge hash
31
+ end
32
+
33
+ # FIXME only provisional
34
+ def with_current_organization(hash = {})
35
+ {organization: current_organization}.merge hash.except(:organization)
36
+ end
37
+
38
+ # FIXME only provisional
39
+ def with_current_organization_and_course(hash = {})
40
+ with_current_organization.merge(course: current_course).merge hash.except(:organization, :course)
41
+ end
42
+
43
+ def with_organization_and_course(hash = {})
44
+ with_organization.merge(course: course_slug).merge hash
45
+ end
46
+
47
+ def authorization_slug
48
+ slug
49
+ end
50
+
51
+ def slug
52
+ if route_slug_parts.present?
53
+ Mumukit::Auth::Slug.join(*route_slug_parts)
54
+ elsif json_body
55
+ Mumukit::Auth::Slug.parse(json_body['slug'])
56
+ else
57
+ raise Mumukit::Auth::InvalidSlugFormatError.new('Slug not available')
58
+ end
59
+ end
60
+
61
+ def permissions
62
+ current_user.permissions
63
+ end
64
+
65
+ def course
66
+ params[:course]
67
+ end
68
+
69
+ def uid
70
+ params[:uid]
71
+ end
72
+
73
+ def exercise_id
74
+ params[:exercise_id].to_i
75
+ end
76
+
77
+ def exercise_student_progress_query
78
+ {'guide.slug': repo_slug, 'student.uid': uid}
79
+ end
80
+
81
+ def tenant
82
+ Mumukit::Platform.organization_name(request)
83
+ end
84
+
85
+ def organization
86
+ tenant
87
+ end
88
+
89
+ def route_slug_parts
90
+ [tenant, course].compact
91
+ end
92
+
93
+ def course_slug
94
+ @course_slug ||= Mumukit::Auth::Slug.join_s(*route_slug_parts)
95
+ end
96
+
97
+ def repo_slug
98
+ @repo_slug ||= Mumukit::Auth::Slug.join_s(params[:organization], params[:repository])
99
+ end
100
+
101
+ def tenantized_json_body
102
+ json_body.merge(tenant: tenant)
103
+ end
104
+
105
+ def ensure_course_existence!
106
+ Course.locate! course_slug
107
+ end
108
+
109
+ def ensure_member_not_exists!(member_json, member_collection)
110
+ member_collection.ensure_not_exists! with_organization_and_course uid: member_json[:uid]
111
+ end
112
+
113
+ def collection_for(role)
114
+ "Mumuki::Classroom::#{role.to_s.titleize}".constantize
115
+ end
116
+
117
+ def create_course_member!(role)
118
+ member_collection = collection_for role
119
+
120
+ member_collection.normalized_attributes_from_json(json_body).tap do |member_json|
121
+ ensure_member_not_exists! member_json, member_collection
122
+ member = member_collection.create!(with_organization_and_course member_json)
123
+ upsert_user! role, member.as_user
124
+ end
125
+ end
126
+
127
+ def set_locale!
128
+ I18n.locale = current_organization.locale
129
+ end
130
+
131
+ def organization_json
132
+ @organization_json ||= current_organization.as_json
133
+ end
134
+
135
+ def current_organization
136
+ @current_organization ||= Organization.locate!(organization).switch!
137
+ end
138
+
139
+ def current_course
140
+ @current_course ||= Course.locate!(course_slug)
141
+ end
142
+
143
+ def update_user_permissions!(uid, method, *slugs)
144
+ user = User.locate!(uid)
145
+ user.send("#{method}_permission!", :student, *slugs)
146
+ user.save!
147
+ end
148
+
149
+ def page
150
+ (params[:page] || 1).to_i - 1
151
+ end
152
+
153
+ def per_page
154
+ (params[:per_page] || 30).to_i
155
+ end
156
+
157
+ def sort_by
158
+ params[:sort_by] || :name
159
+ end
160
+
161
+ def with_detached
162
+ params[:with_detached].boolean_value
163
+ end
164
+
165
+ def query
166
+ params[:q] || ''
167
+ end
168
+
169
+ def query_criteria
170
+ params[:query_criteria]
171
+ end
172
+
173
+ def query_operand
174
+ params[:query_operand]
175
+ end
176
+
177
+ def order_by
178
+ params[:order_by] || :asc
179
+ end
180
+
181
+ def csv_projection_for(projection)
182
+ projection.transform_values do |val|
183
+ next val if val == 0
184
+ {'$ifNull': [val, nil]}
185
+ end
186
+ end
187
+
188
+ def group_report_projection
189
+ {
190
+ '_id': 0,
191
+ 'last_name': '$last_name',
192
+ 'first_name': '$first_name',
193
+ 'email': '$email',
194
+ 'personal_id': '$personal_id',
195
+ 'detached': {'$eq': ['$detached', true]},
196
+ 'created_at': '$created_at',
197
+ 'last_submission_date': '$last_assignment.submission.created_at',
198
+ 'passed_count': '$stats.passed',
199
+ 'passed_with_warnings_count': '$stats.passed_with_warnings',
200
+ 'failed_count': '$stats.failed',
201
+ 'last_lesson_type': '$last_assignment.guide.parent.type',
202
+ 'last_lesson_name': '$last_assignment.guide.parent.name',
203
+ 'last_exercise_number': '$last_assignment.exercise.number',
204
+ 'last_exercise_name': '$last_assignment.exercise.name',
205
+ 'last_chapter': '$last_assignment.guide.parent.chapter.name',
206
+ }
207
+ end
208
+
209
+ def group_report(matcher, projection)
210
+ projection = csv_projection_for projection
211
+ aggregation = Mumuki::Classroom::Student.where(matcher).project(projection)
212
+ pipeline_with_sort_criterion = aggregation.pipeline << {'$sort': {passed_count: -1, passed_with_warnings_count: -1, failed_count: -1, last_name: 1, first_name: 1}}
213
+ json = Mumuki::Classroom::Student.collection.aggregate(pipeline_with_sort_criterion).as_json
214
+ content_type 'application/csv'
215
+ csv_with_headers(Mumuki::Classroom::Reports::Formats.format_report('csv', json), projection)
216
+ end
217
+ end
218
+
219
+
220
+ before do
221
+ content_type 'application/json', 'charset' => 'utf-8'
222
+ end
223
+
224
+ after do
225
+ error_message = env['sinatra.error']
226
+ if response.body.is_a?(Array) && response.body[0].is_a?(String)
227
+ if content_type != 'application/csv'
228
+ content_type 'text/html'
229
+ response.body[0] = <<HTML
230
+ <html>
231
+ <body>
232
+ #{response.body[0]}
233
+ </body>
234
+ </html>
235
+ HTML
236
+ end
237
+ response.body = response.body[0]
238
+ elsif error_message.blank?
239
+ response.body = response.body.to_json
240
+ else
241
+ begin
242
+ json = JSON.parse(error_message.message)
243
+ response.body = json.to_json
244
+ rescue
245
+ response.body = {message: error_message.message}.to_json
246
+ end
247
+ end
248
+ end
249
+
250
+ error JSON::ParserError do
251
+ halt 400
252
+ end
253
+
254
+ error ActiveRecord::RecordInvalid do
255
+ halt 400
256
+ end
257
+
258
+ error ActiveRecord::RecordNotFound do
259
+ halt 404
260
+ end
261
+
262
+ error Mumukit::Auth::InvalidTokenError do
263
+ halt 401
264
+ end
265
+
266
+ error Mumukit::Auth::UnauthorizedAccessError do
267
+ halt 403
268
+ end
269
+
270
+ error Mumukit::Auth::InvalidSlugFormatError do
271
+ halt 400
272
+ end
273
+
274
+ before do
275
+ set_locale! if current_organization
276
+ end
277
+
278
+ options '*' do
279
+ response.headers['Allow'] = settings.allow_methods.map { |it| it.to_s.upcase }.join(',')
280
+ response.headers['Access-Control-Allow-Headers'] = 'X-Mumuki-Auth-Token, X-Requested-With, X-HTTP-Method-Override, Content-Type, Cache-Control, Accept, Authorization'
281
+ 200
282
+ end
283
+ end
284
+
285
+ require_relative './sinatra/errors'
286
+ require_relative './sinatra/pagination'
287
+ require_relative './sinatra/courses'
288
+ require_relative './sinatra/guides'
289
+ require_relative './sinatra/messages'
290
+ require_relative './sinatra/exams'
291
+ require_relative './sinatra/followers'
292
+ require_relative './sinatra/organization'
293
+ require_relative './sinatra/ping'
294
+ require_relative './sinatra/teachers'
295
+ require_relative './sinatra/students'
296
+ require_relative './sinatra/permissions'
297
+ require_relative './sinatra/notifications'
298
+ require_relative './sinatra/suggestions'
299
+ require_relative './sinatra/manual_evaluation'
300
+ require_relative './sinatra/searching'
301
+ require_relative './sinatra/massive'