Commit e0818d92d66cce06139aff325954f2068b473d72

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

- show remaining tries for each question.

- cosmetic changes, mostly.
BUGS.md
1 1  
2 2 # BUGS
3 3  
  4 +- max tries não avança para seguinte ao fim das tentativas.
4 5 - tabelas nas perguntas radio/checkbox não ocupam todo o espaço como em question.
5 6 - nas perguntas de código, quando erra nao se devia acrescentar mesma pergunta no fim.
6 7  
... ... @@ -27,6 +28,7 @@
27 28  
28 29 # FIXED
29 30  
  31 +- ver se ref guardada na BD é só a da pergunta ou inclui o path. -> so ref
30 32 - nao esta a guardar as respostas erradas.
31 33 - reload do topic não gera novas perguntas (alunos abusavam do reload)
32 34 - usar codemirror no textarea
... ...
README.md
... ... @@ -112,9 +112,9 @@ sudo pkg install py27-certbot # FreeBSD
112 112 Shutdown the firewall and any server running. Then run the script to generate the certificate:
113 113  
114 114 ```sh
115   -sudo pfctl -d # disable pf firewall (FreeBSD)
  115 +sudo service pf stop # disable pf firewall (FreeBSD)
116 116 sudo certbot certonly --standalone -d www.example.com
117   -sudo pfctl -e; sudo pfctl -f /etc/pf.conf # enable pf firewall
  117 +sudo service pf start # enable pf firewall
118 118 ```
119 119  
120 120 Certificates are saved under `/usr/local/etc/letsencrypt/live/www.example.com/`. Copy them to `aprendizations/certs` and change permissions to be readable:
... ... @@ -130,9 +130,10 @@ Renews can be done as follows:
130 130 ```sh
131 131 sudo service pf stop # shutdown firewall
132 132 sudo certbot renew
  133 +sudo service pf start # start firewall
133 134 ```
134 135  
135   -and then copy the `cert.pem` and `privkey.pem` files to `aprendizations/certs` directory. Change permissions as appropriate.
  136 +and then copy the `cert.pem` and `privkey.pem` files to `aprendizations/certs` directory. Change permissions and ownership as appropriate.
136 137  
137 138  
138 139 ### Testing
... ...
factory.py
... ... @@ -76,7 +76,7 @@ class QFactory(object):
76 76 # which will print a valid question in yaml format to stdout. This
77 77 # output is then yaml parsed into a dictionary `q`.
78 78 if q['type'] == 'generator':
79   - logger.debug(f' \_ Running "{q["script"]}".')
  79 + logger.debug(f' \\_ Running "{q["script"]}".')
80 80 q.setdefault('arg', '') # optional arguments will be sent to stdin
81 81 script = path.join(q['path'], q['script'])
82 82 out = run_script(script=script, stdin=q['arg'])
... ...
initdb.py
... ... @@ -73,7 +73,7 @@ def get_students_from_csv(filename):
73 73 else:
74 74 students = [{
75 75 'uid': s['N.º'],
76   - 'name': string.capwords(re.sub('\(.*\)', '', s['Nome']).strip())
  76 + 'name': string.capwords(re.sub(r'\(.*\)', '', s['Nome']).strip())
77 77 } for s in csvreader]
78 78  
79 79 return students
... ...
knowledge.py
... ... @@ -27,13 +27,13 @@ class StudentKnowledge(object):
27 27 def __init__(self, deps, state={}):
28 28 self.deps = deps # dependency graph shared among students
29 29 self.state = state # {'topic': {'level':0.5, 'date': datetime}, ...}
  30 +
30 31 self.update_topic_levels() # forgetting factor
31 32 self.topic_sequence = self.recommend_topic_sequence() # ['a', 'b', ...]
32   - self.unlock_topics()
33   -
  33 + self.unlock_topics() # whose dependencies have been done
34 34 self.current_topic = None
35 35  
36   - self.MAX_QUESTIONS = 6 # FIXME get from configuration file??
  36 + self.MAX_QUESTIONS = 6 # FIXME get from yaml configuration file??
