Commit c37266817994065159840b680208d2f3493af2c7

Authored by Miguel Barão
1 parent 094837eb
Exists in master and in 1 other branch dev

- adds button to /admin page to download CSV file with grades.

- button to close the test after the grades.
- replace (scale_points, scale_min, scale_max) by "scale: [0, 20]"
- updates demo.yaml
- correct-question.py changed to count the number of correct colors
- catch exception during loading test configuration
- fix exception handling of sqlalchemy
- change default logging to include seconds
- update BUGS.md
BUGS.md
... ... @@ -2,8 +2,6 @@
2 2 # BUGS
3 3  
4 4 - quando scale_max não é 20, as cores das barras continuam a reflectir a escala 0,20
5   -- servidor ntpd para configurar a data/hora dos portateis dell
6   -- link na pagina com a nota para voltar ao principio.
7 5 - CRITICAL se answer for `i<n` a revisão de provas mostra apenas i (interpreta `<` como tag?)
8 6 - na pagina grade.html as barras estao normalizadas para os limites scale_min e max do teste actual e nao do realizado.
9 7 - codigo `hello world` nao esta a preservar o whitespace. O renderer de markdown gera a tag <code> que não preserva whitespace. Necessario adicionar <pre>.
... ... @@ -22,9 +20,11 @@ ou usar push (websockets?)
22 20  
23 21 # TODO
24 22  
  23 +- servidor ntpd para configurar a data/hora dos portateis dell
  24 +- autorização dada, mas teste não disponível até que seja dada ordem para começar.
  25 +- alunos com necessidades especiais nao podem ter autosubmit. ter um autosubmit_exceptions: ['123', '456']
25 26 - mostrar unfocus e window area em /admin
26 27 - testar as perguntas todas no início do teste.
27   -- test: mostrar duração do teste com progressbar no navbar.
28 28 - submissao fazer um post ajax?
29 29 - adicionar opcao para eliminar um teste em curso.
30 30 - enviar resposta de cada pergunta individualmente.
... ... @@ -63,6 +63,9 @@ ou usar push (websockets?)
63 63  
64 64 # FIXED
65 65  
  66 +- link na pagina com a nota para voltar ao principio.
  67 +- default logger config mostrar horas com segundos
  68 +- test: mostrar duração do teste com progressbar no navbar.
66 69 - lidar com eventos unfocus.
67 70 - servidor nao esta a lidar com eventos resize.
68 71 - sock.bind(sockaddr) OSError: [Errno 48] Address already in use
... ...
demo/demo.yaml
1 1 ---
2 2 # ============================================================================
3   -# The test reference should be a unique identifier. It is saved in the database
4   -# so that queries can be done in the terminal like
5   -# sqlite3 students.db "select * from tests where ref='demo'"
  3 +# Unique identifier of the test.
  4 +# Database queries can be done in the terminal with
  5 +# sqlite3 students.db "select * from tests where ref='tutorial'"
6 6 ref: tutorial
7 7  
8   -# Database with student credentials and grades of all questions and tests done
9   -# The database is an sqlite3 file generate with the command initdb
  8 +# Database file that includes student credentials, tests and questions grades.
  9 +# It's a sqlite3 database generated with the command 'initdb'
10 10 database: students.db
11 11  
12   -# Directory where the tests including submitted answers and grades are stored.
13   -# The submitted tests and their corrections can be reviewed later.
  12 +# Directory where the submitted and corrected test are stored for later review.
14 13 answers_dir: ans
15 14  
16 15 # --- optional settings: -----------------------------------------------------
17 16  
18   -# You may wish to refer the course, year or kind of test
  17 +# Title of this test, e.g. course name, year or test number
19 18 # (default: '')
20 19 title: Teste de demonstração (tutorial)
21 20  
22 21 # Duration in minutes.
23 22 # (0 or undefined means infinite time)
24 23 duration: 2
25   -autosubmit: true
26 24  
27   -# Show points for each question, scale 0-20.
  25 +# Automatic test submission after the timeout 'duration'?
28 26 # (default: false)
  27 +autosubmit: true
  28 +
  29 +# Show points for each question (min and max).
  30 +# (default: true)
