Class ::xowf::test_item::Answer_manager (public)
::nx::Class ::xowf::test_item::Answer_manager
Defined in packages/xowf/tcl/test-item-procs.tcl
Public API: - create_workflow - delete_all_answer_data - allow_answering - get_answer_wf - get_wf_instances - get_answer_attributes - student_submissions_exist - runtime_panel - render_answers_with_edit_history - render_answers - marked_results - answers_panel - exam_results - grading_table - grading_scheme - grade - participants_table - get_duration - get_IPs - revisions_up_to - last_time_in_state - last_time_switched_to_state - state_periods - time_window_setup - waiting_room_message
- Testcases:
- No testcase defined.
Source code: #---------------------------------------------------------------------- # Class: Answer_manager # Method: waiting_room_message #---------------------------------------------------------------------- :public method waiting_room_message {obj:object} { # # Renders the waiting room message, including the JavaScript # reacting to actions from the backend. # set message [::xowiki::bootstrap::card -title #xowf.Waiting_Room# -body [subst { <p>[_ xowf.waiting_for_exam [list title "[$obj title]"]] <p><adp:icon name='clock'> <span id='waiting-msg'></span></p> #xowf.waiting_redirect# }]] set url [$obj pretty_link -query m=poll-open] template::add_body_script -script [subst -nocommands { (function poll() { setTimeout(function() { var xhttp = new XMLHttpRequest(); xhttp.open("GET", '$url', true); xhttp.onreadystatechange = function() { if (this.readyState == 4 && this.status == 200) { var data = JSON.parse(xhttp.response); console.log(data); console.log(data["action"]); console.log(data["msg"]); if (data["action"] == "msg") { var el = document.querySelector('#waiting-msg'); el.innerHTML = data["msg"]; poll(); } else if (data["action"] == "redirect") { window.location.href = data["url"]; } else { console.log("something else"); } } }; xhttp.send(); }, 1000); })(); }] return $message } #---------------------------------------------------------------------- # Class: Answer_manager # Method: create_workflow #---------------------------------------------------------------------- :public method create_workflow { {-answer_workflow /packages/xowf/lib/online-exam-answer.wf} {-master_workflow en:Workflow.form} parentObj:object } { # # Create a workflow based on the template provided in this # method for answering the question for the students. The name # of the workflow is derived from the workflow instance and # recorded in the formfield "wfName". # #:log "create_answer_workflow $parentObj" # first delete workflow and data, when it exists if {[$parentObj property wfName] ne ""} { set wf [:delete_all_answer_data $parentObj] if {$wf ne ""} {$wf delete} } # # Create a fresh workflow (e.g. instance of the online-exam, # inclass-quiz, ...). # set wfName [$parentObj name].wf $parentObj set_property -new 1 wfName $wfName set wfTitle [$parentObj property _title] set questionObjs [:QM question_objs $parentObj] set wfQuestionNames {} set wfQuestionTitles {} set attributeNames {} foreach form_obj $questionObjs { lappend attributeNames [:FL form_name_based_attribute_stem [$form_obj name]] lappend wfQuestionNames ../[$form_obj name] lappend wfQuestionTitles [$form_obj title] } set wfID [$parentObj item_id] set wfDef [subst -nocommands { set wfID $wfID set wfQuestionNames [list $wfQuestionNames] xowf::include $answer_workflow }] set attributeNames [join $attributeNames ,] #:log "create workflow by filling out form '$master_workflow'" set WF [::[$parentObj package_id] instantiate_forms -parent_id [$parentObj parent_id] -forms $master_workflow -default_lang [$parentObj lang]] set fc {} lappend fc "@table:_item_id,_state,$attributeNames,_last_modified" "@table_properties:view_field=_item_id" @cr_fields:hidden set wf [$WF create_form_page_instance -name $wfName -nls_language [$parentObj nls_language] -publish_status ready -parent_id [$parentObj item_id] -package_id [$parentObj package_id] -default_variables [list title $wfTitle] -instance_attributes [list workflow_definition $wfDef form_constraints $fc]] $wf save_new #ns_log notice "create_answer_workflow $wf DONE [$wf pretty_link] IA <[$wf instance_attributes]>" #ns_log notice "create_answer_workflow parent $parentObj IA <[$parentObj instance_attributes]>" set time_window [$parentObj property time_window] if {$time_window ne ""} { :time_window_setup $parentObj -time_window $time_window } } #---------------------------------------------------------------------- # Class: Answer_manager # Method: get_label_from_options #---------------------------------------------------------------------- :method get_label_from_options {value options} { foreach option $options { if {[lindex $option 1] eq $value} { return [lindex $option 0] } } return "" } #---------------------------------------------------------------------- # Class: Answer_manager # Method: recutil_create #---------------------------------------------------------------------- :public method recutil_create { -exam_id:integer {-fn "answers.rec"} -clear:switch } { # # Create recfile # # @see http://www.gnu.org/software/recutils/ # set export_dir $::acs::rootdir/log/exam-exports/$exam_id/ if {![file isdirectory $export_dir]} { file mkdir $export_dir } if {$clear && [file exists $export_dir$fn]} { file delete -- $export_dir$fn } # # If we have no recutils, create for the time being a stub # if {![nsf::is class ::xo::recutil]} { ns_log warning "no recutil class available" set r [::xotcl::Object new -proc ins args {;}] return $r } return [::xo::recutil new -file $export_dir$fn] } #---------------------------------------------------------------------- # Class: Answer_manager # Method: export_answer #---------------------------------------------------------------------- :public method export_answer { -combined_form_info -html:required -recutil:object,required -submission:object } { # # Export the provided question and answer in GNU rectuil format. # #ns_log notice "answers: [$submission serialize]" if {[$submission exists __form_fields]} { set form_fields [$submission set __form_fields] } else { # # We do not have the newest version of xowiki, so locate the # objs the hard way based on the naming convention. # set form_field_objs [lmap f [::xowiki::formfield::FormField info instances -closure] { if {![string match *_ [$f name]]} {continue} set f }] foreach form_field_obj $form_field_objs { dict set form_fields [$form_field_obj name] $form_field_obj } ns_log notice "export_answers: old style form_fields: $form_fields" } set export_dict "" set user [$submission set creation_user] if {![info exists ::__running_ids]} { set ::__running_ids "" } if {![dict exists $::__running_ids $user]} { dict set ::__running_ids $user [incr ::__running_id] } set seeds [$submission property seeds] set instance_attributes [$submission set instance_attributes] set answer_attributes [lmap a $instance_attributes { if {![string match *_ $a]} {continue} set a }] #ns_log notice "export_answers: combined_form_info: $combined_form_info" #set title_infos [dict get $combined_form_info title_infos] # # Get the question dict, which is a mapping between question # names and form_obj_ids. # set question_dict [:FL name_to_question_obj_dict [dict get $combined_form_info question_objs]] # ns_log notice "export_answers: question_dict: $question_dict" set form_constraints [lsort -unique [dict get $combined_form_info form_constraints]] set fc_dict [:fc_to_dict $form_constraints] #ns_log notice "... form_constraints ([llength $form_constraints]) $form_constraints" #ns_log notice ".... dict $fc_dict" # # Every answer_attribute contains the answer to a test_item # (which potentially sub answers). # foreach a $answer_attributes { #ns_log notice "answers <[dict get $instance_attributes $a]>" foreach {alternative_id answer} [dict get $instance_attributes $a] { set alt_value [lindex [split $alternative_id .] 1] set form_obj [dict get $question_dict $a] #set ff [dict get $form_fields $a] #ns_log notice "answer $a: [dict get $instance_attributes $a] [$ff serialize]" #ns_log notice "answer $a: form_obj [$form_obj serialize]" set form_obj_ia [$form_obj instance_attributes] #ns_log notice "answer $a: [dict get $instance_attributes $a] [dict keys [dict get $form_obj_ia question]]" #ns_log notice "INTERACTION [dict get [dict get $form_obj_ia question] question.interaction]" set intro [dict get [dict get [dict get $form_obj_ia question] question.interaction] question.interaction.text] #ns_log notice "TEXT $intro" #set question_title [question_manager question_property $form_obj title] #set question_minutes [question_manager question_property $form_obj minutes] #ns_log notice "answer $a: [dict get $instance_attributes $a] [dict keys [dict get $form_obj_ia question]]" #dict set export_dict name $a dict set export_dict name $alternative_id dict set export_dict user_id $user dict set export_dict running_id [dict get $::__running_ids $user] dict set export_dict question_obj $form_obj dict set export_dict question_title [$form_obj title] dict set export_dict question_intro [ns_striphtml $intro] dict set export_dict question_minutes [dict get $fc_dict $a test_item_minutes] dict set export_dict question_points [dict get $fc_dict $a test_item_points] dict set export_dict question_text [ns_striphtml [:get_label_from_options $alt_value [dict get $fc_dict $a options]]] #dict set export_dict options [dict get $fc_dict $a options] dict set export_dict answer $answer ns_log notice "answer $a: DICT $export_dict" #ns_log notice "avail $a: [dict get $fc_dict $a]" $recutil ins $export_dict } } } #---------------------------------------------------------------------- # Class: Answer_manager # Method: time_window_setup #---------------------------------------------------------------------- :public method time_window_setup {parentObj:object {-time_window:required}} { # # Check the provided time_window values, adjust it if necessary, # and make sure, according atjobs are provided. # # This method was made public, since there configuration window # update in inclass-exam.wf requires this for the update via # update_attribute_from_slot. Probably, we should move the core # of this function to this file, and make it protected again. # set dtstart [dict get $time_window time_window.dtstart] set dtend [dict get $time_window time_window.dtend] # # Delete previously scheduled atjobs. This needs to happen in # any case, because people may have removed the time window # altogether. # ns_log notice "#### deleting old scheduled atjob" :delete_scheduled_atjobs $parentObj if {$dtstart ne ""} { set total_minutes [question_manager total_minutes_for_exam -manager $parentObj] ns_log notice "#### create_workflows: atjobs for time_window <$time_window> total-mins $total_minutes" set start_clock [clock scan $dtstart -format %Y-%m-%dT%H:%M] if {$dtend eq ""} { # # No end given. Set it to start + exam time + 5 minutes. # The value of "total_minutes" might contain fractions of a # minute, so make sure that the end_clock is an integer as # needed by "clock format", set end_clock [expr {int($start_clock + ($total_minutes + 5) * 60)}] set new_dtend [clock format $end_clock -format %H:%M] ns_log notice "#### no dtend given. set it from $dtend to $new_dtend" } else { set end_date [clock format $start_clock -format %Y-%m-%d]T$dtend set end_clock [clock scan $end_date -format %Y-%m-%dT%H:%M] if {($end_clock - $start_clock) < ($total_minutes * 60)} { # # The specified end time is too early. Set it to start + # exam time + 5 minutes. # set end_clock [expr {int($start_clock + ($total_minutes + 5)*60)}] set new_dtend [clock format $end_clock -format %H:%M] ns_log notice "#### dtend is too early. Move it from $dtend to $new_dtend" } else { set new_dtend $dtend } } if {$new_dtend ne $dtend} { ns_log notice "#### create_workflows: must change dtend from <$dtend> to <$new_dtend>" set ia [$parentObj instance_attributes] dict set time_window time_window.dtend $new_dtend dict set ia time_window $time_window #ns_log notice "SAVE updated ia <${:instance_attributes}>" $parentObj update_attribute_from_slot [$parentObj find_slot instance_attributes] $ia } # # Schedule new atjobs # ns_log notice "#### scheduling atjobs" $parentObj schedule_action -time [clock format $start_clock -format "%Y-%m-%d %H:%M:%S"] -action publish $parentObj schedule_action -time [clock format $end_clock -format "%Y-%m-%d %H:%M:%S"] -action unpublish } } #---------------------------------------------------------------------- # Class: Answer_manager # Method: delete_all_answer_data #---------------------------------------------------------------------- :public method delete_all_answer_data {obj:object} { # # Delete all instances of the answer workflow # set wf [:get_answer_wf $obj] if {$wf ne ""} { set items [:get_wf_instances -initialize false $wf] foreach i [$items children] { $i www-delete } } # # Delete as well the manual gradings for this exam. # #$obj set_property -new 1 manual_gradings {} :AM set_exam_results -obj $obj manual_gradings {} return $wf } #---------------------------------------------------------------------- # Class: Answer_manager # Method: delete_scheduled_atjobs #---------------------------------------------------------------------- :public method delete_scheduled_atjobs {obj:object} { # # Delete previously scheduled atjobs # #ns_log notice "#### delete_scheduled_atjobs" set item_id [$obj item_id] set atjob_form_id [::xowf::atjob form_id -parent_id $item_id -package_id [ad_conn package_id]] set to_delete [xo::dc list get_children { select item_id from xowiki_form_instance_item_index where parent_id = :item_id and page_template = :atjob_form_id }] foreach id $to_delete { ns_log notice "#### acs::dc call content_item proc delete -item_id $id" acs::dc call content_item delete -item_id $id } } #---------------------------------------------------------------------- # Class: Answer_manager # Method: get_answer_wf #---------------------------------------------------------------------- :public method get_answer_wf {obj:object} { # # return the workflow denoted by the property wfName in obj # return [::[$obj package_id] instantiate_forms -parent_id [$obj item_id] -default_lang [$obj lang] -forms [$obj property wfName]] } #---------------------------------------------------------------------- # Class: Answer_manager # Method: allow_answering #---------------------------------------------------------------------- :public method allow_answering {-examwf:object -ip:required} { # # Tell if specified IP address is allowed to answer the exam. # # @return boolean # set iprange [$examwf property iprange] if {$iprange ne ""} { set iprangeObj ::xowf::iprange::$iprange if {$iprange ne "all" && (![nsf::is object $iprangeObj] || ![$iprangeObj allow_access $ip] )} { ns_log notice "ANSWER: [list $iprangeObj allow_access $ip] ->" [$iprangeObj allow_access $ip] return 0 } } return 1 } #---------------------------------------------------------------------- # Class: Answer_manager # Method: get_wf_instances #---------------------------------------------------------------------- :public method get_wf_instances { {-initialize false} {-orderby ""} -creation_user:integer -item_id:integer -state wf:object } { # get_wf_instances: return the workflow instances :assert_assessment_container $wf set extra_where_clause "" foreach var {creation_user item_id state} { if {[info exists $var]} { append extra_where_clause "AND $var = [ns_dbquotevalue [set $var]] " } } return [::xowiki::FormPage get_form_entries -base_item_ids [$wf item_id] -form_fields "" -always_queried_attributes "*" -initialize $initialize -orderby $orderby -extra_where_clause $extra_where_clause -publish_status all -package_id [$wf package_id]] } #---------------------------------------------------------------------- # Class: Answer_manager # Method: get_answer_attributes #---------------------------------------------------------------------- :public method get_answer_attributes {{-state ""} {-extra_attributes {}} wf:object} { # # Extracts wf instances as answers (e.g., extracting their # answer-specific attributes) # # @param wf the workflow # @param state retrieve only instances in this state # @param extra_attributes return these attributes additionally # as key/value pairs per tuple # # @return a list of dicts # set results {} set items [:get_wf_instances $wf] foreach i [$items children] { if {$state ne "" && [$i state] ne $state} { continue } set answerAttributes [:FL answer_attributes [$i instance_attributes]] foreach extra $extra_attributes { lappend answerAttributes $extra [$i property $extra] } #ns_log notice "get_answer_attributes $i: <$answerAttributes> ALL [$i instance_attributes]" lappend results [list item $i answerAttributes $answerAttributes state [$i state]] } return $results } #---------------------------------------------------------------------- # Class: Answer_manager # Method: get_duration #---------------------------------------------------------------------- :public method get_duration {{-exam_published_time ""} revision_sets} { # # Get the duration from a set of revisions and return a dict # containing "from", "fromClock","to", "toClock", "seconds", and # "duration". # set first [lindex $revision_sets 0] set last [lindex $revision_sets end] set fromClock [clock scan [::xo::db::tcl_date [ns_set get $first creation_date] tz]] set toClock [clock scan [::xo::db::tcl_date [ns_set get $last last_modified] tz]] dict set r fromClock $fromClock dict set r toClock $toClock dict set r from [clock format $fromClock -format "%H:%M:%S"] dict set r to [clock format $toClock -format "%H:%M:%S"] set timeDiff [expr {$toClock - $fromClock}] dict set r duration "[expr {$timeDiff/60}]m [expr {$timeDiff%60}]s" dict set r seconds $timeDiff if {$exam_published_time ne ""} { set examPublishedClock [clock scan [::xo::db::tcl_date $exam_published_time tz]] dict set r examPublishedClock $examPublishedClock dict set r examPublished [clock format $examPublishedClock -format "%H:%M:%S"] set epTimeDiff [expr {$toClock - $examPublishedClock}] dict set r examPublishedDuration "[expr {$epTimeDiff/60}]m [expr {$epTimeDiff%60}]s" #ns_log notice "EP examPublishedDuration [dict get $r examPublishedDuration]" "EP [dict get $r examPublished] $exam_published_time" dict set r examPublishedSeconds $epTimeDiff } return $r } #---------------------------------------------------------------------- # Class: Answer_manager # Method: get_IPs #---------------------------------------------------------------------- :public method get_IPs {revision_sets} { # # Get the IP addresses for the given revision set. Should be # actually only one. The revision_set must not be empty. # set IPs "" foreach revision_set $revision_sets { set ip [ns_set get $revision_set creation_ip] if {$ip ne ""} { dict set IPs [ns_set get $revision_set creation_ip] 1 } } return [dict keys $IPs] } #---------------------------------------------------------------------- # Class: Answer_manager # Method: revisions_up_to #---------------------------------------------------------------------- :public method revisions_up_to {revision_sets revision_id} { # # Return the revisions of the provided revision set up the # provided revision_id. If this revision_id does not exist, # return the full set. # set result "" set stop 0 return [lmap s $revision_sets { if {$stop} break set stop [expr {[ns_set get $s revision_id] eq $revision_id}] set s }] } #---------------------------------------------------------------------- # Class: Answer_manager # Method: last_time_in_state #---------------------------------------------------------------------- :public method last_time_in_state {revision_sets -state:required} { # # Loops through revision sets and retrieves the latest date # where state is equal the specified value. # # @param revision_sets a list of ns_sets containing revision # data. List is assumed to be sorted in descending # creation_date order (as retrieved by get_revision_sets) # # @return a date # set result "" foreach ps $revision_sets { if {$state eq [ns_set get $ps state]} { set result [ns_set get $ps last_modified] } } return $result } #---------------------------------------------------------------------- # Class: Answer_manager # Method: last_time_switched_to_state #---------------------------------------------------------------------- :public method last_time_switched_to_state {revision_sets -state:required {-before ""}} { # # Loops through revision sets and retrieves the latest date # where state is equal the specified value. # # @param revision_sets a list of ns_sets containing revision # data. List is assumed to be sorted in descending # creation_date order (as retrieved by get_revision_sets) # # @return a date # set result "" set last_state "" foreach ps $revision_sets { if {$before ne ""} { set currentClock [clock scan [::xo::db::tcl_date [ns_set get $ps last_modified] tz]] if {$currentClock > $before} { break } } if {$last_state ne $state && $state eq [ns_set get $ps state]} { set result [ns_set get $ps last_modified] } set last_state [ns_set get $ps state] } return $result } #---------------------------------------------------------------------- # Class: Answer_manager # Method: pretty_period #---------------------------------------------------------------------- :method pretty_period {{-dayfmt %q} {-timefmt %H:%M} from to} { set from_day [lc_time_fmt $from $dayfmt] set from_time [lc_time_fmt $from $timefmt] if {$to ne ""} { set to_day [lc_time_fmt $to $dayfmt] set to_time [lc_time_fmt $to $timefmt] } else { set to_day "" set to_time "" } if {$to_day eq ""} { set period "$from_day, $from_time -" } elseif {$from_day eq $to_day} { set period "$from_day, $from_time - $to_time" } else { set period "$from_day, $from_time - $to_day, $to_time" } return $period } #---------------------------------------------------------------------- # Class: Answer_manager # Method: state_periods #---------------------------------------------------------------------- :public method state_periods {revision_sets -state:required} { # # Return for the provided revision_sets the time ranges the # workflow was in the provided state. # set periods "" set from "" set last_from "" set until "" foreach ps $revision_sets { set current_state [ns_set get $ps state] if {$state eq $current_state} { if {$until ne ""} { lappend periods [:pretty_period $last_from $until] } set from [ns_set get $ps creation_date] set until "" } elseif {$until eq "" && $current_state ne $state && $from ne ""} { set until [ns_set get $ps last_modified] set last_from $from set from "" } } if {$until ne ""} { lappend periods [:pretty_period $last_from $until] } elseif {$from ne ""} { lappend periods [:pretty_period $from ""] } #ns_log notice "state_periods $state <$from> <$last_from> <$until> <$periods>" return $periods } #---------------------------------------------------------------------- # Class: Answer_manager # Method: achieved_points #---------------------------------------------------------------------- :method achieved_points { {-manual_grading ""} -submission:object -exam_question_dict -answer_attributes:required } { # # Calculate the achieved_points dict for an exam submission. This # function iterates of every question and sums up the achievable # and achieved points of the questions. The per-question results # are placed in the dict entry "details". # # This method has to be called after the instance was rendered, # since it uses the produced form_fields. # # @return dict containing "achievedPoints", "achievablePoints" and "details" # set all_form_fields [::xowiki::formfield::FormField info instances -closure] set question_dict $exam_question_dict if {[$submission property question] ne ""} { # # When the submission has a property "question" set then we # have a submission from a pool question. In this case, we # want - at least for the time being - the id of the pool # question and not the id of the replacement # question. Therefore, we have to create a dict for the # mapping of these values and to create a question_dict # (mapping from question_names to ids) updated with the id of # the pool question. # set question_ids_exam [lmap {k v} $exam_question_dict {set v}] set form_objs_submission [:QM load_question_objs $submission [$submission property question]] set question_ids_submission [lmap form_obj $form_objs_submission {$form_obj item_id}] #ns_log notice "=== achieved_points IDs examwf <$question_ids_exam>" #ns_log notice "=== achieved_points IDs submission <$question_ids_submission>" set map "" foreach id_exam $question_ids_exam id_submission $question_ids_submission { if {$id_exam != $id_submission} { #ns_log notice "=== achieved_points must use $id_exam instead of $id_submission" dict set map $id_submission $id_exam } } set question_dict [:FL name_to_question_obj_dict $form_objs_submission] foreach {k v} $question_dict { if {[dict exists $map $v]} { dict set question_dict $k [dict get $map $v] } } } #ns_log notice "=== achieved_points question_dict <$question_dict>" set totalPoints 0 set achievableTotalPoints 0 set details {} foreach a [dict keys $answer_attributes] { set f [$submission lookup_form_field -name $a $all_form_fields] set points {} if {![$f exists test_item_points]} { ns_log warning "question $f [$f name] [$f info precedence] HAS NO POINTS" $f set test_item_points 0 } set achievablePoints [$f set test_item_points] set achievableTotalPoints [expr {$achievableTotalPoints + $achievablePoints}] if {[$f exists correction_data]} { set auto_correct_achieved [:dict_value [$f set correction_data] points] } else { set auto_correct_achieved "" } #ns_log notice "=== achieved_points <$a> auto_correct_achieved $auto_correct_achieved" # # Manual grading has higher priority than autograding. # set achieved [:dict_value [:dict_value $manual_grading $a] achieved] if {$achieved eq ""} { set achieved $auto_correct_achieved } if {$achieved ne ""} { set totalPoints [expr {$totalPoints + $achieved}] } else { ns_log warning "$a: no points via automated or manual grading," "ignoring question in achieved points calculation" } lappend details [dict create attributeName $a question_id [:dict_value $question_dict $a] achieved $achieved auto_correct_achieved $auto_correct_achieved achievable $achievablePoints] } #ns_log notice "final details <$details>" return [list achievedPoints $totalPoints details $details achievablePoints $achievableTotalPoints] } #---------------------------------------------------------------------- # Class: Answer_manager # Method: grading_dialog_setup #---------------------------------------------------------------------- :public method grading_dialog_setup {examWf} { # # Define the modal dialog and everything necessary for reusing # this dialog for multiple occasions. This method registers the # pop-up and dismiss handlers for JavaScript and returns the # HTML markup of the modal dialog. # # @return HTML block for the modal dialog # set url [$examWf pretty_link -query m=grade-single-item] # jquery-ui is just needed for draggable() ::template::add_body_script -src urn:ad:js:jquery-ui ::template::add_body_script -script [subst -novariables { function thumbnail_files_setup(element) { // to be called on the elements of class ".thumbnail-file" element.querySelectorAll('.thumbnail-file-text a.delete') .forEach(el => el.addEventListener('click', event => { // Get the "href" of the a.delete element // and call this actions in the background var href = event.currentTarget.getAttribute('href'); if (!href) { console.log(".thumbnail-file does not have a proper delete link"); return } var fileIcon = event.currentTarget.parentElement.parentElement; var request = new XMLHttpRequest(); request.open('GET', href, true); request.onload = function() { if (this.status >= 200 && this.status < 400) { // Success! fileIcon.parentNode.removeChild(fileIcon); } else { console.log('AJAX request returned bad return code: ' + this.status); } }; request.send(); event.preventDefault(); })); }; $(document).ready(function(){ document.querySelectorAll('.thumbnail-file').forEach(el => thumbnail_files_setup(el)); $('.modal-dialog').draggable(); $('.modal .confirm').on('click', function(ev) { // // Popdown: "submit" button of grading dialog was pressed. // var id = ev.currentTarget.dataset.id; var gradingBox = document.getElementById(id); var pointsInput = document.querySelector('#grading-points'); var helpBlock = document.querySelector('#grading-points-help-block'); var comment = document.querySelector('#grading-comment').value; var points = pointsInput.value; var pointsFormGroup = pointsInput.parentElement.parentElement; var percentage = ""; let hiddenCSSclass = '[::template::CSS class d-none]'; if (points != "") { // // Number validation // if (parseFloat(points) > parseFloat(pointsInput.max) || parseFloat(points) < parseFloat(pointsInput.min)){ if (parseFloat(points) > parseFloat(pointsInput.max)) { helpBlock.textContent = '[_ xowf.Value_max] ' + pointsInput.max; } else { helpBlock.textContent = '[_ xowf.Value_min] ' + pointsInput.min; } pointsFormGroup.classList.add('has-error'); helpBlock.classList.remove(hiddenCSSclass); ev.preventDefault(); return false; } else { pointsFormGroup.classList.remove('has-error'); helpBlock.classList.add(hiddenCSSclass); } var achievable = gradingBox.dataset.achievable; if (achievable != "") { percentage = "(" + (points*100.0/achievable).toFixed(2) + "%)"; } } else { pointsFormGroup.classList.remove('has-error'); helpBlock.classList.add(hiddenCSSclass); } document.querySelector('#' + id + ' .points').textContent = points; document.querySelector('#' + id + ' .percentage').textContent = percentage; document.querySelector('#' + id + ' .comment').textContent = comment; gradingBox.dataset.achieved = points; gradingBox.dataset.comment = comment; if (comment == "") { document.querySelector('#' + id + ' .feedback-label').classList.add(hiddenCSSclass); } else { document.querySelector('#' + id + ' .feedback-label').classList.remove(hiddenCSSclass); } // Copy the content of the thumbnail files wrapper from the dialog // to the main document and register the event handler. let thumbnailFilesWrapper = document.querySelector('#' + id + ' .thumbnail-files-wrapper'); if (!thumbnailFilesWrapper) { thumbnailFilesWrapper = document.createElement('div'); thumbnailFilesWrapper.className = 'thumbnail-files-wrapper'; document.querySelector('#' + id).appendChild(thumbnailFilesWrapper); } thumbnailFilesWrapper.innerHTML = document.querySelector('#thumbnail-files-wrapper').innerHTML; //document.querySelector('#' + id + ' .thumbnail-files-wrapper').innerHTML = // document.querySelector('#thumbnail-files-wrapper').innerHTML; gradingBox.querySelectorAll('.thumbnail-file').forEach(el => thumbnail_files_setup(el)); var user_id = gradingBox.dataset.user_id; var examGradingBox = document.getElementById('runtime-panel-' + user_id); var data = new FormData(); data.append('question_name', gradingBox.dataset.question_name); data.append('user_id', user_id); data.append('achieved', points); data.append('comment', comment); data.append('grading_scheme', examGradingBox.dataset.grading_scheme); data.append('achieved_points', examGradingBox.dataset.achieved_points); var xhttp = new XMLHttpRequest(); xhttp.open('POST', '[set url]', true); xhttp.onload = function () { if (this.readyState == 4) { if (this.status == 200) { var text = this.responseText; var span = document.querySelector('#runtime-panel-' + user_id + ' .achieved-points'); span.textContent = text; } else { console.log('sent NOT ok'); } } }; xhttp.send(data); return true; }); $('.modal-dialog form').keypress(function(e){ if(e.keyCode == 13) { e.preventDefault(); return false; } }); $('#grading-modal').on('shown.bs.modal', function (ev) { // // Popup of grading dialog. // Copy values from data attributes to input fields. // var gradingBox = ev.relatedTarget.parentElement; document.getElementById('grading-question-title').textContent = gradingBox.dataset.title; document.getElementById('grading-participant').textContent = gradingBox.dataset.full_name; var pointsInput = document.getElementById('grading-points'); pointsInput.value = gradingBox.dataset.achieved; pointsInput.max = gradingBox.dataset.achievable; document.getElementById('grading-comment').value = gradingBox.dataset.comment; //document.getElementById('drop-zone').dataset.link = gradingBox.dataset.link; var filesUpload = document.getElementById('js-upload-files'); filesUpload.dataset.file_name_prefix = gradingBox.dataset.question_name; filesUpload.dataset.url = gradingBox.dataset.link; filesUpload.dataset.disposition = "FileIconified"; //console.log("... URL " + filesUpload.dataset.url); var feedBackFiles = gradingBox.getElementsByClassName("thumbnail-files-wrapper")\[0\]; // // For legacy composite items, there is no "thumbnail-files-wrapper" // // console.log(feedBackFiles); document.getElementById('thumbnail-files-wrapper').innerHTML = (feedBackFiles ? feedBackFiles.innerHTML : ""); document.querySelectorAll('#grading-modal .thumbnail-file').forEach(el => thumbnail_files_setup(el)); // Tell confirm button to which grading box it belongs var confirmButton = document.querySelector('#grading-modal-confirm'); confirmButton.dataset.id = gradingBox.id; }); }); }] set uploader_link [::[$examWf package_id] make_link $examWf file-upload] set dropZone [::xowiki::BootstrapNavbarDropzone new -href $uploader_link -label #xowf.Feedback_files_dnd# -text "Text for SUBMIT label" -file_name_prefix "" -disposition File] set dropZoneHTML [$dropZone asHTML] #ns_log notice "dropZoneHTML=$dropZoneHTML" return [::xowiki::bootstrap::modal_dialog -id grading-modal -title "#xowf.Grading#: <span id='grading-participant'></span>" -subtitle "#xowf.question#: <span id='grading-question-title'></span>" -body [subst [ns_trim -delimiter | { |<form class="form-horizontal" role="form" action='#' method="post"> | <div class="form-group"> | <label for="grading-points" class="control-label col-sm-2">#xowf.Points#:</label> | <div class="col-sm-9"> | <input class="form-control" id="grading-points" placeholder="#xowf.Points#" | type="number" step="0.1" min="0"> | <span id="grading-points-help-block" class="help-block hidden"></span> | </div> | </div> | <div class="form-group"> | <label for="grading-comment" class="control-label col-sm-2">#xowf.feedback#:</label> | <div class="col-sm-9"> | <textarea lines="2" class="form-control" id="grading-comment" | placeholder="..."></textarea> | </div> | </div> |</form> |<div class="control-label">#xowf.Feedback_files#:</div> |<div id="thumbnail-files-wrapper"></div> |<ul class="dropZone">$dropZoneHTML</ul> }]] ] } #---------------------------------------------------------------------- # Class: Answer_manager # Method: runtime_panel #---------------------------------------------------------------------- :public method runtime_panel { {-revision_id ""} {-view default} {-grading_info ""} answerObj:object } { # # Return statistics for the provided object in the form of HTML: # - minimal statistics: when view default # - statistics with clickable revisions: when view = revision_overview # - per-revision statistics: when view = revision_overview and revision_id is provided # # @return HTML block # set revision_sets [$answerObj get_revision_sets] set parent_revision_sets [[$answerObj parent_id] get_revision_sets] set item_id [$answerObj item_id] set live_revision_id [xo::dc get_value -prepare integer live_revision_id { select live_revision from cr_items where item_id = :item_id }] set current_question [expr {[dict get [$answerObj instance_attributes] position] + 1}] set page_info "#xowf.question#: $current_question" if {$view eq "default"} { set url [ad_return_url]&id=$item_id set revisionDetails "#xowf.nr_changes#: <a href='[ns_quotehtml $url]'>[llength $revision_sets]</a><br>" } elseif {$view eq "student"} { set revisionDetails "" } elseif {$view eq "revision_overview"} { set displayed_revision_info "" set live_revision_info "" set make_live_info "" set baseUrl [ns_conn url] set filtered_revision_sets [:revisions_up_to $revision_sets $revision_id] set c 0 foreach s $revision_sets { set rid [ns_set get $s revision_id] incr c if {$rid == $live_revision_id} { set liveCSSclass "live" set live_revision_info "#xowf.Live_revision#: $c" } else { set liveCSSclass "other" } set revision_url $baseUrl?[::xo::update_query [ns_conn query] rid $rid] if {$rid == [$answerObj revision_id]} { set suffix "*" set displayed_revision_info "#xowf.Displayed_revision#: $c" if {$rid ne $live_revision_id} { set query [::xo::update_query [ns_conn query] m make-live-revision] set query [::xo::update_query $query revision_id $rid] set query [::xo::update_query $query local_return_url [ad_return_url]] set live_revision_link $baseUrl?$query set make_live_info [subst { <a class="button" href="[ns_quotehtml $live_revision_link]">#xowf.Make_live_revision#</a> }] lappend revision_list "<span class='current'>$c</span>" } else { lappend revision_list "<span class='$liveCSSclass'>$c</span>" } } else { lappend revision_list [subst { <a class="$liveCSSclass" title="#xowf.Goto_this_revision#" href="[ns_quotehtml $revision_url]">$c</a> }] } } set revision_sets $filtered_revision_sets set revisionDetails [subst {#xowiki.revisions#: [join $revision_list {, }] <div class="revision-details right">$displayed_revision_info<br>$live_revision_info<br> $make_live_info </div> <br> }] } if {$revision_id eq ""} { set revision_sets [:revisions_up_to $revision_sets $live_revision_id] } set toClock [clock scan [::xo::db::tcl_date [ns_set get [lindex $revision_sets end] last_modified] tz]] set last_published [:last_time_switched_to_state $parent_revision_sets -state published -before $toClock] #ns_log notice "LAST PUBLISHED $last_published" set duration [:get_duration -exam_published_time $last_published $revision_sets] set state [$answerObj state] if {$state eq "done"} { set submission_info "#xowf.submitted#" } else { set submission_info "#xowf.not_submitted# ($page_info)" } if {[dict exists $duration examPublished]} { set publishedInfo "#xowf.Exam_published#: <span class='data'>[dict get $duration examPublished]</span><br>" set extraDurationInfo " - #xowf.since_published#: [dict get $duration examPublishedDuration]" } else { set publishedInfo "" set extraDurationInfo "" } if {$view eq "student"} { set IPinfo "" set statusInfo "" set extraDurationInfo "" set publishedInfo "" } else { set IPinfo [subst {IP: <span class="data">[:get_IPs $revision_sets]</span>}] set statusInfo "#xowf.Status#: <span class='data'>$submission_info</span><br>" } if {$grading_info ne ""} { set achievedPointsInfo [subst { #xowf.Achieved_points#: <span class='data achieved-points'>$grading_info</span><br> }] } else { set achievedPointsInfo "" } set HTML [subst { $publishedInfo $revisionDetails $statusInfo #xowf.Duration#: <span class="data">[dict get $duration from] - [dict get $duration to] ([dict get $duration duration]$extraDurationInfo)</span><br> $achievedPointsInfo $IPinfo }] return $HTML } #---------------------------------------------------------------------- # Class: Answer_manager # Method: render_submission=edit_history #---------------------------------------------------------------------- :method render_submission=edit_history { {-submission:object} {-examWf:object} {-nameToQuestionObj} } { set last_answers {} set rev_nr 1 set q_nr 0 set qnames "" set report "" set student_href [$examWf pretty_link -query m=print-answers&id=[$submission set item_id]] set revision_sets [$submission get_revision_sets -with_instance_attributes] foreach s $revision_sets { set msgs {} set ia [ns_set get $s instance_attributes] foreach key [dict keys $ia *_] { if {![dict exists $qnames $key]} { dict set qnames $key [incr q_nr] } set value [dict get $ia $key] # # Determine the question type # set form_obj [dict get $nameToQuestionObj $key] set template_obj [$form_obj page_template] if {[$template_obj name] eq "en:edit-interaction.wf"} { set item_type [dict get [$form_obj instance_attributes] item_type] } else { switch [$template_obj name] { en:TestItemShortText.form {set item_type ShortText} en:TestItemText.form {set item_type Text} default {set item_type unknown} } } #ns_log notice "Template name = [$template_obj name] -> item_type '$item_type'" # # For the time being, compute the differences just for short text questions # if {$item_type in {ShortText}} { foreach answer_key [dict keys $value] { set answer_value [string trim [dict get $value $answer_key]] set what "" set last_value [:dict_value $last_answers $answer_key ""] if {$last_value ne ""} { if {$answer_value eq ""} { set what cleared ns_log notice " ==> $answer_key: answer_value '$last_value' cleared in revision $rev_nr" } elseif {$answer_value ne $last_value} { set what updated } } else { # last answer was empty if {$answer_value ne ""} { set what added } } # # Remember last answer values # dict set last_answers $answer_key $answer_value if {$what ne ""} { if {$what eq "cleared"} { set answer_value $last_value } lappend msgs [subst { <span class='alert-[dict get {cleared warning added success updated info "" ""} $what]'> q[string map [list answer "" {*}$qnames] $answer_key] $what [ns_quotehtml '$answer_value'] </span> }] } } } else { # # Show the full content of the field # if {$value ne ""} { lappend msgs [subst { <span class=''>q[string map [list answer "" {*}$qnames] $key]: [ns_quotehtml '$value']</span> }] } } } append report [subst { <a href='[ns_quotehtml $student_href&rid=[ns_set get $s revision_id]]'>[format %02d $rev_nr]</a>: [join $msgs {; }]<br> }] incr rev_nr } append HTML [subst { <tr> <td><a href='[ns_quotehtml $student_href]'>[$submission set online-exam-userName]</td> <td>[$submission set online-exam-fullName]</td> <td>$report</td> </tr> }] return $HTML } #---------------------------------------------------------------------- # Class: Answer_manager # Method: render_submissions=edit_history #---------------------------------------------------------------------- :method render_submissions=edit_history { {-examWf:object} {-submissions:object} } { set combined_form_info [:QM combined_question_form $examWf] set nameToQuestionObj [:FL name_to_question_obj_dict [dict get $combined_form_info question_objs]] # # Sort items by username # $submissions orderby online-exam-userName return [subst { <h2>Quick Submission Analysis</h2> <table class='table table-condensed'> <tr><th></th><th>Name</th><th>Revisions</th></tr> [join [lmap submission [$submissions children] { :render_submission=edit_history -submission $submission -examWf $examWf -nameToQuestionObj $nameToQuestionObj}]] </table> }] } #---------------------------------------------------------------------- # Class: Answer_manager # Method: render_answers_with_edit_history #---------------------------------------------------------------------- :public method render_answers_with_edit_history { examWf:object } { # # Analyze the student submissions an find situations, where input # is "cleared" between revisions and return the HTML rendering. # # TODO: we should resolve this, move the exam protocol rendering # (www-print-answers) also into the answer manager and make it # configurable to provide this as an alternate item renderer. # The current result is provided for all submission,s, but in # general, this could be as well made available per question or # per-student. # # @return HTML # set wf [:get_answer_wf $examWf] if {$wf eq ""} { return "" } set submissions [:submissions -wf $wf] set HTML [:render_submissions=edit_history -examWf $examWf -submissions $submissions] return $HTML } ######################################################################## :method render_proctor_images { {-submission:object} {-revisions} {-examWf:object} {-revision_id} } { # # Render proctor images the provided submission. # # @return HTML # set user_id [$submission creation_user] set img_url [$examWf pretty_link -query m=proctor-image&user_id=$user_id] set proctoring_dir [proctoring::folder -object_id [$examWf item_id] -user_id $user_id] set files [glob -nocomplain -directory $proctoring_dir *.*] #ns_log notice "proctoring_dir $proctoring_dir files $files" if {$revision_id ne ""} { set filtered_revisions [:revisions_up_to $revisions $revision_id] } else { set filtered_revisions $revisions } set start_date [ns_set get [lindex $filtered_revisions 0] creation_date] set end_date [ns_set get [lindex $filtered_revisions end] last_modified] set start_clock [clock scan [::xo::db::tcl_date $start_date tz_var]] set end_clock [clock scan [::xo::db::tcl_date $end_date tz_var]] set image "" #ns_log notice "start date $start_date end_date $end_date / $start_clock $end_clock" foreach f $files { #ns_log notice "check: $f" if {[regexp {/([^/]+)-(\d+|\d+[.]\d+)[.](webm|png|jpeg)$} $f . type stamp ext]} { set inWindow [expr {$stamp >= $start_clock && $stamp <= $end_clock}] ns_log notice "parsed $type $stamp $ext $inWindow $stamp " [clock format $stamp -format {%m-%d %H:%M:%S}] >= $start_clock ([expr {$stamp >= $start_clock}]) && $stamp <= $end_clock ([expr {$stamp <= $end_clock}]) if {$inWindow} { dict set image $stamp $type $ext } } } set markup "" foreach ts [lsort -integer [dict keys $image]] { #ns_log notice "ts $ts [dict get $image $ts]" append markup [subst {<div>[clock format $ts -format {%Y-%m-%d %H:%M:%S}]</div>}] append markup {<div style="display: flex">} foreach type {camera-image desktop-image} { if {[dict exists $image $ts $type]} { set ext [dict get $image $ts $type] append markup [subst {<img height="240" src="$img_url&type=$type&ts=$ts&e=$ext">}] } } if {[dict exists $image $ts camera-audio]} { set ext [dict get $image $ts camera-audio] append markup [subst {<audio controls src="$img_url&type=camera-audio&ts=$ts&e=$ext" type="video/webm"></audio>}] } append markup </div>\n } return $markup } #---------------------------------------------------------------------- # Class: Answer_manager # Method: student_submissions_exist #---------------------------------------------------------------------- :public method student_submissions_exist {wf:object} { # # Returns 1 if there are student submissions. The method returns # already true, when a student has started to work on this exam. # # This method could be optimized if necessary via caching the # wf_instances or a more specific database query. # set items [:get_wf_instances $wf] foreach i [$items children] { if {[$i property try_out_mode] ne "1"} { #ns_log notice "==================== student_submissions_exist 1" return 1 } } #ns_log notice "==================== student_submissions_exist 0" return 0 } #---------------------------------------------------------------------- # Class: Answer_manager # Method: submissions #---------------------------------------------------------------------- :method submissions { {-creation_user:integer,0..1 ""} {-filter_submission_id:integer,0..1 ""} {-revision_id:integer,0..1 ""} {-wf:object} } { # # Return an ordered composite built form all student submission, # potentially filtered via the provided values. # if {$revision_id ne ""} { # # In case we have a revision_id, return this single # revision. # set r [::xowiki::FormPage get_instance_from_db -revision_id $revision_id] set submissions [::xo::OrderedComposite new -destroy_on_cleanup] $submissions add $r } else { set submissions [:get_wf_instances {*}[expr {$creation_user ne "" ? "-creation_user $creation_user" : ""}] {*}[expr {$filter_submission_id ne "" ? "-item_id $filter_submission_id" : ""}] $wf] } # # Provide additional attributes to the instances such as the # userName and fullName. # foreach submission [$submissions children] { set submission_item_id [$submission set item_id] set feedbackFiles [xo::dc list_of_lists . { select item_id, name from cr_items where parent_id = :submission_item_id }] #ns_log notice "item_id $submission_item_id : children <$feedbackFiles>" $submission set online-exam-userName [acs_user::get_element -user_id [$submission creation_user] -element username] $submission set online-exam-fullName [::xo::get_user_name [$submission creation_user]] $submission set online-exam-feedbackFiles $feedbackFiles } return $submissions } #---------------------------------------------------------------------- # Class: Answer_manager # Method: render_print_button #---------------------------------------------------------------------- :method render_print_button {} { # # Render a simple print button for the unaware that makes it # easy to print the exam protocol to PDF and use e.g. a pdf-tool # to annotate free text answers. The function is designed to # work with streaming HTML output. # # @return HTML rendering # template::add_event_listener -id print-button -event click -preventdefault=false -script "window.print();" return [ns_trim -delimiter | [subst { |<adp:button class="btn btn-default" id="print-button"> |[::xowiki::bootstrap::icon -name print] print |</adp:button> |[template::collect_body_scripts] }]] } #---------------------------------------------------------------------- # Class: Answer_manager # Method: render_filter_bar #---------------------------------------------------------------------- :method render_filter_bar { {-examWf:object} {-filter_form_ids:integer,0..n ""} {-revision_id:integer,0..1 ""} {-filter_submission_id:integer,0..1 ""} {-orderby:token "online-exam-userName"} } { # # Render a bar to filter, sort and export exam submissions. # # @return HTML # template::add_event_listener -id search-question -event submit -preventdefault=true -script "handleSearch();" template::add_event_listener -id search-content -event change -preventdefault=true -script "handleSearch();" template::add_event_listener -id search-not-graded -event change -preventdefault=true -script "filterNotGraded();" template::head::add_javascript -order 100 -src "/resources/xowf/inclass-exam.js" set HTML [subst { <form id="search-question"> Filter: <input class="form-control" style="display:inline;width:70%;" type="text" id="search-question-string" name="search" placeholder="#xowf.Insert_Filter_keywords#"> <input type="checkbox" id="search-content"> #xowf.Search_in_content# <input type="checkbox" id="search-not-graded"> #xowf.Search_not_graded# </form> [template::collect_body_scripts] }] set sort_baseurl [$examWf pretty_link]?m=print-answers&fos=$filter_form_ids&rid=$revision_id&id=$filter_submission_id append HTML [subst { <div class='dimensional dimensional-list'> <ul class='list-unstyled'> <li> <span>#xowf.Order_by#: </span> <span><a href='[ns_quotehtml $sort_baseurl&orderby=online-exam-userName]' class=' btn-sm [expr {$orderby eq "online-exam-userName" ? "btn btn-primary" : "btn btn-default"}]'>#xowf.Student_Username#</a></span> <span><a href='[ns_quotehtml $sort_baseurl&orderby=online-exam-fullName]' class='btn-sm [expr {$orderby eq "online-exam-fullName" ? "btn btn-primary" : "btn btn-default"}]'>#xowiki.name#</a></span> </li> </ul> </div> }] append HTML [:export_links -examWf $examWf -filter_form_ids $filter_form_ids -b_aggregate true] return $HTML } #---------------------------------------------------------------------- # Class: Answer_manager # Method: render_full_submission_form #---------------------------------------------------------------------- :method render_full_submission_form { -wf:object -submission:object -filter_form_ids:integer,0..n -with_feedback:switch -with_correction_notes:switch } { # # Compute the HTML of the full submission of the user with all # form fields instantiated according to randomization. # # @param filter_form_ids used for filtering questions # @return HTML of question form object containing all (wanted) questions # # # Flush all form fields, since their contents depend on # randomization. In later versions, we should introduce a more # intelligent caching respecting randomization. # foreach f [::xowiki::formfield::FormField info instances -closure] { #ns_log notice "FF could DESTROY $f [$f name]" if {[string match *_ [$f name]]} { #ns_log notice "FF DESTROY $f [$f name]" $f destroy } } $wf form_field_flush_cache # # The call to "render_content" calls actually the # "summary_form" of online/inclass-exam-answer.wf when the submit # instance is in state "done". We set the __feedback_mode to # get the auto-correction included. # xo::cc eval_as_user -user_id [$submission creation_user] { $submission set __feedback_mode 2 $submission set __form_objs $filter_form_ids $submission set __aggregated_form_options "-with_feedback=$with_feedback -with_correction_notes=$with_correction_notes" set question_form [$submission render_content] } return $question_form } #---------------------------------------------------------------------- # Class: Answer_manager # Method: get_non_empty_file_formfields #---------------------------------------------------------------------- :method get_non_empty_file_formfields { {-submission:object} } { if {[$submission exists __form_fields]} { set objs [lmap {name obj} [$submission set __form_fields] {set obj}] # # Filter out the form-fields, which have a nonempty # revision_id. # return [::xowiki::formfield::child_components -filter {[$_ hasclass "::xowiki::formfield::file"] && [dict exists [$_ value] revision_id] && [dict get [$_ value] revision_id] ne ""} $objs] } else { return "" } } #---------------------------------------------------------------------- # Class: Answer_manager # Method: pretty_formfield_name #---------------------------------------------------------------------- :method pretty_formfield_name {f_obj} { regsub {_[.]answer([0-9]+)} [$f_obj name] {-\1} exercise_name #ns_log notice "PRETTY '[$f_obj name]' -> '$exercise_name'" return $exercise_name } #---------------------------------------------------------------------- # Class: Answer_manager # Method: export_file_submission #---------------------------------------------------------------------- :method export_file_submission { {-submission:object} {-zipFile:object} {-check_for_file_submission_exists:boolean false} } { # # Get all nonempty file form-fields and add these to a zip # file. The filename is composed of the user, the exercise and # the provided file-name. # foreach f_obj [:get_non_empty_file_formfields -submission $submission] { set exercise_name [:pretty_formfield_name $f_obj] foreach file_revision_id [dict get [$f_obj value] revision_id] { set file_object [::xo::db::CrClass get_instance_from_db -revision_id $file_revision_id] set download_file_name "" append download_file_name [$submission set online-exam-userName] "-" $exercise_name "-" [$file_object title] $zipFile addFile [$file_object full_file_name] [$zipFile cget -name]/[ad_sanitize_filename $download_file_name] } } } #---------------------------------------------------------------------- # Class: Answer_manager # Method: dom ensemble for tDOM manipluations #---------------------------------------------------------------------- :method "dom node replace" {domNode xquery script} { set node [$domNode selectNodes $xquery] if {$node ne ""} { foreach child [$node childNodes] { $child delete } :uplevel [list $node appendFromScript $script] } } :method "dom node replaceXML" {domNode xquery XML} { set node [$domNode selectNodes $xquery] if {$node ne ""} { foreach child [$node childNodes] { $child delete } # # There is in tDOM only an appendXML and no appendHTML. If the # replace-text is an <img>" XML-parse fails since there is no # ending tag. So, we use the following heuristic. Note that # this does not happen in full installations, where icon sets # are available, but it might show up in a native regression # test with minimal packages. # if {[string match "<img*" $XML]} { append XML </img> } :uplevel [list $node appendXML $XML] } } :method "dom node appendXML" {domNode xquery XML} { set node [$domNode selectNodes $xquery] :uplevel [list $node appendXML $XML] } :method "dom node delete" {domNode xquery} { set nodes [$domNode selectNodes $xquery] foreach node $nodes { $node delete } } :method "dom class add" {domNode xquery class} { set nodes [$domNode selectNodes $xquery] foreach node $nodes { set oldClass [$node getAttribute class] if {$class ni $oldClass} { $node setAttribute class "$oldClass $class" } } } :method "dom class remove" {domNode xquery class} { set nodes [$domNode selectNodes $xquery] foreach node $nodes { set oldClass [$node getAttribute class] set pos [lsearch $oldClass $class] if {$pos != -1} { $node setAttribute class [lreplace $oldClass $pos $pos] } } } #---------------------------------------------------------------------- # Class: Answer_manager # Method: postprocess_question_html #---------------------------------------------------------------------- :method postprocess_question_html { {-question_form:required} {-achieved_points:required} {-manual_grading:required} {-submission:object,required} {-runtime_panel_view:required} {-exam_state:required} {-feedbackFiles ""} } { # # Post-process the HTML of a question by adding information of # the student as data attributes, such as achieved and # achievable points, setting CSS classes, mangling names of # composite questions to match with the data in achieved_points, # # @return HTML block #ns_log notice "QF=$question_form" dom parse -html -- $question_form doc $doc documentElement root if {$root eq ""} { error "form '$form' is not valid" } set per_question_points "" foreach pd [:dict_value $achieved_points details] { set qn [dict get $pd attributeName] dict set per_question_points $qn achieved [dict get $pd achieved] dict set per_question_points $qn achievable [dict get $pd achievable] } # # The aggregated form (method :aggregated_form) is generated # before submissions are available. For e.g., the exam protocol, # the same grading-box with the same raw data will be reused # potentially per submission. To ensure uniqueness of the HTML # IDs for the dialogs, we have to fill in the submission IDs. # set grading_boxes [$root selectNodes {//div[contains(@class,'grading-box')]}] foreach grading_box $grading_boxes { $grading_box setAttribute id [$grading_box getAttribute id]-[$submission item_id] } # # For every composite question: # # - update the question_name of the subquestion by prefixing it # with the name of the composite, since this is what we have # in the details of achieved_points. # - hide the grading box of the composite # - unhide the grading box of the composite children # set composite_grading_boxes [$root selectNodes {//div[@data-item_type='Composite']/div[contains(@class,'grading-box')]}] foreach composite_grading_box $composite_grading_boxes { set composite_qn [$composite_grading_box getAttribute "data-question_name"] set parentNode [$composite_grading_box parentNode] :dom class add $composite_grading_box {.} [::template::CSS class d-none] foreach grading_box [$parentNode selectNodes {div//div[contains(@class,'grading-box')]}] { set qn [$grading_box getAttribute data-question_name] regsub {^answer_} $qn ${composite_qn}_ new_qn #ns_log notice "CHILD of Composite: rename QN from $qn to $new_qn" $grading_box setAttribute data-question_name $new_qn $grading_box setAttribute id ${composite_qn}_[$grading_box getAttribute id] :dom class remove $grading_box {.} [::template::CSS class d-none] # # The composite questions are prerendered and do not have # hint boxes, since we do not want to have even hidden in # the HTML rendering show to the student during the # exam. Therefore, we add these now for the exam protocol in # an extra step. We try to add here both, feedback and # correction notes (if available). The loop over all grading # boxes below should care for the visibility of the hint # boxes due to percentages. # if {[$grading_box hasAttribute data-question_id]} { set subquestion_id [$grading_box getAttribute data-question_id] set subquestion_obj [::xowiki::FormPage get_instance_from_db -item_id $subquestion_id] #ns_log notice "CHILD of Composite has form_id $subquestion_id [nsf::is object ::$subquestion_id]" set HTML [:QM hint_boxes -question_obj $subquestion_obj -with_feedback=1 -with_correction_notes=1] if {$HTML ne ""} { dom parse -simple -html <body>$HTML</body> hintsDoc $hintsDoc documentElement hintsBody foreach child $hintsBody { [$grading_box parentNode] appendChild $child } } } else { # # Probably some legacy item # ::util_user_message -message "Composite Exercise looks like legacy data; please edit+save" ad_log warning "composite_grading_box has no data-question_id" } } } # # Composite grading-boxes are done, now general code over all # grading-boxes. # set submission_state [$submission state] #set noManualGrading [expr {$submission_state ne "done" || $exam_state eq "published"}] set noManualGrading [expr {$exam_state eq "published"}] set grading_boxes [$root selectNodes {//div[contains(@class,'grading-box')]}] foreach grading_box $grading_boxes { set qn [$grading_box getAttribute "data-question_name"] set item_node [$grading_box parentNode] set item_type [expr {[$item_node hasAttribute "data-item_type"] ? [$item_node getAttribute "data-item_type"] : ""}] set feedbackFilesHTML [:render_feedback_files -question_name $qn -feedbackFiles $feedbackFiles] #ns_log notice "FEEDBACK '$qn' feedbackFiles $feedbackFiles HTML\n$feedbackFilesHTML" #ns_log notice "... QN '$qn' item_type '$item_type'" "submission state $submission_state" "exam state $exam_state noManualGrading $noManualGrading" if {$noManualGrading} { :dom class add $grading_box {a[contains(@class,'manual-grade')]} [::template::CSS class d-none] } # # Get manual gradings, if these were already provided. # if {[dict exists $manual_grading $qn achieved]} { set achieved [dict get $manual_grading $qn achieved] } else { set achieved "" } if {[dict exists $manual_grading $qn comment]} { set comment [dict get $manual_grading $qn comment] } else { set comment "" } if {[dict exists $per_question_points $qn achieved]} { # # Manual grading has higher priority than automated grading. # if {$achieved eq ""} { set achieved [dict get $per_question_points $qn achieved] } set achievable [dict get $per_question_points $qn achievable] $grading_box setAttribute data-autograde 1 } else { set achievable "" } #ns_log notice "... QN '$qn' item_type $item_type achieved '$achieved' achievable '$achievable'" set percentage "" if {$achieved eq ""} { set warning [::template::icon -class [template::CSS class text-warning] -name warn ] set pencil [::template::icon -name pencil] :dom node replaceXML $grading_box {span[@class='points']} [dict get $warning HTML] :dom node replaceXML $grading_box {a[@class='manual-grade-edit']/span/..} [dict get $pencil HTML] # # The last case with "span/.." is for legacy cases, where # composite items were generated before bootstrap5 support # and/or where composite items were generated under # bootstrap5 but are rendered with bootstrap3 # :dom node replaceXML $grading_box {a[@class='manual-grade']/span/..} [dict get $pencil HTML] } else { :dom node replace $grading_box {span[@class='points']} {::html::t $achieved} if {$achievable ne ""} { set percentage [format %.2f [expr {$achieved*100.0/$achievable}]] :dom node replace $grading_box {span[@class='percentage']} {::html::t ($percentage%)} } } # # handling of legacy items # set changes [expr {[::template::CSS toolkit] eq "bootstrap" ? {bs-toggle toggle bs-target target} : {toggle bs-toggle target bs-target}}] foreach node [$grading_box selectNodes {a[@class='manual-grade']}] { foreach {old new} $changes { if {[$node hasAttribute data-$old]} { $node setAttribute data-$new [$node getAttribute data-$old] $node removeAttribute data-$old } } } if {$feedbackFilesHTML ne ""} { #ns_log notice "REPLACE thumbnail-files-wrapper in\n[$grading_box asXML]" if {[llength [$grading_box selectNodes {div[@class='thumbnail-files-wrapper']}]] == 0} { # # Must be a legacy composite item without the thumbnail # wrapper. # $grading_box appendXML {<div class="thumbnail-files-wrapper"></div>} } :dom node replaceXML $grading_box {div[@class='thumbnail-files-wrapper']} $feedbackFilesHTML } # # When "comment" is empty, do not show the label. # :dom node replace $grading_box {span[@class='comment']} {::html::t $comment} if {$comment eq ""} { :dom class add $grading_box {span[@class='feedback-label']} [::template::CSS class d-none] } else { :dom class remove $grading_box {span[@class='feedback-label']} [::template::CSS class d-none] } $grading_box setAttribute data-user_id [$submission creation_user] $grading_box setAttribute data-user_name [$submission set online-exam-userName] $grading_box setAttribute data-full_name [$submission set online-exam-fullName] $grading_box setAttribute data-achieved $achieved $grading_box setAttribute data-achievable $achievable $grading_box setAttribute data-comment $comment $grading_box setAttribute data-link [::[$submission package_id] make_link $submission file-upload] # # Feedback handling (should be merged with the individual feedback) # set correct_feedback_node [$item_node selectNodes {div[contains(@class,'feedback-correct')]}] set incorrect_feedback_node [$item_node selectNodes {div[contains(@class,'feedback-incorrect')]}] set correction_notes_node [$item_node selectNodes {div[contains(@class,'correction-notes')]}] if {$percentage ne "" && $percentage < 50 && $incorrect_feedback_node ne ""} { # # Remove positive and keep negative feedback. # if {$correct_feedback_node ne ""} { $correct_feedback_node delete set correct_feedback_node "" } } if {$correct_feedback_node ne "" && $incorrect_feedback_node ne ""} { # # If we still have a positive feedback, remove negative # feedback. # $incorrect_feedback_node delete } # # In student review mode ('Einsicht'), remove # - correction notes, and # - edit controls. # if {$runtime_panel_view eq "student"} { if {$correction_notes_node ne ""} { $correction_notes_node delete } :dom node delete $grading_box {a} } } return [$root asHTML] } #---------------------------------------------------------------------- # Class: Answer_manager # Method: render_submission=exam_protocol #---------------------------------------------------------------------- :method render_submission=exam_protocol { {-autograde:boolean false} {-combined_form_info} {-examWf:object} {-exam_question_dict} {-filter_submission_id:integer,0..1 ""} {-filter_form_ids:integer,0..n ""} {-grading_scheme:object} {-recutil:object,0..1 ""} {-zipFile:object,0..1 ""} {-revision_id:integer,0..1 ""} {-submission:object} {-totalPoints:double} {-runtime_panel_view default} {-wf:object} {-with_signature:boolean false} {-with_exam_heading:boolean true} } { set userName [$submission set online-exam-userName] set fullName [$submission set online-exam-fullName] set user_id [$submission set creation_user] set manual_gradings [:get_exam_results -obj $examWf manual_gradings] set results "" #if {[$submission state] ne "done"} { # ns_log notice "online-exam: submission of $userName is not finished (state [$submission state])" # return "" #} set revisions [$submission get_revision_sets] if {[llength $revisions] == 1 } { # # We have always an initial revision. This revision might be # already updated via autosave, in which case we show the # content. # set rev [lindex $revisions 0] set unmodified [string equal [ns_set get $rev last_modified] [ns_set get $rev creation_date]] if {$unmodified} { ns_log notice "online-exam: submission of $userName is empty. Ignoring." return "" } } # # We have to distinguish between the answered attributes (based # on the instance attributes in the database) and the answer # attributes, which should be rendered. The latter one might be # a subset, especially in cases, where filtering (e.g., show # only one question of all candidates) happens. # set exam_question_objs [dict values $exam_question_dict] set answeredAnswerAttributes [:FL answer_attributes [$submission instance_attributes]] set formAnswerAttributeNames [dict keys [:FL name_to_question_obj_dict $exam_question_objs]] set usedAnswerAttributes {} foreach {k v} $answeredAnswerAttributes { if {$k in $formAnswerAttributeNames} { dict set usedAnswerAttributes $k $v } } #ns_log notice "filter_form_ids <$filter_form_ids>" #ns_log notice "question_objs <[dict get $combined_form_info question_objs]>" #ns_log notice "answeredAnswerAttributes <$answeredAnswerAttributes>" #ns_log notice "formAnswerAttributeNames <$formAnswerAttributeNames> [:FL name_to_question_obj_dict $filter_form_ids]" #ns_log notice "usedAnswerAttributes <$usedAnswerAttributes>" # # "render_full_submission_form" calls "summary_form" to obtain the # user's answers to all questions. # set question_form [:render_full_submission_form -wf $wf -submission $submission -filter_form_ids $filter_form_ids -with_correction_notes=[expr {$runtime_panel_view ne "student"}] -with_feedback ] if {$recutil ne ""} { :export_answer -submission $submission -html $question_form -combined_form_info $combined_form_info -recutil $recutil } if {$zipFile ne ""} { :export_file_submission -submission $submission -zipFile $zipFile } # # Achieved_points are computed for autograded and manually # graded exams. # set achieved_points [:achieved_points -manual_grading [:dict_value $manual_gradings $user_id] -submission $submission -exam_question_dict $exam_question_dict -answer_attributes $usedAnswerAttributes] dict set achieved_points totalPoints $totalPoints #ns_log notice "user $user_id: achieved_points [dict get $achieved_points details]" #ns_log notice "user $user_id: manual_gradings [:dict_value $manual_gradings $user_id]" foreach pd [:dict_value $achieved_points details] { set qn [dict get $pd attributeName] dict set results $qn achieved [dict get $pd achieved] dict set results $qn achievable [dict get $pd achievable] dict set results $qn question_id [dict get $pd question_id] } set question_form [:postprocess_question_html -question_form $question_form -achieved_points $achieved_points -manual_grading [:dict_value $manual_gradings $user_id] -submission $submission -exam_state [$examWf state] -runtime_panel_view $runtime_panel_view -feedbackFiles [$submission set online-exam-feedbackFiles]] if {$with_signature} { set sha256 [ns_md string -digest sha256 $answeredAnswerAttributes] set signatureString "<div class='signature'>online-exam-actual_signature: $sha256</div>\n" set submissionSignature [$submission property signature ""] if {$submissionSignature ne ""} { append signatureString "<div>#xowf.online-exam-submission_signature#: $submissionSignature</div>\n" } } else { set signatureString "" } set time [::xo::db::tcl_date [$submission property _last_modified] tz_var] set pretty_date [clock format [clock scan $time] -format "%Y-%m-%d"] # # If we filter by student and the exam is proctored, display # the procoring images as well. # if {$filter_submission_id ne "" && [$examWf property proctoring] eq "t"} { set markup [:render_proctor_images -submission $submission -revisions $revisions -examWf $examWf -revision_id $revision_id] set question_form [subst { <div class="container"> <div class="row"> <div class="col-md-6">$question_form</div> <div class="col-md-6">$markup</div> </div> </div> }] } if {$runtime_panel_view ne ""} { set gradingInfo [$grading_scheme print -achieved_points $achieved_points] set gradingPanel [:dict_value $gradingInfo panel ""] set runtime_panel [:runtime_panel -revision_id $revision_id -view $runtime_panel_view -grading_info $gradingPanel $submission] if {$autograde} { set grade [$grading_scheme grade -achieved_points $achieved_points] ns_log notice "CSV $userName\t[dict get $gradingInfo csv]" dict incr :grade_dict $grade append :grade_csv $userName\t[dict get $gradingInfo csv]\n } } else { set runtime_panel "" } # # Don't add details to exam-review for student. # if {$runtime_panel_view eq "student"} { set grading_scheme "" set achieved_points "" } set heading "$userName · $fullName · $pretty_date" append HTML [subst [ns_trim { <div class='single_exam'> <div class='runtime-panel' id='runtime-panel-$user_id' data-grading_scheme='[namespace tail $grading_scheme]' data-achieved_points='$achieved_points'> [expr {$with_exam_heading ? "<h3>$heading</h3>" : ""}] $runtime_panel </div> $signatureString $question_form </div> }]] return [list HTML $HTML results $results] } #---------------------------------------------------------------------- # Class: Answer_manager # Method: grading_scheme #---------------------------------------------------------------------- :public method grading_scheme { {-examWf:object,required} {-grading:token,0..n ""} {-total_points 100} } { # # Return the grading scheme object based on the provided short # name. In case the grading scheme belongs to the predefined # grading schemes, the object can be directly loaded. When the # name refers to a user-defined grading object, this might have # to be loaded. # # We could consider some hints about the usefulness of the # chosen grading scheme, E.g., when an exam has 40 points or # less, rounding has the potential effect that a high percentage # of the grade is just due to rounding. So, in such cases a # non-rounding scheme should be preferred. # # @return fully qualified grading scheme object # # # When not grading is provided, this muse be a legacy question. # if {$grading eq ""} { #set grading [expr {$total_points < 40 ? "round-none" : "round-points"}] set grading "none" ns_log notice "--- legacy grading scheme -> none" } set grading_scheme ::xowf::test_item::grading::$grading if {![nsf::is object $grading_scheme]} { # # Maybe we have to load this grading scheme... # #ns_log notice "grading_scheme_name load loaded yet: '$grading'" #::xo::show_stack ::xowf::test_item::grading::load_grading_schemes -package_id [$examWf package_id] -parent_id [$examWf parent_id] ns_log notice "--- grading schemes loaded" } if {![nsf::is object $grading_scheme]} { set grading_scheme ::xowf::test_item::grading::round-points ns_log notice "--- fallback to default grading scheme object" } #ns_log notice "USE grading_scheme $grading_scheme" return $grading_scheme } #---------------------------------------------------------------------- # Class: Answer_manager # Method: render_answers #---------------------------------------------------------------------- :public method render_answers { {-as_student:boolean false} {-filter_submission_id:integer,0..1 ""} {-creation_user:integer,0..1 ""} {-revision_id:integer,0..1 ""} {-filter_form_ids:integer,0..n ""} {-export:boolean false} {-orderby:token "online-exam-userName"} {-grading:token,0..n ""} {-with_grading_table:boolean false} examWf:object } { # # Return the answers in HTML format in a somewhat printer # friendly way, e.g. as the exam protocol. # # @return dict containing "do_stream" and "HTML" # #ns_log notice "RENDER ANSWERS 0" set combined_form_info [:QM combined_question_form $examWf] set autograde [dict get $combined_form_info autograde] set totalPoints [:QM total_points -max_items [$examWf property max_items ""] $combined_form_info] set withSignature [$examWf property signature 0] set examTitle [$examWf title] set ctx [::xowf::Context require $examWf] set results "" set wf [:get_answer_wf $examWf] if {$wf eq ""} { return [list do_stream 0 HTML ""] } if {$filter_form_ids ne "" && $filter_form_ids ni [dict get $combined_form_info question_objs]} { ns_log warning "inclass-exam: ignore invalid form_obj '$filter_form_ids';" "valid [dict get $combined_form_info question_objs]" set filter_form_ids "" } ns_log notice "--- grading '$grading'" set grading_scheme [:grading_scheme -examWf $examWf -grading $grading -total_points $totalPoints] #ns_log notice "--- grading_scheme $grading_scheme from grading '$grading'" set :grade_dict {} set :grade_csv "" set items [:submissions -creation_user $creation_user -filter_submission_id $filter_submission_id -revision_id $revision_id -wf $wf] # # In case we have many items to render (which might take a # while), use streaming mode. # set do_stream [expr {[llength [$items children]] > 100}] set HTML [:render_print_button] if {!$as_student} { # # When rendering for teachers, we offer the possibility for to # sort, filter and export submissions. # append HTML [:render_filter_bar -examWf $examWf -filter_form_ids $filter_form_ids -revision_id $revision_id -filter_submission_id $filter_submission_id -orderby $orderby] } ::xo::cc set_parameter template_file view-plain-master ::xo::cc set_parameter MenuBar 0 if {[llength $filter_form_ids] > 0} { # # Filter by questions. For the time being, we allow only a # single question, ... and we take the first ones. # append HTML "<h2>#xowf.question#: [ns_quotehtml [[lindex $filter_form_ids 0] title]]</h2>\n" set runtime_panel_view "" } elseif {$as_student} { # # Show the student his own submission # set userName [acs_user::get_element -user_id [ad_conn user_id] -element username] set fullName [::xo::get_user_name [ad_conn user_id]] set heading "$userName - $fullName" append HTML "<h2>#xowf.online-exam-review-protocol# - $heading</h2>\n" set runtime_panel_view "student" } else { # # Provide the full protocol (or a subset of it) # append HTML "<h2>#xowf.online-exam-protocol#</h2>\n" if {$filter_submission_id ne ""} { set runtime_panel_view "revision_overview" } else { set runtime_panel_view "default" } } append HTML [:grading_dialog_setup $examWf] #ns_log notice "RENDER ANSWERS 1" if {$do_stream} { # ns_log notice STREAM-[info level]-$::template::parse_level # # The following line is tricky: set on the parsing level the # title of and context of the page, since this is needed by # the streaming template. # uplevel #$::template::parse_level [subst {set title "$examTitle"; set context .}] ad_return_top_of_page [ad_parse_template -params [list context title] [template::streaming_template]] ns_write [subst { <div class=''main-content> <div class='xowiki-content' style='padding-left:15px;'> <h1>[ns_quotehtml $examTitle]</h1> [lang::util::localize $HTML] }] set HTML "" } if {$export} { set recutil [:recutil_create -clear -exam_id [$wf parent_id] -fn [expr {$filter_submission_id eq "" ? "all.rec" : "$filter_submission_id.rec"}] ] } else { set recutil "" } #ns_log notice "RENDER ANSWERS 2" # # Create zip file from file submissions # set create_zip_file [::xo::cc query_parameter create-file-submission-zip-file:boolean 0] if {$create_zip_file} { package req nx::zip [$examWf package_id] get_lang_and_name -name [$examWf set name] lang stripped_name if {[string equal [nx::zip::Archive info lookup parameters create name] -name]} { set zipFile [nx::zip::Archive new -name [ad_sanitize_filename $stripped_name]] } else { set zipFile [::nx::zip::Archive new] # # Post-register property, since it is not yet available in # this version of nx. # $zipFile object property name $zipFile configure -name [ad_sanitize_filename $stripped_name] } } else { set zipFile "" } #ns_log notice "RENDER ANSWERS 3 (submissions: [llength [$items children]])" set file_submission_exists 0 set form_objs_exam [:QM load_question_objs $examWf [$examWf property question]] set question_dict [:FL name_to_question_obj_dict $form_objs_exam] #ns_log notice "passed filter_form_ids <$filter_form_ids> form_objs_exam <$form_objs_exam>" # # Iterate over the items sorted by orderby. # $items orderby $orderby foreach submission [$items children] { set d [:render_submission=exam_protocol -submission $submission -wf $wf -examWf $examWf -exam_question_dict $question_dict -autograde $autograde -combined_form_info $combined_form_info -filter_submission_id $filter_submission_id -filter_form_ids $filter_form_ids -grading_scheme $grading_scheme -recutil $recutil -zipFile $zipFile -revision_id $revision_id -totalPoints $totalPoints -runtime_panel_view $runtime_panel_view -with_exam_heading [expr {!$as_student}] -with_signature $withSignature] set html [:dict_value $d HTML] #ns_log notice "RENDER ANSWERS setting result" dict set results [$submission set creation_user] [:dict_value $d results] if {$do_stream && $html ne ""} { ns_write [lang::util::localize $html] } else { append HTML $html } # # Check if we have found a file submission # if {!$file_submission_exists && !$export && [llength [:get_non_empty_file_formfields -submission $submission]] > 0 } { set file_submission_exists 1 } } #ns_log notice "RENDER ANSWERS 4" if {$export} { $recutil destroy } if {$with_grading_table && $autograde && $grading ne "none"} { append HTML <p>[:grading_table -csv ${:grade_csv} ${:grade_dict}]</p> # # The following lines are convenient for debugging # #set manual_gradings [$examWf property manual_gradings] #set manual_gradings [:get_exam_results -obj $examWf manual_gradings] #append HTML <pre>$manual_gradings</pre> #append HTML <pre>[:exam_results -manual_gradings $manual_gradings $results]</pre> } if {$create_zip_file} { $zipFile ns_returnZipFile [$zipFile cget -name].zip $zipFile destroy ad_script_abort } # # If we have already some file submission we are showing a link # for bulk-downloading the submissions # if {$file_submission_exists} { # # Avoid empty entries for query parameters # if {[llength $filter_form_ids] > 0} { set fos $filter_form_ids } foreach value {revision_id filter_submission_id} var {rid id} { if {[set $value] ne ""} { set $var [set $value] } } set href [$examWf pretty_link -query [export_vars { {m print-answers} {create-file-submission-zip-file 1} fos rid id }]] append HTML [ns_trim -delimiter | [subst { |<a href='[ns_quotehtml $href]'> |[::xowiki::bootstrap::icon -name download -CSSclass download-submissions] |#xowf.Download_file_submissions#</a> }]] } #ns_log notice "RENDER ANSWERS 5" # # Store statistics only in autograding cases, and only, when it # was a full evaluation of the exam. This has the advantage # that we do no have to partially update the statistics. These # are somewhat overly conservative assumptions for now, which # might be partially relaxed in the future. # if {$with_grading_table && !$as_student && $filter_submission_id eq "" && $creation_user eq "" && $revision_id eq "" } { set statistics {} set ia [$examWf instance_attributes] if {$autograde} { foreach var {__stats_success __stats_count} key {success count} { if {[$examWf exists $var]} { dict set statistics $key [$examWf set $var] $examWf unset $var } } :AM set_exam_results -obj $examWf statistics $statistics } :AM set_exam_results -obj $examWf results $results } return [list do_stream $do_stream HTML $HTML] } #---------------------------------------------------------------------- # Class: Answer_manager # Method: participant_result #---------------------------------------------------------------------- :method participant_result { -obj:object answerObj:object form_info form_field_objs } { :assert_answer_instance $answerObj :assert_assessment $obj set instance_attributes [$answerObj instance_attributes] set answer [list item $answerObj] foreach f $form_field_objs { set att [$f name] if {[dict exists $instance_attributes $att]} { set value [dict get $instance_attributes $att] #ns_log notice "### '$att' value '$value'" $answerObj combine_data_and_form_field_default 1 $f $value $f set_feedback 1 # # TODO: world-cloud statistics make mostly sense for the # inclass quizzes, but there these require still an # interface via "reporting_obj" instead of "add_statistics" # (although, for the purposes of the inclass-quiz, # randomization is not an issue. # #$f add_statistics -options {word_statistics word_cloud} # # Leave the form-field in statistics mode in a state with # correct answers. # $f make_correct #ns_log notice "FIELD $f [$f name] [$f info class] -> VALUE [$f set value]" if {[$f exists correction]} { set correction [$f set correction] } else { set correction "" ns_log warning "form-field [$f name] of type [$f info class] " "does not provide variable correction via 'make_correct'" } lappend answer [list name $att value $value correction $correction evaluated_answer_result [$f set evaluated_answer_result]] } } return $answer } #---------------------------------------------------------------------- # Class: Answer_manager # Method: answer_form_field_objs #---------------------------------------------------------------------- :public method answer_form_field_objs {-clear:switch -wf:object -generic:switch form_info} { # # Instantiate the form_field objects of the provided form based on # form_info. # set key ::__test_item_answer_form_fields if {$clear} { # # The -clear option is needed, when there are multiple # assessments protocols/tables on the same page (currently # not). # unset -nocomplain $key } else { #ns_log notice "### answer_form_field_objs key exists [info exists $key]" if {![info exists $key]} { #ns_log notice "form_info: $form_info" set fc [lsort -unique [dict get $form_info disabled_form_constraints]] #ns_log notice "### FC $fc" set pc_params [::xo::cc perconnection_parameter_get_all] if {$generic} { set fc [:replace_in_fc -fc $fc shuffle_kind none] set fc [:replace_in_fc -fc $fc show_max ""] } set $key [$wf create_form_fields_from_form_constraints -lookup $fc] ::xo::cc perconnection_parameter_set_all $pc_params $wf form_field_index [set $key] } return [set $key] } } :method "result_table per_question" { {-manual_gradings "" } results_dict } { set table [::xowiki::TableWidget new -name results -columns { Field create participant -label #xowf.participant# -orderby participant Field create question -label #xowf.question# Field create achieved -label #xowf.Achieved_points# -orderby achieved -html {align right} Field create achievable -label #xowf.Achievable_points# -orderby achievable -html {align right} Field create comment -label #xowf.feedback# }] foreach user_id [dict keys $results_dict] { set manual_grading [:dict_value $manual_gradings $user_id] set participant [acs_user::get_element -user_id $user_id -element username] foreach qn [dict keys [dict get $results_dict $user_id]] { set achievable [dict get $results_dict $user_id $qn achievable] set achieved [:dict_value [:dict_value $manual_grading $qn] achieved] if {$achieved eq ""} { set achieved [dict get $results_dict $user_id $qn achieved] } $table add -participant $participant -question [string trimright $qn _] -achievable $achievable -achieved [expr {$achieved eq "" ? "" : [format %.2f $achieved]}] -comment [:dict_value [:dict_value $manual_grading $qn] comment] } } return $table } :method "result_table per_participant" { {-manual_gradings ""} {-gradingScheme} {-only_grades:boolean false} results_dict } { ns_log notice "per_participant gradingScheme $gradingScheme" # # In case "only_grades" is specified, hide field "achieved". # set fieldType [expr {$only_grades ? "HiddenField" : "Field"}] set fieldTypeGrade [expr {$gradingScheme eq "::xowf::test_item::grading::none" ? "HiddenField" : "Field"}] set grade_dict {} set table [::xowiki::TableWidget new -name results -columns [subst { Field create participant -label #xowf.participant# -orderby participant $fieldType create achieved -label #xowf.Achieved_points# -orderby achieved -html {align right} HiddenField create achievable -label #xowf.Achievable_points# -orderby achievable -html {align right} $fieldType create percentage -label #xowf.Percentage# -orderby percentage -html {align right} $fieldTypeGrade create grade -label #xowf.Grade# -orderby grade -html {align right} }]] #ns_log notice "We have in results_dict the following users: [dict keys $results_dict]" foreach {user_id properties} $results_dict { if {[llength $properties] == 0} { # # The user has not seen any exercises, probably in "initial" # state, ignore it. # continue } set manual_grading [:dict_value $manual_gradings $user_id] set achievedPoints 0.0 set achievablePoints 0.0 set participant [acs_user::get_element -user_id $user_id -element username] foreach qn [dict keys [dict get $results_dict $user_id]] { set achievable [dict get $results_dict $user_id $qn achievable] # # Respect manual_grading, since these are eagerly updated # via exam protocol. # set achieved [:dict_value [:dict_value $manual_grading $qn] achieved] if {$achieved eq ""} { set achieved [dict get $results_dict $user_id $qn achieved] } # # When a participant has not done yet this exercise, the # value might be empty. # if {$achieved eq ""} { set achieved 0 } set achievedPoints [expr {$achievedPoints + $achieved}] set achievablePoints [expr {$achievablePoints + $achievable}] } set gradingDict [$gradingScheme grading_dict [list achievedPoints $achievedPoints achievablePoints $achievablePoints totalPoints $achievablePoints]] #ns_log notice "COMPLETED DICT $gradingDict" set grade [$gradingScheme grade -achieved_points $gradingDict] dict incr grade_dict $grade set l [::xo::Table::Line new] $table add -participant $participant -achievable $achievablePoints -achieved [dict get $gradingDict achievedPoints] -percentage [dict get $gradingDict percentageRounded] -grade $grade } $table set __grade_dict $grade_dict return $table } #---------------------------------------------------------------------- # Class: Answer_manager # Method: exam_results #---------------------------------------------------------------------- :public method exam_results { {-manual_gradings "" } {-gradingScheme ""} {-only_grades:boolean false} {-reply:switch false} {-format csv} {-orderby "participant,desc"} results_dict } { # # Return results either as HTML table, as HTML chart or as # csv. When "reply" is set. the result is returned directly to # the browser (for downloading). # # When "gradingScheme" is empty, this method returns the # following fields: # # participant, question, achieved_points, achievable points, comment # # When the "gradingScheme" is specified the results are # per-participant. In this cases, when the "gradingScheme" is # "....::none", the fields are # # participant, achieved, percentage # # otherwise the grade and rounding of achieved points and # percentage are exported based on the rules of the grading # scheme. # # participant, achieved, percentage, grade # # When additionally "only_grades" is specified, just participant # and grad are returned/exported. # # @param gradingScheme needed for reporting grades, can be empty # @param reply when false, csv will be returned as text, when # true, it will be returned as response to the # browser. # @param results_dict the results to format as csv, every key in # the dict represents a user_id. # # @return csv as value or as response to the client # set result "" if {$gradingScheme eq ""} { set t [:result_table per_question -manual_gradings $manual_gradings $results_dict] } else { set t [:result_table per_participant -gradingScheme $gradingScheme -only_grades $only_grades -manual_gradings $manual_gradings $results_dict] } lassign [split $orderby ,] att order $t orderby -order [expr {$order eq "asc" ? "increasing" : "decreasing"}] -type [ad_decode $att achieved real achievable real grade integer dictionary] $att # # XLS export requires OOXML # # See https://fossil.sowaswie.de/ooxml/index # if {$format eq "xls" && [::namespace which ::ooxml::xl_write] eq ""} { set format csv } if {$reply} { switch $format { html { ns_return 200 "text/html; charset=utf-8" [$t asHTML] ad_script_abort } xls { set s [::ooxml::xl_write new] set sheet [$s worksheet {1}] set decimal [lc_get "decimal_point"] set doublestyle [$s style -numfmt [$s numberformat -decimal -format "#${decimal}##"]] set stringstyle [$s style -numfmt [$s numberformat -string]] set datestyle [$s style -numfmt [$s numberformat -date]] set cellformat {} #iterate cols of table $s row $sheet set displayColumns [lmap column [${t}::__columns children] { if {[$column exists no_csv]} continue if {[$column istype ::xo::Table::BulkAction]} continue if {[$column istype ::xo::Table::HiddenField]} continue set column }] foreach column $displayColumns { if {[$column name] in {"achieved" "achievable" "percentage"}} { lappend cellformat double } else { lappend cellformat string } set label [$column label] if {[regexp {^#([a-zA-Z0-9_:-]+\.[a-zA-Z0-9_:-]+)#$} $label _ message_key]} { set label [_ $message_key] } set value [string map {\" \\\" \n \r} $label] $s cell $sheet $value } #iterate row content foreach row [$t children] { $s row $sheet set i 0 foreach column $displayColumns { set value [string map {\" \\\" \n \r} [$row set [$column set name]]] set format [lindex $cellformat $i] $s cell $sheet $value -style [set [set format]style] incr i } } $s write results.xlsx ad_script_abort } default {set result [$t write_csv]} } } else { switch $format { chart {set result [:grading_table [$t set __grade_dict]]} html {set result [$t asHTML]} default {set result [$t format_csv]} } } $t destroy return $result } #---------------------------------------------------------------------- # Class: Answer_manager # Method: grading_table #---------------------------------------------------------------------- :public method grading_table {{-csv ""} grade_dict} { # # Produce HTML markup based on a dict with grades as keys and # counts as values. # set gradingTable {<div class="grading-info"><div class="table-responsive"><table class="table grading">} append gradingTable "<thead><th class='text-right col-md-1'>#xowf.Grade#</th><th class='col-md-1 text-right'>#</th></thead>" "<tbody>\n" set nrGrades 0 foreach v [dict values $grade_dict] { incr nrGrades $v} set grades [lsort [dict keys $grade_dict]] foreach k $grades { set count [dict get $grade_dict $k] set countPercentage [format %.2f [expr {$count *100.0 / $nrGrades}]] append gradingTable <tr> [subst {<td class="text-right">$k</td><td class="text-right">$count</td>}] [subst {<td><div class="progress"><div class="progress-bar" style="width:$countPercentage%">$countPercentage%</div></td}] </tr>\n } append gradingTable "</tbody></table></div>\n" if {$csv ne "" } { append gradingTable "<pre>$csv</pre></div>\n" } if {[template::head::can_resolve_urn urn:ad:js:highcharts]} { # # The highcharts package is available # template::add_body_script -src urn:ad:js:highcharts set graphID pie-[incr ::__xotcl_highcharts_pie] append gradingTable "<div id='$graphID'></div>\n" set data "" foreach k $grades { set count [dict get $grade_dict $k] set countPercentage [format %.2f [expr {$count *100.0 / $nrGrades}]] lappend data [subst {{name:'$k', y: $countPercentage}}] } set gradeLabel [_ xowf.Grade] template::add_body_script -script [subst [ns_trim { Highcharts.chart('$graphID', { chart: {type: 'pie'}, plotOptions: {pie: {size: 200}, series: {dataLabels: {enabled: true, format: '$gradeLabel {point.name}: {point.y:.1f}%'} }}, title: {text: ''}, credits: {enabled: true }, series: \[{name: 'Percentage', data: \[ [join $data ,] \]}\] }); }]] } return $gradingTable } #---------------------------------------------------------------------- # Class: Answer_manager # Method: results_table #---------------------------------------------------------------------- :public method results_table { -package_id:integer -items:object,required {-view_all_method print-answers} {-with_answers:boolean true} {-state done} {-grading_scheme ::xowf::test_item::grading::none} wf:object } { # # Render the results in format of a table and return HTML. # Currently mostly deactivated (but potentially called by # online-exam.wf and topic-assignment.wf). # #set form_info [:combined_question_form -with_numbers $wf] set form_info [:QM combined_question_form $wf] set answer_form_field_objs [:answer_form_field_objs -wf $wf $form_info] set autograde [dict get $form_info autograde] #if {$autograde && [llength $answer_form_field_objs] > 10} { # set with_answers 0 #} set form_field_objs {} lappend form_field_objs [$wf create_raw_form_field -name _online-exam-userName -spec text,label=#xowf.participant#] if {$with_answers} { # # Create for every answer field a matching grading field # set ff_dict {} foreach answer_field_obj $answer_form_field_objs { #ns_log notice "LABEL [$answer_field_obj name] <[$answer_field_obj label]>" $answer_field_obj label [string trimright [$answer_field_obj name] _] $answer_field_obj mixin ::xowf::test_item::td_pretty_value set grading_field_obj [$wf create_raw_form_field -name [$answer_field_obj name].score -spec number,label=#xowf.Grading-Score#] lappend form_field_objs $answer_field_obj $grading_field_obj dict set ff_dict [$answer_field_obj name] $answer_field_obj dict set ff_dict [$grading_field_obj name] $grading_field_obj } } # if {0 && $autograde} { # lappend form_field_objs # [$wf create_raw_form_field # -name _online-exam-total-score # -spec number,label=#xowf.Total-Score#] # [$wf create_raw_form_field # -name _online-exam-grade # -spec number,label=#xowf.Grade#] # } lappend form_field_objs [$wf create_raw_form_field -name _online-exam-seconds -spec number,label=#xowf.Seconds#] [$wf create_raw_form_field -name _creation_date -spec date,label=#xowiki.Page-last_modified#] # # Check, if any of the answer form field objects is # randomized. If so, it is necessary to recreate these eagerly, # since the full object structure might be personalized. # set randomized_fields {} foreach ff_obj $answer_form_field_objs { if {[$ff_obj exists shuffle_kind] && [$ff_obj shuffle_kind] ne "none"} { lappend randomized_fields $ff_obj } } # # Take "orderby" from the query parameter. If not set, order by # the first field. # set orderby [::$package_id query_parameter orderby:token ""] if {$orderby eq "" && [llength $form_field_objs] > 0} { set orderby [[lindex $form_field_objs 0] name],asc } # # Create table widget. # set table_widget [::xowiki::TableWidget create_from_form_fields -package_id $package_id -form_field_objs $form_field_objs -orderby $orderby] # # Extend properties of every answer with corresponding ".score" # values. # foreach p [$items children] { # # If we have randomized fields, we have to # recreate/reinitialize these to get proper correction # markings for this user. It might be possible to optimize # this, when only a few fields are randomized. # if {[llength $randomized_fields] > 0} { #ns_log notice "WORK ON [$p creation_user] " :answer_form_field_objs -clear -wf $wf $form_info $wf form_field_flush_cache xo::cc eval_as_user -user_id [$p creation_user] { set answer_form_field_objs [:answer_form_field_objs -wf $wf $form_info] } } set total_score 0 set total_points 0 foreach ff_obj $answer_form_field_objs { $ff_obj object $p set property [$ff_obj name] $ff_obj value [$p property $property] $ff_obj set_feedback 3 #ns_log notice "[$p creation_user] [$ff_obj name] [$p property $property] -> [$ff_obj set evaluated_answer_result]" set r [expr {[$ff_obj exists grading_score] ? [$ff_obj set grading_score] : ""}] # # In case, we have a grading score, which is not starred, we # can compute points from this. # if {$r ne "" && ![regexp {[*]$} $r]} { # # Add exercise score weighted to the total score to # compute points. # if {[$ff_obj exists test_item_points]} { #ns_log notice "[$ff_obj name]: grading_score <$r>, test_item_points <[$ff_obj set test_item_points]>" set minutes [$ff_obj set test_item_points] set total_score [expr {$total_score + ($minutes * [$ff_obj set grading_score])}] set total_points [expr {$total_points + $minutes}] } #ns_log notice "==== [$ff_obj name] grading_score => $r" } else { set r [expr {[$ff_obj set evaluated_answer_result] eq "correct" ? 100.0 : 0.0}]* #ns_log notice [$ff_obj serialize] } $p set_property -new 1 $property.score $r } set duration [:get_duration [$p get_revision_sets]] $p set_property -new 1 _online-exam-seconds [dict get $duration seconds] # if {0 && $autograde && $total_points > 0} { # set final_score [expr {$total_score/$total_points}] # $p set_property -new 1 _online-exam-total-score $final_score # # set d [list achievedPoints $total_score achievablePoints $total_points totalPoints $total_points] # set grade [$grading_scheme grade -achieved_points $d] # dict incr grade_count $grade # $p set_property -new 1 _online-exam-grade $grade # } } if {$state eq "done"} { set uc {tcl {[$p state] ne "done"}} } else { set uc {tcl {false}} } # # Render table widget with extended properties. # set HTML [$table_widget render_page_items_as_table -package_id $package_id -items $items -form_field_objs $form_field_objs -csv true -uc $uc -view_field _online-exam-userName -view_filter_link [$wf pretty_link -query m=$view_all_method] {*}[expr {[info exists generate] ? [list -generate $generate] : ""}] -return_url [ad_return_url] -return_url_att local_return_url ] $table_widget destroy if {0 && $autograde} { set gradingTable {<div class="table-responsive"><table class="table">} append gradingTable "<thead><th class='text-right col-md-1'>#xowf.Grade#</th><th class='col-md-1 text-right'>#</th></thead>" "<tbody>\n" set nrGrades 0 foreach v [dict values $grade_count] { incr nrGrades $v} foreach k [lsort [dict keys $grade_count]] { set count [dict get $grade_count $k] set countPercentage [expr {$count*100.0/$nrGrades}] append gradingTable <tr> [subst {<td class="text-right">$k</td><td class="text-right">$count</td>}] [subst {<td><div class="progress"><div class="progress-bar" style="width:$countPercentage%">$countPercentage%</div></td}] </tr>\n } append gradingTable "</tbody></table></div>\n" append HTML <p>$gradingTable</p> } return $HTML } #---------------------------------------------------------------------- # Class: Answer_manager # Method: participants_table #---------------------------------------------------------------------- :public method participants_table { -package_id:integer -items:object,required {-view_all_method print-answers} {-state done} wf:object } { # # This method returns an HTML table containing a row for every # participant with Name and short summary information. This # table provides as well an interface for sending messages to # this student. # set form_field_objs {} lappend form_field_objs [$wf create_raw_form_field -name _online-exam-userName -spec text,label=#xowf.participant#] [$wf create_raw_form_field -name _online-exam-fullName -spec label,label=#acs-subsite.Name#,disableOutputEscaping=true] [$wf create_raw_form_field -name _state -spec text,label=#xowf.Status#] [$wf create_raw_form_field -name _online-exam-seconds -spec number,label=#xowf.Seconds#] [$wf create_raw_form_field -name _creation_date -spec date,label=#xowiki.Page-last_modified#] # # Take "orderby" from the query parameter. If not set, order by # the first field. # set orderby [::$package_id query_parameter orderby:token ""] if {$orderby eq "" && [llength $form_field_objs] > 0} { set orderby [[lindex $form_field_objs 0] name],asc } # # Create table widget. # set table_widget [::xowiki::TableWidget create_from_form_fields -package_id $package_id -form_field_objs $form_field_objs -type_map {_online-exam-seconds integer} -orderby $orderby] # # Extend properties of individual answers and add notification # dialogs. # set dialogs "" set user_list {} foreach p [$items children] { #foreach ff_obj $answer_form_field_objs { # $ff_obj object $p # set property [$ff_obj name] # $ff_obj value [$p property $property] #} # # Provide a notification dialog only before the student has # submitted her exam and the exam is published. # if {[$p state] ne "done" && [$wf state] eq "published"} { set dialog_info [::xowiki::includelet::personal-notification-messages modal_message_dialog -to_user_id [$p creation_user]] append dialogs [dict get $dialog_info dialog] \n set notification_dialog_button [dict get $dialog_info link] $p set online-exam-fullName "$notification_dialog_button [$p set online-exam-fullName]</a>" lappend user_list [$p creation_user] } # # Extend every answer with corresponding precomputed extra # "_online-exam-*" values to ease rendering: # set duration [:get_duration [$p get_revision_sets]] $p set_property -new 1 _online-exam-seconds [dict get $duration seconds] } ::xowiki::includelet::personal-notification-messages modal_message_dialog_register_submit -url [$wf pretty_link -query m=send-participant-message] set bulk_notification_HTML "" if {$state eq "done"} { set uc {tcl {[$p state] ne "done"}} } else { set uc {tcl {false}} if {[llength $user_list] > 0} { # # Provide bulk notification message dialog to send message to all users # set dialog_info [::xowiki::includelet::personal-notification-messages modal_message_dialog -to_user_id $user_list] append dialogs [dict get $dialog_info dialog] \n set notification_dialog_button [dict get $dialog_info link] set bulk_notification_HTML "<div class='bulk-personal-notification-message'>$notification_dialog_button #xowiki.Send_message_to# [llength $user_list] #xowf.Participants#</a></div>" } } # # Render table widget with extended properties. # set HTML [$table_widget render_page_items_as_table -package_id $package_id -items $items -form_field_objs $form_field_objs -csv true -uc $uc -view_field _online-exam-userName -view_filter_link [$wf pretty_link -query m=$view_all_method] {*}[expr {[info exists generate] ? [list -generate $generate] : ""}] -return_url [ad_return_url] -return_url_att local_return_url ] $table_widget destroy return $dialogs$HTML$bulk_notification_HTML } #---------------------------------------------------------------------- # Class: Answer_manager # Method: marked_results #---------------------------------------------------------------------- :public method marked_results {-obj:object -wf:object form_info} { # # Return for every participant the individual results for an exam # set form_field_objs [:answer_form_field_objs -wf $wf $form_info] set items [:get_wf_instances $wf] set results "" foreach i [$items children] { xo::cc eval_as_user -user_id [$i creation_user] { set participantResult [:participant_result -obj $obj $i $form_info $form_field_objs] } append results $participantResult \n } #ns_log notice "=== marked_results of [llength [$items children]] items => $results" return $results } #---------------------------------------------------------------------- # Class: Answer_manager # Method: answers_panel #---------------------------------------------------------------------- :public method answers_panel { {-polling:switch false} {-heading #xowf.submitted_answers#} {-submission_msg #xowf.participants_answered_question#} {-manager_obj:object} {-target_state ""} {-wf:object} {-current_question ""} {-extra_text ""} } { # # Produce HTML code for an answers panel, containing the number # of participants of an e-assessment and the number of # participants, who have already answered. # # @param polling when specified, provide live updates # of the numbers via AJAX calls # @param extra_text optional extra text for the panel, # has to be provided with valid HTML markup. # set answers [:get_answer_attributes $wf] set nrParticipants [llength $answers] if {$current_question ne ""} { set answered [:FL answers_for_form [$current_question name] $answers] } else { set answered [:get_answer_attributes -state $target_state $wf] } set nrAnswered [llength $answered] set answerStatus [::xowiki::bootstrap::card -title $heading -body [subst {<p><span id='answer-status'>$nrAnswered/$nrParticipants</span> $submission_msg<p>$extra_text}]] if {$polling} { # # Auto refresh of number of participants and submissions when # polling is on. # set url [$manager_obj pretty_link -query m=poll] template::add_body_script -script [subst -nocommands { (function poll() { setTimeout(function() { var xhttp = new XMLHttpRequest(); xhttp.open("GET", '$url', true); xhttp.onreadystatechange = function() { if (this.readyState == 4 && this.status == 200) { var data = xhttp.responseText; var el = document.querySelector('#answer-status'); el.innerHTML = data; poll(); //activate links if a users started the exam var answers = data.split('/'); if (answers.length == 2 && answers[1] > 0) { var disabledLinkItems = document.querySelectorAll(".list-group-item.link-disabled"); disabledLinkItems.forEach(function(linkItem) { linkItem.classList.remove("link-disabled"); }); } } }; xhttp.send(); }, 1000); })(); }] } return $answerStatus } #---------------------------------------------------------------------- # Class: Answer_manager # Method: prevent_multiple_tabs #---------------------------------------------------------------------- :public method prevent_multiple_tabs { {-cookie_name multiple_tabs} } { # # Prevent answering the same survey from multiple, concurrently # open tabs. # template::add_body_script -script [subst { var cookieLine = document.cookie.split('; ').find(row => row.startsWith('$cookie_name=')); var cookieValue = (cookieLine === undefined) ? 1 : parseInt(cookieLine.split('=')\[1\]) + 1; // console.log("cookie $cookie_name " + cookieValue); if (cookieValue > 1) { alert('Already open!'); window.open("about:blank", "_self").close(); } document.cookie = "$cookie_name=" + cookieValue; // console.log("START finished -> " + document.cookie); window.onunload = function () { var cookieLine = document.cookie.split('; ').find(row => row.startsWith('$cookie_name=')); var cookieValue = (cookieLine === undefined) ? 0 : parseInt(cookieLine.split('=')\[1\]) - 1; document.cookie = "$cookie_name=" + cookieValue; // console.log("UNLOAD finished -> " + document.cookie); }; }] } #---------------------------------------------------------------------- # Class: Answer_manager # Method: countdown_timer #---------------------------------------------------------------------- :public method countdown_timer { {-target_time:required} {-id:required} {-audio_alarm:boolean true} {-audio_alarm_cookie incass_exam_audio_alarm} {-audio_alarm_times: 60,30,20,10,5,2} } { # # Accepted formats for target_time, determined by JavaScript # ISO 8601, e.g. YYYY-MM-DDTHH:mm:ss.sss" # # Set current time based on host time instead of new # Date().getTime() to avoid surprises, in cases, the time at the # client browser is set incorrectly. # set nowMs [clock milliseconds] set nowIsoTime [clock format [expr {$nowMs/1000}] -format "%Y-%m-%dT%H:%M:%S"].[format %.3d [expr {$nowMs % 1000}]] template::add_body_script -script [subst { var countdown_target_date = new Date('$target_time').getTime(); var countdown_days, countdown_hours, countdown_minutes, countdown_seconds; var countdown = document.getElementById('$id'); // adjust target time by the difference between the host and client time countdown_target_date = countdown_target_date - (new Date('$nowIsoTime').getTime() - new Date().getTime()); setInterval(function () { var current_date = new Date().getTime(); var absolute_seconds_left = (countdown_target_date - current_date) / 1000; var seconds_left = absolute_seconds_left var HTML = ''; countdown_days = parseInt(seconds_left / 86400); seconds_left = seconds_left % 86400; countdown_hours = parseInt(seconds_left / 3600); seconds_left = seconds_left % 3600; countdown_minutes = parseInt(seconds_left / 60); countdown_seconds = parseInt(seconds_left % 60); var alarmseconds = countdown.parentNode.dataset.alarmseconds; if (typeof alarmseconds !== 'undefined') { var full_seconds = Math.trunc(absolute_seconds_left); // for testing purposes, use: (full_seconds % 5 == 0) if (alarmseconds.includes(full_seconds)) { beep(200); } } if (seconds_left < -60) { countdown.innerHTML = "<span style='color:red;'> [_ xowf.Countdown_timer_expired]</span>" return } if (countdown_days != 0) { HTML += '<span class="days">' + countdown_days + ' <b> ' + (countdown_days != 1 ? '[_ xowf.Days]' : '[_ xowf.Day]') + '</b></span> '; } if (countdown_hours != 0 || countdown_days != 0) { HTML += '<span class="hours">' + countdown_hours + ' <b> ' + (countdown_hours != 1 ? '[_ xowf.Hours]' : '[_ xowf.Hour]') + '</b></span> '; } HTML += '<span class="minutes">' + countdown_minutes + ' <b> ' + (countdown_minutes != 1 ? '[_ xowf.Minutes]' : '[_ xowf.Minute]') + '</b></span> ' + '<span class="seconds">' + countdown_seconds + ' <b> ' + (countdown_seconds != 1 ? '[_ xowf.Seconds]' : '[_ xowf.Second]') + '</b></span> [_ xowf.remaining]' ; countdown.innerHTML = HTML; }, 1000); var beep = (function () { return function (duration, finishedCallback) { var container = document.getElementById('$id').parentNode; //console.log("beep attempt " + duration + ' ' + audioContext + ' ' + container.dataset.alarm); if (typeof audioContext !== 'undefined' && (container.dataset.alarm == 'active')) { //console.log("true beep duration " + duration + ' ' + audioContext + ' ' + audioContext.state); var osc = audioContext.createOscillator(); osc.type = "sine"; osc.connect(audioContext.destination); if (osc.noteOn) osc.noteOn(0); // old browsers if (osc.start) osc.start(); // new browsers setTimeout(function () { if (osc.noteOff) osc.noteOff(0); // old browsers if (osc.stop) osc.stop(); // new browsers }, duration); } }; })(); }] if {$audio_alarm} { # # Audio alarm handling is more tricky than expected, since # modern browsers do not allow one to create an active sound # context without a "user gesture" (requires e.g. a click to # start). # # The code tries to remember the audio state between different # pages, such when e.g. being in an exam, the user has to # activate/deactivate the audio not on every page. However, # when the user does a full reload, then the user has to # activate the audio alarm again. # # The state is symbolized using bootstrap 3 glyphicons or # bootstrap icons. The code is tested primarily with chrome. # template::add_body_script -script [subst { var audioContext = new AudioContext(); var audioContext_setSate = (function (targetState) { var container = document.getElementById('$id').parentNode; //console.log('--- state = ' + audioContext.state + ' want ' + targetState); if (targetState == 'active') { var elements = container.querySelector('i'); var prefix = 'bi'; if (!elements) { elements = container.querySelector('span'); prefix = 'glyphicon'; } elements.classList.remove(prefix + '-volume-off'); elements.classList.add(prefix + '-volume-up'); container.dataset.alarm = 'active'; document.cookie = '$audio_alarm_cookie=active; sameSite=strict'; audioContext.resume().then(() => {console.log('Playback resumed successfully ' + targetState);}); } else { var elements = container.querySelector('i'); var prefix = 'bi'; if (!elements) { elements = container.querySelector('span'); prefix = 'glyphicon'; } elements.classList.remove(prefix + '-volume-up'); elements.classList.add(prefix + '-volume-off'); container.dataset.alarm = 'inactive'; document.cookie = '$audio_alarm_cookie=inactive; sameSite=strict'; audioContext.suspend().then(() => {console.log('Playback suspended successfully ' + targetState);}); } //console.log('setSate ' + audioContext.state + ' alarm ' + container.dataset.alarm); }); var audioContext_toggle = (function (event) { var container = document.getElementById('$id').parentNode; //console.log('audioContext_toggle ' + audioContext.state); if (container.dataset.alarm != 'active') { audioContext_setSate('active'); beep(200); } else { audioContext_setSate('inactive'); } }); var audioContext_onload = (function (event) { var m = document.cookie.match('(^|;)\\s*$audio_alarm_cookie\\s*=\\s*(\[^;\]+)'); var cookieValue = (m ? m.pop() : 'inactive'); console.log('audioContext_onload ' + audioContext.state + ' cookie ' + cookieValue); // // When the current state is 'running' the behavior seems // cross browser uniform, we can set it to the state we got // from the cookie. // if (audioContext.state == 'running') { audioContext_setSate(cookieValue); } else { // // FireFox can switch to "active" after reload, while // this does not work on Chrome and friends. // if (navigator.userAgent.toLowerCase().indexOf('firefox') > -1) { audioContext_setSate(cookieValue); } else { audioContext_setSate('inactive'); } } }); console.log('onload'); console.log(document.getElementById('$id')); console.log('register audiocontext_toggle'); document.getElementById('$id').parentNode.addEventListener('click', audioContext_toggle); window.addEventListener('load', audioContext_onload); }] if {[ns_conn isconnected]} { # # The icon names "volume-off" and "volume-up" exist in the # glyph icons and for the bootstrap icons (Bootstrap 5) # set alarmState [ns_getcookie $audio_alarm_cookie "inactive"] set icon [expr {$alarmState eq "inactive" ? "volume-off":"volume-up"}] } else { set alarmState "inactive" set icon "volume-off" } #ns_log notice "C=$alarmState" return [subst { <div data-alarm='$alarmState' data-alarmseconds='\[$audio_alarm_times\]'> <adp:icon name='$icon'> <div style='display: inline-block;' id='$id'></div> </div> }] } else { return [subst { <div style='display: inline-block;' id='$id'></div> }] } } #---------------------------------------------------------------------- # Class: Answer_manager # Method: get_exam_results #---------------------------------------------------------------------- :public method get_exam_results { -obj:object,required property {default ""} } { # # Retrieve a property value from the exam statistics result # page. This page is an instance of the exam statistics workflow # stored as a child of the exam object. # # @param obj the exam object # @param property the property name # @param default default value when property is not found # set p [$obj childpage -name en:result -form inclass-exam-statistics.wf] set instance_attributes [$p instance_attributes] if {[dict exists $instance_attributes $property]} { #ns_log notice "get_exam_results <$property> returns value from " "results page: [dict get $instance_attributes $property]" return [dict get $instance_attributes $property] } #ns_log notice "get_exam_results <$property> returns default" return $default } #---------------------------------------------------------------------- # Class: Answer_manager # Method: set_exam_results #---------------------------------------------------------------------- :public method set_exam_results { -obj:object,required property value } { #ns_log notice "SES '$property' bytes [string length $value]" set p [$obj childpage -name en:result -form inclass-exam-statistics.wf] set instance_attributes [$p instance_attributes] dict set instance_attributes $property $value $p update_attribute_from_slot [$p find_slot instance_attributes] ${instance_attributes} # # cleanup of legacy values # set instance_attributes [$obj instance_attributes] foreach property_name [list $property __$property] { if {[dict exists $instance_attributes $property_name]} { ns_log notice "SES set_exam_results:" "clearing values from earlier releases for '$property_name'" "was <[dict get $instance_attributes $property_name]>" dict unset instance_attributes $property_name $obj set instance_attributes $instance_attributes #ns_log notice "FINAL IA <$instance_attributes> for item_id [$obj item_id]" "revision_id [$obj revision_id]" $obj update_attribute_from_slot [$obj find_slot instance_attributes] $instance_attributes ::xo::xotcl_object_cache flush [$obj item_id] ::xo::xotcl_object_cache flush [$obj revision_id] } } }XQL Not present: Generic, PostgreSQL, Oracle