37 37  
38 38 # ------------------------------------------------------------------------
39 39 # Updates the proficiency levels of the topics, with forgetting factor
... ... @@ -57,9 +57,10 @@ class StudentKnowledge(object):
57 57 for topic in self.topic_sequence:
58 58 if topic not in self.state: # if locked
59 59 pred = self.deps.predecessors(topic)
60   - if all(d in self.state and self.state[d]['level'] > min_level for d in pred): # and all dependencies are done
  60 + if all(d in self.state and self.state[d]['level'] > min_level for d in pred):
  61 + # all dependencies are done
61 62 self.state[topic] = {
62   - 'level': 0.0, # then unlock
  63 + 'level': 0.0, # unlocked
63 64 'date': datetime.now()
64 65 }
65 66 logger.debug(f'Unlocked "{topic}".')
... ... @@ -73,42 +74,53 @@ class StudentKnowledge(object):
73 74 def init_topic(self, topic=''):
74 75 logger.debug(f'StudentKnowledge.init_topic({topic})')
75 76  
  77 + # maybe get topic recommendation
76 78 if not topic:
77 79 topic = self.get_recommended_topic()
  80 + logger.debug(f'Recommended topic is {topic}')
78 81  
79   - # check if it's unlocked
  82 + # do not allow locked topics
80 83 if self.is_locked(topic):
81   - return False
82   -
83   - if self.current_topic is not None and topic == self.current_topic:
84   - return True
  84 + logger.debug(f'Topic {topic} is locked')
  85 + return
85 86  
  87 + # starting new topic
86 88 self.current_topic = topic
87 89  
88   - # generate question instances for current topic
89 90 factory = self.deps.node[topic]['factory']
90 91 questionlist = self.deps.node[topic]['questions']
91 92  
92 93 self.correct_answers = 0
93 94 self.wrong_answers = 0
94 95  
  96 + # select a random set of questions for this topic
95 97 size = min(self.MAX_QUESTIONS, len(questionlist)) # number of questions
96 98 questionlist = random.sample(questionlist, k=size)
97 99 logger.debug(f'Questions: {", ".join(questionlist)}')
98 100  
  101 + # generate instances of questions
99 102 self.questions = [factory[qref].generate() for qref in questionlist]
100 103 logger.debug(f'Total: {len(self.questions)} questions')
101 104  
102   - try:
103   - self.current_question = self.questions.pop(0) # FIXME crash if empty
104   - except IndexError:
105   - # self.current_question = None
106   - self.finish_topic() # FIXME if no questions, what should be done?
107   - return False
108   - else:
109   - self.current_question['start_time'] = datetime.now()
110   - return True
  105 + # get first question
  106 + self.next_question()
  107 +
  108 +
  109 + # def init_learning(self, topic=''):
  110 + # logger.debug(f'StudentKnowledge.init_learning({topic})')
  111 +
  112 + # if self.is_locked(topic):
  113 + # logger.debug(f'Topic {topic} is locked')
  114 + # return False
  115 +
  116 + # self.current_topic = topic
  117 + # factory = self.deps.node[topic]['factory']
  118 + # lesson = self.deps.node[topic]['lesson']
111 119  
  120 + # self.questions = [factory[qref].generate() for qref in lesson]
  121 + # logger.debug(f'Total: {len(self.questions)} questions')
  122 +
  123 + # self.next_question_in_lesson()
112 124  
113 125 # ------------------------------------------------------------------------
114 126 # The topic has finished and there are no more questions.
... ... @@ -127,7 +139,10 @@ class StudentKnowledge(object):
127 139  
128 140  
129 141 # ------------------------------------------------------------------------
130   - # returns the current question with correction, time and comments updated
  142 + # corrects current question with provided answer.
  143 + # implements the logic:
  144 + # - if answer ok, goes to next question
  145 + # - if wrong, counts number of tries. If exceeded, moves on.
131 146 # ------------------------------------------------------------------------
132 147 def check_answer(self, answer):
133 148 logger.debug('StudentKnowledge.check_answer()')
... ... @@ -142,21 +157,48 @@ class StudentKnowledge(object):
142 157 # if answer is correct, get next question
143 158 if grade > 0.999:
144 159 self.correct_answers += 1
145   - try:
146   - self.current_question = self.questions.pop(0) # FIXME empty?
147   - except IndexError:
148   - self.finish_topic()
149   - else:
150   - self.current_question['start_time'] = datetime.now()
  160 + self.next_question()
151 161  
152 162 # if wrong, keep same question and append a similar one at the end
153 163 else:
154 164 self.wrong_answers += 1
155   - factory = self.deps.node[self.current_topic]['factory']
156   - self.questions.append(factory[q['ref']].generate())
157   - # returns answered and corrected question
  165 +
  166 + self.current_question['tries'] -= 1
  167 +
  168 + logger.debug(f'Wrong answers = {self.wrong_answers}; Tries = {self.current_question["tries"]}')
  169 +
  170 + # append a new instance of the current question to the end and
  171 + # move to the next question
  172 + if self.current_question['tries'] <= 0:
  173 + logger.debug("Appending new instance of this question to the end")
  174 + factory = self.deps.node[self.current_topic]['factory']
  175 + self.questions.append(factory[q['ref']].generate())
  176 + self.next_question()
  177 +
  178 + # returns answered and corrected question (not new one)
158 179 return q
159 180  
  181 + # ------------------------------------------------------------------------
  182 + # Move to next question
  183 + # ------------------------------------------------------------------------
  184 + def next_question(self):
  185 + try:
  186 + self.current_question = self.questions.pop(0)
  187 + except IndexError:
  188 + self.finish_topic()
  189 + else:
  190 + self.current_question['start_time'] = datetime.now()
  191 + self.current_question['tries'] = self.current_question.get('max_tries', 3) # FIXME hardcoded 3
  192 + logger.debug(f'Next question is "{self.current_question["ref"]}"')
  193 +
  194 + # def next_question_in_lesson(self):
  195 + # try:
  196 + # self.current_question = self.questions.pop(0)
  197 + # except IndexError:
  198 + # self.current_question = None
  199 + # else:
  200 + # logger.debug(f'Next question is "{self.current_question["ref"]}"')
  201 +
160 202  
161 203 # ========================================================================
162 204 # pure functions of the state (no side effects)
... ...
learnapp.py
... ... @@ -143,23 +143,28 @@ class LearnApp(object):
143 143 s.add(a)
144 144 logger.debug(f'Saved topic "{topic}" into database')
145 145  
146   - return q['grade']
  146 +
  147 + if knowledge.get_current_question() is None:
  148 + return 'finished_topic'
  149 + if q['tries'] > 0 and q['grade'] <= 0.999:
  150 + return 'wrong'
  151 + # elif q['tries'] <= 0 and q['grade'] <= 0.999:
  152 + # return 'max_tries_exceeded'
  153 + else:
  154 + return 'new_question'
  155 + # return q['grade']
147 156  
148 157 # ------------------------------------------------------------------------
149 158 # Start new topic
150 159 # ------------------------------------------------------------------------
151 160 def start_topic(self, uid, topic):
152 161 try:
153   - ok = self.online[uid]['state'].init_topic(topic)
  162 + self.online[uid]['state'].init_topic(topic)
154 163 except KeyError as e:
155 164 logger.warning(f'User "{uid}" tried to open nonexistent topic: "{topic}"')
156 165 raise e
157 166 else:
158   - if ok:
159   - logger.info(f'User "{uid}" started "{topic}"')
160   - else:
161   - logger.warning(f'User "{uid}" restarted "{topic}"')
162   - return ok
  167 + logger.info(f'User "{uid}" started "{topic}"')
163 168  
164 169 # ------------------------------------------------------------------------
165 170 # Start new topic
... ... @@ -245,7 +250,7 @@ class LearnApp(object):
245 250 return self.online[uid]['state'].get_topic_progress()
246 251  
247 252 # ------------------------------------------------------------------------
248   - def get_student_question(self, uid):
  253 + def get_current_question(self, uid):
249 254 return self.online[uid]['state'].get_current_question() # dict
250 255  
251 256 # ------------------------------------------------------------------------
... ... @@ -316,11 +321,12 @@ def build_dependency_graph(config={}):
316 321 g.add_edges_from((d,ref) for d in attr.get('deps', []))
317 322  
318 323 fullpath = path.expanduser(path.join(prefix, ref))
  324 +
  325 + # load questions
319 326 filename = path.join(fullpath, 'questions.yaml')
320 327 loaded_questions = load_yaml(filename, default=[]) # list
321   -
322   - # if questions not in configuration then load all, preserving order
323 328 if not tnode['questions']:
  329 + # if questions not in configuration then load all, preserving order
324 330 tnode['questions'] = [q.setdefault('ref', f'{ref}:{i}') for i,q in enumerate(loaded_questions)]
325 331  
326 332 # make questions factory (without repeating same question)
... ...
serve.py
1   -#!/usr/bin/env python3.6
  1 +#!/usr/bin/env python3
2 2  
3 3 # python standard library
4 4 from os import path
... ... @@ -35,7 +35,7 @@ class WebApplication(tornado.web.Application):
35 35 (r'/question', QuestionHandler), # renders each question
36 36 (r'/topic/(.+)', TopicHandler), # page for exercising a topic
37 37 # (r'/learn/(.+)', LearnHandler), # page for learning a topic
38   - (r'/file/(.+)', FileHandler), # FIXME
  38 + (r'/file/(.+)', FileHandler), # serve files, images, etc
39 39 (r'/', RootHandler), # show list of topics
40 40 ]
41 41 settings = {
... ... @@ -139,18 +139,14 @@ class TopicHandler(BaseHandler):
139 139 uid = self.current_user
140 140  
141 141 try:
142   - ok = self.learn.start_topic(uid, topic)
  142 + self.learn.start_topic(uid, topic)
143 143 except KeyError:
144 144 self.redirect('/')
145 145 else:
146   - if ok:
147   - self.render('topic.html',
148   - uid=uid,
149   - name=self.learn.get_student_name(uid),
150   - )
151   - else:
152   - self.redirect('/')
153   -
  146 + self.render('topic.html',
  147 + uid=uid,
  148 + name=self.learn.get_student_name(uid),
  149 + )
154 150  
155 151 # class LearnHandler(BaseHandler):
156 152 # @tornado.web.authenticated
... ... @@ -225,9 +221,6 @@ class QuestionHandler(BaseHandler):
225 221 'information': 'question-information.html',
226 222 'info': 'question-information.html',
227 223 'success': 'question-success.html',
228   - # 'warning': '', FIXME
229   - # 'warn': '', FIXME
230   - # 'alert': '', FIXME
231 224 }
232 225  
233 226 # --- get question to render
... ... @@ -236,16 +229,20 @@ class QuestionHandler(BaseHandler):
236 229 logging.debug('QuestionHandler.get()')
237 230 user = self.current_user
238 231  
239   - question = self.learn.get_student_question(user)
240   - template = self.templates[question['type']]
241   - question_html = self.render_string(template, question=question, md=md_to_html)
  232 + question = self.learn.get_current_question(user)
  233 +
  234 + question_html = self.render_string(self.templates[question['type']],
  235 + question=question, md=md_to_html)
  236 +
  237 + print(question['tries'])
242 238  
243 239 self.write({
244 240 'method': 'new_question',
245 241 'params': {
246 242 'question': tornado.escape.to_unicode(question_html),
247   - 'progress': self.learn.get_student_progress(user) ,
248   - }
  243 + 'progress': self.learn.get_student_progress(user),
  244 + 'tries': question['tries'],
  245 + },
249 246 })
250 247  
251 248 # --- post answer, returns what to do next: shake, new_question, finished
... ... @@ -253,13 +250,11 @@ class QuestionHandler(BaseHandler):
253 250 async def post(self):
254 251 logging.debug('QuestionHandler.post()')
255 252 user = self.current_user
256   -
257   - # check answer and get next question (same, new or None)
258 253 answer = self.get_body_arguments('answer') # list
259 254  
260   - # answers returned in a list. fix depending on question type
  255 + # answers are returned in a list. fix depending on question type
261 256 qtype = self.learn.get_student_question_type(user)
262   - if qtype in ('success', 'information', 'info'): # FIXME unused?
  257 + if qtype in ('success', 'information', 'info'):
263 258 answer = None
264 259 elif qtype == 'radio' and not answer:
265 260 answer = None
... ... @@ -267,20 +262,25 @@ class QuestionHandler(BaseHandler):
267 262 answer = answer[0]
268 263  
269 264 # check answer in another thread (nonblocking)
270   - loop = asyncio.get_event_loop()
271   - grade = await loop.run_in_executor(None, self.learn.check_answer, user, answer)
272   - question = self.learn.get_student_question(user)
  265 + action = await asyncio.get_event_loop().run_in_executor(None,
  266 + self.learn.check_answer, user, answer)
  267 +
  268 + # get next question (same, new or None)
  269 + question = self.learn.get_current_question(user)
273 270  
274   - if grade <= 0.999: # wrong answer
275   - comments_html = self.render_string('comments.html', comments=question['comments'], md=md_to_html)
  271 + if action == 'wrong':
  272 + comments_html = self.render_string('comments.html',
  273 + comments=question['comments'], md=md_to_html)
276 274 self.write({
277   - 'method': 'shake',
  275 + 'method': action,
278 276 'params': {
279 277 'progress': self.learn.get_student_progress(user),
280 278 'comments': tornado.escape.to_unicode(comments_html), # FIXME
  279 + 'tries': question['tries'],
281 280 }
282 281 })
283   - elif question is None: # right answer, finished topic
  282 +
  283 + elif action == 'finished_topic': # right answer, finished topic
284 284 finished_topic_html = self.render_string('finished_topic.html')
285 285 self.write({
286 286 'method': 'finished_topic',
... ... @@ -288,18 +288,23 @@ class QuestionHandler(BaseHandler):
288 288 'question': tornado.escape.to_unicode(finished_topic_html)
289 289 }
290 290 })
291   - else: # right answer, get next question in the topic
  291 +
  292 + elif action == 'new_question': # get next question in the topic
292 293 template = self.templates[question['type']]
293   - question_html = self.render_string(
294   - template, question=question, md=md_to_html)
  294 + question_html = self.render_string(template,
  295 + question=question, md=md_to_html)
295 296 self.write({
296 297 'method': 'new_question',
297 298 'params': {
298 299 'question': tornado.escape.to_unicode(question_html),
299 300 'progress': self.learn.get_student_progress(user),
  301 + 'tries': question['tries'],
300 302 }
301 303 })
302 304  
  305 + else:
  306 + logger.error(f'Unknown action {action}')
  307 +
303 308  
304 309 # -------------------------------------------------------------------------
305 310 def signal_handler(signal, frame):
... ... @@ -332,6 +337,7 @@ def main():
332 337 except:
333 338 print('An error ocurred while setting up the logging system.')
334 339 sys.exit(1)
  340 +
335 341 logging.info('====================================================')
336 342  
337 343 # --- start application
... ...
static/fontawesome
1   -libs/fontawesome-free-5.0.13/svg-with-js/js/
2 1 \ No newline at end of file
  2 +libs/fontawesome-free-5.1.1-web/
3 3 \ No newline at end of file
... ...
static/js/maintopics.js
1 1 function notify(msg) {
2   - $("#notifications").html(msg);
3   - $("#notifications").fadeIn(250).delay(3000).fadeOut(500);
  2 + $("#notifications").html(msg);
  3 + $("#notifications").fadeIn(250).delay(3000).fadeOut(500);
4 4 }
5 5  
6 6 function getCookie(name) {
7   - var r = document.cookie.match("\\b" + name + "=([^;]*)\\b");
8   - return r ? r[1] : undefined;
  7 + var r = document.cookie.match("\\b" + name + "=([^;]*)\\b");
  8 + return r ? r[1] : undefined;
9 9 }
10 10  
11 11 function change_password() {
12   - var token = getCookie('_xsrf');
13   - $.ajax({
14   - type: "POST",
15   - url: "/change_password",
16   - headers: {'X-XSRFToken' : token },
17   - data: {
18   - "new_password": $("#new_password").val(),
19   - },
20   - dataType: "json",
21   - success: function(r) {
22   - notify(r['msg']);
23   - },
24   - error: function(r) {
25   - notify(r['msg']);
26   - },
27   - });
  12 + var token = getCookie('_xsrf');
  13 + $.ajax({
  14 + type: "POST",
  15 + url: "/change_password",
  16 + headers: {'X-XSRFToken': token},
  17 + data: {
  18 + "new_password": $("#new_password").val(),
  19 + },
  20 + dataType: "json",
  21 + success: function(r) {
  22 + notify(r['msg']);
  23 + },
  24 + error: function(r) {
  25 + notify(r['msg']);
  26 + },
  27 + });
28 28 }
29 29  
30 30 $(document).ready(function() {
31   - $("#change_password").click(change_password);
  31 + $("#change_password").click(change_password);
32 32 });
... ...
static/js/topic.js
... ... @@ -7,12 +7,15 @@ $.fn.extend({
7 7 }
8 8 });
9 9  
10   -// Process response given by the server
  10 +// updates question according to the response given by the server