29 31 show_points: true
30 32  
31   -# scale final grade to the interval [scale_min, scale_max]
32   -# (default: scale to [0,20])
33   -scale_max: 20
34   -scale_min: 0
35   -scale_points: true
  33 +# scale final grade to an interval, e.g. [0, 20], keeping the relative weight
  34 +# of the points declared in the questions below.
  35 +# (default: no scaling, just use question points)
  36 +scale: [0, 5]
  37 +
  38 +# DEPRECATED: old version, to be removed
  39 +# scale_max: 20
  40 +# scale_min: 0
  41 +# scale_points: true
36 42  
37 43 # ----------------------------------------------------------------------------
38 44 # Base path applied to the questions files and all the scripts
... ... @@ -50,7 +56,7 @@ files:
50 56 # The order is preserved.
51 57 # There are several ways to define each question (explained below).
52 58 questions:
53   - - tut-test
  59 + - ref: tut-test
54 60 - tut-questions
55 61  
56 62 - tut-radio
... ...
demo/questions/correct/correct-question.py
1 1 #!/usr/bin/env python3
2 2  
  3 +'''
  4 +Demonstação de um script de correcção
  5 +'''
  6 +
3 7 import re
4 8 import sys
5 9  
6   -msg1 = '''---
7   -grade: 1.0
8   -comments: Muito bem!
9   -'''
  10 +s = sys.stdin.read()
10 11  
11   -msg0 = '''
12   -grade: 0.0
13   -comments: A resposta correcta é "red green blue".
14   -'''
  12 +ans = set(re.findall(r'[\w]+', s.lower())) # get words in lowercase
  13 +rgb = set(['red', 'green', 'blue']) # the correct answer
15 14  
16   -s = sys.stdin.read()
  15 +# a nota é o número de cores certas menos o número de erradas
  16 +grade = max(0,
  17 + len(rgb.intersection(ans)) - len(ans.difference(rgb))) / 3
17 18  
18   -answer = set(re.findall(r'[\w]+', s.lower())) # get words in lowercase
19   -rgb_colors = set(['red', 'green', 'blue']) # the correct answer
  19 +if ans == rgb:
  20 + print('---\n'
  21 + 'grade: 1.0\n'
  22 + 'comments: Muito bem!')
20 23  
21   -if answer == rgb_colors:
22   - print(msg1)
23 24 else:
24   - print(msg0)
  25 + print('---\n'
  26 + f'grade: {grade}\n'
  27 + 'comments: A resposta correcta é "red green blue".')
... ...
demo/questions/generators/generate-question.py
... ... @@ -18,10 +18,11 @@ print(f&quot;&quot;&quot;---
18 18 type: text
19 19 title: Geradores de perguntas
20 20 text: |
21   - Existe a possibilidade da pergunta ser gerada por um programa externo.
22   - Este programa deve escrever no `stdout` uma pergunta em formato `yaml` como
23   - os anteriores. Pode também receber argumentos para parametrizar a geração da
24   - pergunta. Aqui está um exemplo de uma pergunta gerada por um script python:
  21 + Existe a possibilidade da pergunta ser gerada por um programa externo. Este
  22 + programa deve escrever no `stdout` uma pergunta em formato `yaml` como nos
  23 + exemplos anteriores. Pode também receber argumentos para parametrizar a
  24 + geração da pergunta. Aqui está um exemplo de uma pergunta gerada por um
  25 + script python:
25 26  
26 27 ```python
27 28 #!/usr/bin/env python3
... ...
perguntations/__init__.py
... ... @@ -32,7 +32,7 @@ proof of submission and for review.
32 32 '''
33 33  
34 34 APP_NAME = 'perguntations'
35   -APP_VERSION = '2020.04.dev5'
  35 +APP_VERSION = '2020.05.dev1'
36 36 APP_DESCRIPTION = __doc__
37 37  
38 38 __author__ = 'Miguel Barão'
... ...
perguntations/app.py
... ... @@ -6,13 +6,15 @@ Main application module
6 6 # python standard libraries
7 7 import asyncio
8 8 from contextlib import contextmanager # `with` statement in db sessions
  9 +import csv
  10 +import io
9 11 import json
10 12 import logging
11 13 from os import path
12 14  
13   -# user installed packages
  15 +# installed packages
14 16 import bcrypt
15   -from sqlalchemy import create_engine
  17 +from sqlalchemy import create_engine, exc
16 18 from sqlalchemy.orm import sessionmaker
17 19  
18 20 # this project
... ... @@ -73,9 +75,10 @@ class App():
73 75 try:
74 76 yield session
75 77 session.commit()
76   - except Exception:
  78 + except exc.SQLAlchemyError:
77 79 logger.error('DB rollback!!!')
78 80 session.rollback()
  81 + raise
79 82 finally:
80 83 session.close()
81 84  
... ... @@ -85,7 +88,12 @@ class App():
85 88 self.allowed = set([]) # '0' is hardcoded to allowed elsewhere
86 89  
87 90 logger.info('Loading test configuration "%s".', conf["testfile"])
88   - testconf = load_yaml(conf['testfile'])
  91 + try:
  92 + testconf = load_yaml(conf['testfile'])
  93 + except Exception as exc:
  94 + logger.critical('Error loading test configuration YAML.')
  95 + raise AppException(exc)
  96 +
89 97 testconf.update(conf) # command line options override configuration
90 98  
91 99 # start test factory
... ... @@ -258,7 +266,7 @@ class App():
258 266  
259 267 # ------------------------------------------------------------------------
260 268 def event_test(self, uid, cmd, value):
261   - '''handle browser events the occur during the test'''
  269 + '''handles browser events the occur during the test'''
262 270 if cmd == 'focus':
263 271 logger.info('Student %s: focus %s', uid, value)
264 272 elif cmd == 'size':
... ... @@ -273,6 +281,21 @@ class App():
273 281 # def get_student_name(self, uid):
274 282 # return self.online[uid]['student']['name']
275 283  
  284 + def get_test_csv(self):
  285 + '''generates a CSV with the grades of the test'''
  286 + with self.db_session() as sess:
  287 + grades = sess.query(Test.student_id, Test.grade,
  288 + Test.starttime, Test.finishtime)\
  289 + .filter(Test.ref == self.testfactory['ref'])\
  290 + .order_by(Test.student_id)\
  291 + .all()
  292 +
  293 + csvstr = io.StringIO()
  294 + writer = csv.writer(csvstr, delimiter=';', quoting=csv.QUOTE_ALL)
  295 + writer.writerow(('Número', 'Nota', 'Início', 'Fim'))
  296 + writer.writerows(grades)
  297 + return csvstr.getvalue()
  298 +
276 299 def get_student_test(self, uid, default=None):
277 300 '''get test from online student'''
278 301 return self.online[uid].get('test', default)
... ... @@ -320,11 +343,7 @@ class App():
320 343 .get('start_time', ''),
321 344 'password_defined': pw != '',
322 345 'grades': self.get_student_grades_from_test(
323   - uid,
324   - self.testfactory['ref']
325   - ),
326   -
327   - # 'focus': self.online.get(uid, {}).get('student', {}).get('focus', True), # FIXME
  346 + uid, self.testfactory['ref'])
328 347 } for uid, name, pw in self.get_all_students()]
329 348  
330 349 # def get_allowed_students(self):
... ... @@ -363,7 +382,7 @@ class App():
363 382 try:
364 383 with self.db_session() as sess:
365 384 sess.add(Student(id=uid, name=name, password=''))
366   - except Exception:
367   - logger.error('Insert failed: student %s already exists.', uid)
  385 + except exc.SQLAlchemyError:
  386 + logger.error('Insert failed: student %s already exists?', uid)
368 387 else:
369 388 logger.info('New student inserted: %s, %s', uid, name)
... ...
perguntations/main.py
... ... @@ -74,7 +74,7 @@ def get_logger_config(debug=False):
74 74 'formatters': {
75 75 'standard': {
76 76 'format': '%(asctime)s %(levelname)-8s %(message)s',
77   - 'datefmt': '%H:%M',
  77 + 'datefmt': '%H:%M:%S',
78 78 },
79 79 },
80 80 'handlers': {
... ...
perguntations/serve.py
... ... @@ -42,6 +42,7 @@ class WebApplication(tornado.web.Application):
42 42 (r'/file', FileHandler),
43 43 # (r'/root', MainHandler), # FIXME
44 44 # (r'/ws', AdminSocketHandler),
  45 + (r'/adminwebservice', AdminWebservice),
45 46 (r'/studentwebservice', StudentWebservice),
46 47 (r'/', RootHandler),
47 48 ]
... ... @@ -62,7 +63,10 @@ class WebApplication(tornado.web.Application):
62 63 # ----------------------------------------------------------------------------
63 64 def admin_only(func):
64 65 '''
65   - Decorator used to restrict access to the administrator
  66 + Decorator used to restrict access to the administrator. For example:
  67 +
  68 + @admin_only()
  69 + def get(self): ...
66 70 '''
67 71 @functools.wraps(func)
68 72 async def wrapper(self, *args, **kwargs):
... ... @@ -75,14 +79,14 @@ def admin_only(func):
75 79 # ----------------------------------------------------------------------------
76 80 class BaseHandler(tornado.web.RequestHandler):
77 81 '''
78   - Base handler. Other handlers will inherit this one.
  82 + Handlers should inherit this one instead of tornado.web.RequestHandler.
  83 + It automatically gets the user cookie, which is required to identify the
  84 + user in most handlers.
79 85 '''
80 86  
81 87 @property
82 88 def testapp(self):
83   - '''
84   - simplifies access to the application
85   - '''
  89 + '''simplifies access to the application'''
86 90 return self.application.testapp
87 91  
88 92 def get_current_user(self):
... ... @@ -155,8 +159,8 @@ class BaseHandler(tornado.web.RequestHandler):
155 159  
156 160 class StudentWebservice(BaseHandler):
157 161 '''
158   - Receive ajax from students in the test:
159   - focus, unfocus
  162 + Receive ajax from students in the test in response from focus, unfocus and
  163 + resize events.
160 164 '''
161 165  
162 166 @tornado.web.authenticated
... ... @@ -167,6 +171,25 @@ class StudentWebservice(BaseHandler):
167 171 value = json.loads(self.get_body_argument('value', None))
168 172 self.testapp.event_test(uid, cmd, value)
169 173  
  174 +
  175 +# ----------------------------------------------------------------------------
  176 +class AdminWebservice(BaseHandler):
  177 + '''
  178 + Receive ajax requests from admin
  179 + '''
  180 +
  181 + @tornado.web.authenticated
  182 + @admin_only
  183 + async def get(self):
  184 + '''admin webservices that do not change state'''
  185 + cmd = self.get_query_argument('cmd')
  186 + if cmd == 'testcsv':
  187 + self.set_header('Content-Type', 'text/csv')
  188 + self.set_header('content-Disposition',
  189 + 'attachment; filename=notas.csv')
  190 + self.write(self.testapp.get_test_csv())
  191 + await self.flush()
  192 +
170 193 # ----------------------------------------------------------------------------
171 194 class AdminHandler(BaseHandler):
172 195 '''Handle /admin'''
... ...
perguntations/static/js/admin.js
... ... @@ -14,35 +14,27 @@ jQuery.postJSON = function(url, args) {
14 14 $(document).ready(function() {
15 15 function button_handlers() {
16 16 // button handlers (runs once)
17   - $("#allow_all").click(
18   - function() {
19   - $(":checkbox").prop("checked", true).trigger('change');
20   - }
21   - );
22   - $("#deny_all").click(
23   - function() {
24   - $(":checkbox").prop("checked", false).trigger('change');
25   - }
26   - );
27   - $("#reset_password").click(
28   - function () {
29   - $.postJSON("/admin", {
30   - "cmd": "reset_password",
31   - "value": $("#reset_number").val()
32   - });
33   - }
34   - );
35   - $("#inserir_novo_aluno").click(
36   - function () {
37   - $.postJSON("/admin", {
38   - "cmd": "insert_student",
39   - "value": JSON.stringify({
40   - "number": $("#novo_numero").val(),
41   - "name": $("#novo_nome").val()
42   - })
43   - });
44   - }
45   - );
  17 + $("#allow_all").click(function() {
  18 + $(":checkbox").prop("checked", true).trigger('change');
  19 + });
  20 + $("#deny_all").click(function() {
  21 + $(":checkbox").prop("checked", false).trigger('change');
  22 + });
  23 + $("#reset_password").click(function () {
  24 + $.postJSON("/admin", {
  25 + "cmd": "reset_password",
  26 + "value": $("#reset_number").val()
  27 + });
  28 + });
  29 + $("#inserir_novo_aluno").click(function () {
  30 + $.postJSON("/admin", {
  31 + "cmd": "insert_student",
  32 + "value": JSON.stringify({
  33 + "number": $("#novo_numero").val(),
  34 + "name": $("#novo_nome").val()
  35 + })
  36 + });
  37 + });
46 38 // authorization checkboxes in the students_table:
47 39 $("tbody", "#students_table").on("change", "input", autorizeStudent);
48 40 }
... ...
perguntations/templates/admin.html
... ... @@ -68,24 +68,29 @@
68 68 <div class="container-fluid">
69 69  
70 70 <div class="jumbotron">
71   - <h3 id="title"></h3>
72   - Ref: <span id="ref"></span><br>
73   - Enunciado: <span id="filename"></span><br>
74   - Base de dados: <span id="database"></span><br>
75   - Testes submetidos: <span id="answers_dir"></span>
  71 + <h3 id="title"></h3>
  72 + <p>
  73 + Referência: <code id="ref">--</code><br>
  74 + Ficheiro de configuração do teste: <code id="filename">--</code><br>
  75 + Testes em formato JSON no directório: <code id="answers_dir">--</code><br>
  76 + Base de dados: <code id="database">--</code><br>
  77 + </p>
  78 + <p>
  79 + <a href="/adminwebservice?cmd=testcsv" class="btn btn-primary">Obter CSV com as notas</a>
  80 + </p>
76 81 </div> <!-- jumbotron -->
77 82  
78 83 <table class="table table-sm table-striped" style="width:100%" id="students_table">
79   - <thead class="thead thead-light">
80   - <tr>
81   - <th>#</th>
82   - <th>Ok</th>
83   - <th>Número</th>
84   - <th>Nome</th>
85   - <th>Estado</th>
86   - <th>Nota</th>
87   - </tr>
88   - </thead>
  84 + <thead class="thead thead-light">
  85 + <tr>
  86 + <th>#</th>
  87 + <th>Ok</th>
  88 + <th>Número</th>
  89 + <th>Nome</th>
  90 + <th>Estado</th>
  91 + <th>Nota</th>
  92 + </tr>
  93 + </thead>
89 94 </table>
90 95  
91 96 </div> <!-- container -->
... ...
perguntations/templates/grade.html
... ... @@ -43,11 +43,11 @@
43 43 {% if t['state'] == 'FINISHED' %}
44 44 <h1>Resultado:
45 45 <strong>{{ f'{round(t["grade"], 1)}' }}</strong>
46   - valores na escala de {{t['scale_min']}} a {{t['scale_max']}}.
  46 + valores na escala de {{t['scale'][0]}} a {{t['scale'][1]}}.
47 47 </h1>
48   - <p>O seu teste foi registado.<br>
49   - Pode fechar o browser e desligar o computador.</p>
50   - {% if t['grade'] - t['scale_min'] >= 0.75*(t['scale_max'] - t['scale_min']) %}
  48 + <p>O seu teste foi entregue e está registado.</p>
  49 + <p><a href="/" class="btn btn-primary btn-lg active" role="button">Clique aqui para sair do teste</a></p>
  50 + {% if t['grade'] - t['scale'][0] >= 0.75*(t['scale'][1] - t['scale'][0]) %}
51 51 <i class="fas fa-thumbs-up fa-5x text-success" aria-hidden="true"></i>
52 52 {% end %}
53 53 {% elif t['state'] == 'QUIT' %}
... ... @@ -78,19 +78,19 @@
78 78 <td> <!-- progress column -->
79 79 <div class="progress" style="height: 20px;">
80 80 <div class="progress-bar
81   - {% if g[1] - t['scale_min'] < 0.5*(t['scale_max'] - t['scale_min']) %}
  81 + {% if g[1] - t['scale'][0] < 0.5*(t['scale'][1] - t['scale'][0]) %}
82 82 bg-danger
83   - {% elif g[1] - t['scale_min'] < 0.75*(t['scale_max'] - t['scale_min']) %}
  83 + {% elif g[1] - t['scale'][0] < 0.75*(t['scale'][1] - t['scale'][0]) %}
84 84 bg-warning
85 85 {% else %}
86 86 bg-success
87 87 {% end %}
88 88 "
89 89 role="progressbar"
90   - aria-valuenow="{{ round(100*(g[1] - t['scale_min'])/(t['scale_max'] - t['scale_min'])) }}"
  90 + aria-valuenow="{{ 100*(g[1] - t['scale'][0])/(t['scale'][1] - t['scale'][0]) }}"
91 91 aria-valuemin="0"
92 92 aria-valuemax="100"
93   - style="min-width: 2em; width: {{ round(100*(g[1]-t['scale_min'])/(t['scale_max']-t['scale_min'])) }}%;"
  93 + style="min-width: 2em; width: {{ 100*(g[1]-t['scale'][0])/(t['scale'][1]-t['scale'][0]) }}%;"
94 94 >
95 95  
96 96 {{ str(round(g[1], 1)) }}
... ...
perguntations/test.py
... ... @@ -42,10 +42,8 @@ class TestFactory(dict):
42 42 # --- set test defaults and then use given configuration
43 43 super().__init__({ # defaults
44 44 'title': '',
45   - 'show_points': False,
46   - 'scale_points': True,
47   - 'scale_max': 20.0,
48   - 'scale_min': 0.0,
  45 + 'show_points': True,
  46 + 'scale': None, # or [0, 20]
49 47 'duration': 0, # 0=infinite
50 48 'autosubmit': False,
51 49 'debug': False,
... ... @@ -197,11 +195,12 @@ class TestFactory(dict):
197 195  
198 196 def check_grade_scaling(self):
199 197 '''Just informs the scale limits'''
200   - if self['scale_points']:
201   - smin, smax = self["scale_min"], self["scale_max"]
202   - logger.info('Grades will be scaled to [%g, %g]', smin, smax)
203   - else:
204   - logger.info('Grades are not being scaled.')
  198 + if 'scale_points' in self:
  199 + msg = ('*** DEPRECATION WARNING: *** scale_points, scale_min, '
  200 + 'scale_max were replaced by "scale: [min, max]".')
  201 + logger.warning(msg)
  202 + self['scale'] = [self['scale_min'], self['scale_max']]
  203 +
205 204  
206 205 # ------------------------------------------------------------------------
207 206 def sanity_checks(self):
... ... @@ -254,25 +253,32 @@ class TestFactory(dict):
254 253  
255 254 test.append(question)
256 255  
257   - # normalize question points to scale
258   - if self['scale_points']:
259   - total_points = sum(q['points'] for q in test)
260   - if total_points == 0:
261   - logger.warning('Can\'t scale, total points in the test is 0!')
262   - else:
263   - scale = (self['scale_max'] - self['scale_min']) / total_points
  256 + # setup scale
  257 + total_points = sum(q['points'] for q in test)
  258 +
  259 + if total_points > 0:
  260 + # normalize question points to scale
  261 + if self['scale'] is not None:
  262 + scale_min, scale_max = self['scale']
264 263 for question in test:
265   - question['points'] *= scale
  264 + question['points'] *= (scale_max - scale_min) / total_points
  265 + else:
  266 + self['scale'] = [0, total_points]
  267 + else:
  268 + logger.warning('Total points is **ZERO**.')
  269 + if self['scale'] is None:
  270 + self['scale'] = [0, 20]
266 271  
267 272 if nerr > 0:
268 273 logger.error('%s errors found!', nerr)
269 274  
  275 + # these will be copied to the test instance
270 276 inherit = {'ref', 'title', 'database', 'answers_dir',
271 277 'questions_dir', 'files',
272 278 'duration', 'autosubmit',
273   - 'scale_min', 'scale_max', 'show_points',
  279 + 'scale', 'show_points',
274 280 'show_ref', 'debug', }
275   - # NOT INCLUDED: scale_points, testfile, allow_all, review
  281 + # NOT INCLUDED: testfile, allow_all, review
276 282  
277 283 return Test({
278 284 **{'student': student, 'questions': test},
... ... @@ -318,6 +324,7 @@ class Test(dict):
318 324 '''Corrects all the answers of the test and computes the final grade'''
319 325 self['finish_time'] = datetime.now()
320 326 self['state'] = 'FINISHED'
  327 +
321 328 grade = 0.0
322 329 for question in self['questions']:
323 330 await question.correct_async()
... ... @@ -325,8 +332,8 @@ class Test(dict):
325 332 logger.debug('Correcting %30s: %3g%%',
326 333 question["ref"], question["grade"]*100)
327 334  
328   - # truncate to avoid negative grade and adjust scale
329   - self['grade'] = max(0.0, grade) + self['scale_min']
  335 + # truncate to avoid negative final grade and adjust scale
  336 + self['grade'] = max(0.0, grade) + self['scale'][0]
330 337 return self['grade']
331 338  
332 339 # ------------------------------------------------------------------------
... ...