Code assement web application for the Mumuki Platform
Laboratory is a multitenant Rails webapp for solving exercises, organized in terms of chapters and guides.
First, we need to install some software: PostgreSQL database, RabbitMQ queue, and some common Ruby on Rails native dependencies
sudo apt-get install autoconf curl git build-essential libssl-dev autoconf bison libreadline6 libreadline6-dev zlib1g zlib1g-dev postgresql libpq-dev rabbitmq-server
rbenv is a ruby versions manager, similar to rvm, nvm, and so on.
curl -fsSL https://github.com/rbenv/rbenv-installer/raw/master/bin/rbenv-installer | bash
echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc # or .bash_profile
echo 'eval "$(rbenv init -)"' >> ~/.bashrc # or .bash_profile
Now we have rbenv installed, we can install ruby and bundler
rbenv install 2.6.3
rbenv global 2.6.3
rbenv rehash
gem install bundler
Because, err... we need to clone this repostory before developing it 😛
git clone https://github.com/mumuki/mumuki-laboratory
cd mumuki-laboratory
We need to create a PostgreSQL role - AKA a user - who will be used by Laboratory to create and access the database
# create db user for linux users
sudo -u postgres psql <<EOF
create role mumuki with createdb login password 'mumuki';
EOF
# create db user for mac users
psql postgres
#once inside postgres server
create role mumuki with createdb login password 'mumuki';
# create schema and initial development data
./devinit
If you want to start the server quickly in developer environment, you can just do the following:
./devstart
This will install your dependencies and boot the server.
If you just want to install dependencies, just do:
bundle install
You can boot the server by using the standard rackup command:
# using defaults from config/puma.rb and rackup default port 9292
bundle exec rackup
# changing port
bundle exec rackup -p 8080
# changing threads count
MUMUKI_LABORATORY_THREADS=30 bundle exec rackup
# changing workers count
MUMUKI_LABORATORY_WORKERS=4 bundle exec rackup
Or you can also start it with puma
command, which gives you more control:
# using defaults from config/puma.rb
bundle exec puma
# changing ports, workers and threads count, using puma-specific options:
bundle exec puma -w 4 -t 2:30 -p 8080
# changing ports, workers and threads count, using environment variables:
MUMUKI_LABORATORY_WORKERS=4 MUMUKI_LABORATORY_PORT=8080 MUMUKI_LABORATORY_THREADS=30 bundle exec puma
Finally, you can also start your server using rails
:
rails s
# Run all tests
bundle exec rake
# Run only web tests (i.e. Capybara and Teaspoon)
bundle exec rake spec:web
The Capybara config of this project supports running tests on Firefox, Chrome and Safari via Selenium. The webdrivers
gem automatically installs (and updates) all the necessary Selenium webdrivers.
By default, Capybara tests will run with the default dummy-driver (Rack test). If you want to run on a real browser, you should set MUMUKI_SELENIUM_DRIVER
variable to firefox
, chrome
or safari
. Also, a Rake task to run just the Capybara tests is available.
Some examples:
# Run web tests, using Firefox
MUMUKI_SELENIUM_DRIVER=firefox bundle exec rake spec:web
# Run Capybara tests on Chrome
MUMUKI_SELENIUM_DRIVER=chrome bundle exec rake spec:web:capybara
The webdrivers
gem also works with Teaspoon, no need to install anything manually. By default tests run on Firefox, but this behavior can be changed by setting MUMUKI_SELENIUM_DRIVER
(see section above).
bundle exec rake spec:web:teaspoon
yarn run lint
Sometimes you will need to check laboratory
against a local runner. Run the following code in you rails console
:
require 'mumuki/domain/seed'
# import a new language
Mumuki::Domain::Seed.languages_syncer.locate_and_import! Language, 'http://localhost:9292'
# update an existing language object
Mumuki::Domain::Seed.languages_syncer.import! Mumukit::Sync.key(Language, 'http://localhost:9292'), language
Likewise, you will sometimes require a guide that is not locally available. Run the following code in rails console
:
require 'mumuki/domain/seed'
# import a new guide
Mumuki::Domain::Seed.contents_syncer.locate_and_import! Guide, slug
# update an existing guide object
Mumuki::Domain::Seed.contents_syncer.import! Mumukit::Sync.key(Guide, slug), guide
After that you will probably to add it somewhere. The easiest way is to create a complement of central
:
o = Organization.central
o.book.complements << Guide.locate!(slug).as_complement_of(o.book)
o.reindex_usages!
Now you will be able to visit that guide at http://localhost:3000/central/guides/#{slug}
The development environment is configured to "send" emails via mailcatcher
, a mock server, if it is available. Run these commands to install and run it - and do it before the emails are sent, so it can actually catch them:
gem install mailcatcher
mailcatcher
Once up and running, go to http://localhost:1080/ to see which emails have been sent. Unfortunately, the developers recommend not to install it via Bundler, so it has to be done this way. 🤷♀️
In order to be customized by runners, Laboratory exposes the following selectors and methods which are granted to be safe and stable.
.mu-final-state
.mu-initial-state-header
.mu-initial-state
.mu-kids-blocks
.mu-kids-context
.mu-kids-exercise-description
.mu-kids-exercise
.mu-kids-reset-button
.mu-kids-results-aborted
.mu-kids-results
.mu-kids-state-image
.mu-kids-state
.mu-kids-states
.mu-kids-submit-button
.mu-multiple-scenarios
.mu-scenarios
.mu-submit-button
#mu-actual-state-text
#mu-${languageName}-custom-editor
#mu-custom-editor-default-value
#mu-custom-editor-extra
#mu-custom-editor-test
#mu-custom-editor-value
#mu-initial-state-text
.mu-kids-gbs-board-initial
: Use.mu-initial-state
instead.mu-state-final
: Use.mu-final-state
instead.mu-state-initial
: Use.mu-initial-state
instead#kids-results-aborted
: Use.mu-kids-results-aborted
instead#kids-results
: Use.mu-kids-results
instead
mumuki.bridge.Laboratory
.runTests
mumuki.CustomEditor
addSource
mumuki.editor
formatContent
reset
toggleFullscreen
mumuki.elipsis
replaceHtml
mumuki.kids
registerBlocksAreaScaler
registerStateScaler
restart
scaleBlocksArea
scaleState
showResult
showContext
mumuki.renderers
SpeechBubbleRenderer
renderSpeechBubbleResultItem
mumuki.locale
mumuki.exercise
id
: theid
of the currently loaded exercise, if anylayout
: thelayout
of the currently loaded exercise, if any
mumuki.incognitoUser
: whether the current user is an incognito usermumuki.MultipleScenarios
scenarios
currentScenarioIndex
resetIndicators
updateIndicators
mumuki.multipleFileEditor
setUpAddFile
setUpDeleteFiles
setUpDeleteFile
updateButtonsVisibility
mumuki.submission
processSolution
sendSolution
registerContentSyncer
mumuki.version
{
"status": "passed|passed_with_warnings|failed",
"guide_finished_by_solution": "boolean",
"html": "string",
"remaining_attempts_html": "string",
"current_exp": "integer",
"title_html": "string", // kids-only
"button_html": "string", // kids-only
"expectations": [ // kids-only
{
"status": "passed|failed",
"explanation": "string"
}
],
"tips": [ "string" ], // kids-only
"test_results": [ // kids-only
{
"title": "string",
"status": "passed|failed",
"result": "string",
"summary": "string"
}
]
}
- Laboratory Kids API Initialization
- Runner Editor JS
- Laboratory Kids Layout Initialization
- Runner Editor HTML
Laboartory provides the mumuki.events
object, which acts as minimal, generic event system, which is mostly designed for third party components built on top of laboratory and runners. It does nothing by default.
This API has two parts: consumers API and producers API.
// ======
// producer
// ======
// you need to call this method in order to enable registration of event handlers
// otherwise, it will be ignored
mumuki.events.enable('myEvent');
// fire the event, with an optional event object as payload
mumuki.events.fire('myEvent', aPlainOldObject);
// clear all the registered event handlers
mumuki.events.clear('myEvent');
// ========
// consumer
// ========
// register an event handler
mumuki.events.on('myEvent', (anEventObject) => {
// do stuff
});
Mumuki provides several editor types: code editors, multiple choice, file upload, and so on. However, some runners will require custom editors in order to provide better ways of entering solutions.
The process to do so is not difficult, but tricky, since there are a few hooks you need to implement. Let's look at them:
If you need to provide a custom editor, chances are that you also need to provide assets to augment the layout, e.g. providing ways to render some custom components on descriptions or corollaries. That code will be included first.
In order to do that, add to your runner the layout html, css and js code. Layout code has no further requirements. It can customize any public selector previously.
Although it is not required, it is recommended that your layout code works with any of the mumuki layouts:
input_right
input_bottom
input_primary
input_kindergarten
Then expose code in the MetadataHook
:
class ... < Mumukit::Hook
def metadata
{
layout_assets_urls: {
js: [
'assets/....'
],
css: [
'assets/....'
],
html: [
'assets/....'
]
}
}
end
end
Finally, it is recommended that you layout code calls mumuki.assetsLoadedFor('layout')
when fully loaded.
That's it!
The process for registering custom editors is more involving.
Add your js, css and html assets to your runner, and expose them in MetadataHook
:
class ... < Mumukit::Hook
def metadata
{
editor_assets_urls: {
js: [
'assets/....'
],
css: [
'assets/....'
],
html: [
'assets/....'
]
}
}
end
end
These assets will only be loaded when the editor custom
is used.
Using JavaScript, append your components the custom-editor root, which can be found using the following selectors:
mu-${languageName}-custom-editor
#mu-${languageName}-custom-editor
.mu-${languageName}-custom-editor
$('#mu-mylang-custom-editor').append(/* ... */)
If necessary, read the test definition from #mu-custom-editor-test
, and plump into your custom components
const test = $('#mu-custom-editor-test').val()
//...use test...
Before sending a submission, mumuki needs to be able to your read you editor components contents. There are two different approaches:
- Register a syncer that writes
#mu-custom-editor-value
or any other custom editor selectors - Add one or more content sources
// simplest method - you can register just one
mumuki.editors.registerContentSyncer(() => {
// ... write here your custom component content...
$('#mu-custom-editor-value').val(/* ... */);
});
// alternate method
// you can register many sources
mumuki.editors.addCustomSource({
getContent() {
return { name: "solution[content]", value: /* ... */ } ;
}
});
Your solution will be automatically sent to the client and processed when the submit button is pressed.
However, if you need to trigger the whole submission process programmatically,
call mumuki.submission.processSolution
:
mumuki.submission.processSolution({solution: {content: /* ... */}});
Your solution will be automatically sent to the client when the submit button is pressed, as part of the
solution processing. However, if you just need to send your submission to the server programmatically,
call mumuki.submission.sendSolution
:
mumuki.submission.sendSolution({solution: {content: /* ... */}});
You can alternatively override the default submit button UI and behaviour, by replacing it with a custom component. In order to
do that, override the .mu-submit-button
or the kids-specific .mu-kids-submit-button
:
$(".mu-submit-button").html(/* ... */);
However, doing this is tricky, since you will need to manually update the UI and connecting to the server. See:
mumuki.kids.showResult
mumuki.bridge.Laboratory.runTests
mumuki.updateProgressBarAndShowModal
Kids layouts have some special areas:
- state area: its display initial and/or final states of the exercise
- blocks area: a workspace that contains the building blocks of the solution - which are not necessary programming or blockly blocks, actually
If you want to support kids layouts, you need to register scalers that will be called when device is resized. Skip this step otherwise.
mumuki.kids.registerStateScaler(($state, fullMargin, preferredWidth, preferredHeight) => {
// ... resize your components ...
});
mumuki.kids.registerBlocksAreaScaler(($blocks) => {
// ... resize your components ...
});
In order to remove loading spinners, you will need to call mumuki.assetsLoadedFor
when your code is ready.
mumuki.assetsLoadedFor('editor');
In order to be able to link content, laboratory exposes slug-based routes that will redirect to the actual content URL in the current organization transparently:
GET <organization-url>/topics/<organization>/<repository>
GET <organization-url>/guides/<organization>/<repository>
GET <organization-url>/exercises/<organization>/<repository>/<bibliotheca-id>
Before using the API, you must create an ApiClient
using rails c
, which will generate a private JWT. Use it to authenticate API calls in any Platform application within a Authorizaion: Bearer <TOKEN>
.
Before using the API, take a look to the roles hierarchy:
Permissions are bound to a scope, that states in which context the operation can be performed. Scopes are simply two-level contexts, expressed as slugss <first>/<second>
, without any explicit semantic. They exact meaning depends on the role:
- ex_student:
organization/course
- student:
organization/course
- teacher and headmaster:
organization/course
- writer and editor:
organization/content
- janitor:
organization/_
- moderator and forum_supervisor:
organization/_
- admin:
_/_
- owner:
_/_
This is a generic user creation request.
Minimal permission: janitor
POST /users
Sample request body:
{
"first_name": "María",
"last_name": "Casas",
"email": "[email protected]",
"permissions": {
"student": "cpt/*:rte/*",
"teacher": "ppp/2016-2q"
}
}
This is a way of updating user basic data. Permissions are ignored.
Minimal permission: janitor
PUT /users/:uid
Sample request body:
{
"first_name": "María",
"last_name": "Casas",
"email": "[email protected]",
"uid": "[email protected]"
}
Creates the student if necessary, and updates her permissions.
Minimal permission: janitor
POST /courses/:organization/:course/students
{
"first_name": "María",
"last_name": "Casas",
"email": "[email protected]",
"uid": "[email protected]"
}
Response
{
"uid": "[email protected]",
"first_name": "María",
"last_name": "Casas",
"email": "[email protected]"
}
Forbidden Response
{
"status": 403,
"error": "Exception"
}
Remove student permissions from a course.
Minimal permission: janitor
POST /courses/:organization/:course/students/:uid/detach
Response: status code: 200
Not Found Response
{
"status": 404,
"error": "Couldn't find User"
}
Add student permissions to a course.
Minimal permission: janitor
POST /courses/:organization/:course/students/:uid/attach
Response: status code: 200
Not Found Response
{
"status": 404,
"error": "Couldn't find User"
}
Creates the teacher if necessary, and updates her permissions.
Minimal permission: headmaster
, janitor
POST /course/:id/teachers
{
"first_name": "Erica",
"last_name": "Gonzalez",
"email": "[email protected]",
"uid": "[email protected]"
}
Creates every user if necesssary, an updates permissions.
Minimal permission: janitor
POST /course/:id/batches
{
"students": [
{
"first_name": "Tupac",
"last_name": "Lincoln",
"email": "[email protected]",
"uid": "[email protected]"
}
],
"teachers": [
{
"first_name": "Erica",
"last_name": "Gonzalez",
"email": "[email protected]",
"uid": "[email protected]"
}
]
}
Minimal permission: janitor
DELETE /course/:id/students/:uid
Minimal permission: janitor
DELETE /course/:id/teachers/:uid
Minimal permission: admin
DELETE /users/:uid
Minimal permission: janitor
POST /organization/:id/courses/
{
"name": "....",
}
Minimal permission: janitor
DELETE /organization/:id/courses/:id
Minimal permission: admin
DELETE /courses/:id
{
"name": "academy",
"contact_email": "[email protected]",
"books": [
"MumukiProject/mumuki-libro-metaprogramacion"
],
"locale": "es-AR"
}
{
"public": false,
"description": "...",
"login_methods": [
"facebook", "twitter", "google"
],
"logo_url": "http://mumuki.io/logo-alt-large.png",
"terms_of_service": "Al usar Mumuki aceptás que las soluciones de tus ejercicios sean registradas para ser corregidas por tu/s docente/s...",
"theme_stylesheet": ".theme { color: red }",
"extension_javascript": "doSomething = function() { }"
}
- If you set
null
topublic
,login_methods
, the values will befalse
and `["user_pass"]. - If you set
null
todescription
, the value will benull
. - If you set
null
to the others, it will be inherited from an organization called"base"
every time you query the API.
{
"theme_stylesheet_url": "stylesheets/academy-asjdf92j1jd8.css",
"extension_javascript_url": "javascripts/academy-jd912j8jdj19.js"
}
get /organizations
Sample response body:
{
"organizations": [
{ "name": "academy", "contact_email": "[email protected]", "locale": "es-AR", "login_methods": ["facebook"], "books": ["libro"], "public": true, "logo_url": "http://..." },
{ "name": "alcal", "contact_email": "[email protected]", "locale": "en-US", "login_methods": ["facebook", "github"], "books": ["book"], "public": false }
]
}
Minimal permission: None for public organizations, janitor
for user's private organizations.
get /organizations/:name
Sample response body:
{ "name": "academy", "contact_email": "[email protected]", "locale": "es-AR", "login_methods": ["facebook"], "books": ["libro"], "public": true, "logo_url": "http://..." }
Minimal permission: janitor
of the organization.
post /organizations
... with at least the required fields.
Minimal permission: admin
of that organization
put /organizations/:name
... with a partial update.
Minimal permission: admin
of :name