11 11 function updateQuestion(response){
  12 +
12 13 switch (response["method"]) {
13 14 case "new_question":
14 15 $("#question_div").html(response["params"]["question"]);
15 16 $("#comments").html("");
  17 + $("#tries").html(response["params"]["tries"]);
  18 +
16 19 $('#topic_progress').css('width', (100*response["params"]["progress"])+'%').attr('aria-valuenow', 100*response["params"]["progress"]);
17 20  
18 21 MathJax.Hub.Queue(["Typeset",MathJax.Hub,"question_div"]);
... ... @@ -28,10 +31,11 @@ function updateQuestion(response){
28 31 $('#question_div').animateCSS('bounceInDown');
29 32 break;
30 33  
31   - case "shake":
  34 + case "wrong":
32 35 $('#topic_progress').css('width', (100*response["params"]["progress"])+'%').attr('aria-valuenow', 100*response["params"]["progress"]);
33 36 $('#question_div').animateCSS('shake');
34 37 $('#comments').html(response['params']['comments']);
  38 + $("#tries").html(response["params"]["tries"]);
35 39 MathJax.Hub.Queue(["Typeset",MathJax.Hub,"#comments"]);
36 40 break;
37 41  
... ... @@ -57,7 +61,8 @@ function getQuestion() {
57 61 }
58 62  
59 63 // Send answer and receive a response.
60   -// The response can be a new_question or a shake if the answer is wrong.
  64 +// The response can be a new_question or a shake if the answer is wrong, which
  65 +// is then passed to updateQuestion()
61 66 function postQuestion() {
62 67 if (typeof editor === 'object')
63 68 editor.save();
... ...
templates/login.html
... ... @@ -14,7 +14,7 @@
14 14 <!-- Scripts -->
15 15 <script defer src="/static/libs/jquery-3.3.1.min.js"></script>
16 16 <script defer src="/static/popper/popper.min.js"></script>
17   - <script defer src="/static/fontawesome/fontawesome-all.min.js"></script>
  17 + <script defer src="/static/fontawesome/js/all.js"></script>
18 18 <script defer src="/static/bootstrap/js/bootstrap.min.js"></script>
19 19 <script defer src="/static/MDB/js/mdb.min.js"></script>
20 20  
... ...
templates/maintopics-table.html
... ... @@ -18,7 +18,7 @@
18 18 <!-- Scripts -->
19 19 <script defer src="/static/libs/jquery-3.3.1.min.js"></script>
20 20 <script defer src="/static/popper/popper.min.js"></script>
21   - <script defer src="/static/fontawesome/fontawesome-all.min.js"></script>
  21 + <script defer src="/static/fontawesome/js/all.js"></script>
22 22 <script defer src="/static/bootstrap/js/bootstrap.min.js"></script>
23 23 <script defer src="/static/MDB/js/mdb.min.js"></script>
24 24 <script defer src="/static/js/maintopics.js"></script>
... ...
templates/question.html
... ... @@ -7,3 +7,5 @@
7 7 </div>
8 8  
9 9 {% block answer %}{% end %}
  10 +
  11 +<p class="text-right font-italic">(<span id="tries"></span> tentativas)</p>
... ...
templates/topic.html
... ... @@ -29,7 +29,7 @@
29 29 <!-- Scripts -->
30 30 <script defer src="/static/libs/jquery-3.3.1.min.js"></script>
31 31 <script defer src="/static/popper/popper.min.js"></script>
32   - <script defer src="/static/fontawesome/fontawesome-all.min.js"></script>
  32 + <script defer src="/static/fontawesome/js/all.js"></script>
33 33 <script defer src="/static/bootstrap/js/bootstrap.min.js"></script>
34 34 <script defer src="/static/MDB/js/mdb.min.js"></script>
35 35 <script defer src="/static/codemirror/codemirror.js"></script>
... ... @@ -86,6 +86,8 @@
86 86  
87 87 <div id="comments"></div>
88 88  
  89 +
  90 +
89 91 </div>
90 92 </div>
91 93  
... ...