Class ::xowf::test_item::Answer_manager (public)

 ::nx::Class ::xowf::test_item::Answer_manager[i]

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 {$stopbreak
        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 &middot; $fullName &middot; $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;'>&nbsp;[_ 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
[ hide source ] | [ make this the default ]
Show another procedure: