Commit c68097b2c536277f8f68a362aed8ea909ec77931

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

- /admin functional but table sorting is unfinished.

- /review almost there, total_points still undefined.
- images not working.
@@ -43,17 +43,17 @@ class App(object): @@ -43,17 +43,17 @@ class App(object):
43 43
44 # connect to database and check registered students 44 # connect to database and check registered students
45 dbfile = path.expanduser(self.testfactory['database']) 45 dbfile = path.expanduser(self.testfactory['database'])
46 - engine = create_engine('sqlite:///{}'.format(dbfile), echo=False) 46 + engine = create_engine(f'sqlite:///{dbfile}', echo=False)
47 self.Session = scoped_session(sessionmaker(bind=engine)) 47 self.Session = scoped_session(sessionmaker(bind=engine))
48 48
49 try: 49 try:
50 with self.db_session() as s: 50 with self.db_session() as s:
51 n = s.query(Student).filter(Student.id != '0').count() 51 n = s.query(Student).filter(Student.id != '0').count()
52 except Exception as e: 52 except Exception as e:
53 - logger.critical('Database not usable {}.'.format(self.testfactory['database'])) 53 + logger.critical(f'Database not usable {self.testfactory["database"]}.')
54 raise e 54 raise e
55 else: 55 else:
56 - logger.info('Database has {} students registered.'.format(n)) 56 + logger.info(f'Database has {n} students registered.')
57 57
58 # command line option --allow-all 58 # command line option --allow-all
59 if conf['allow_all']: 59 if conf['allow_all']:
@@ -64,7 +64,8 @@ class App(object): @@ -64,7 +64,8 @@ class App(object):
64 # ----------------------------------------------------------------------- 64 # -----------------------------------------------------------------------
65 def exit(self): 65 def exit(self):
66 if len(self.online) > 1: 66 if len(self.online) > 1:
67 - logger.warning('Students still online: {}'.format(', '.join(self.online))) 67 + online_students = ', '.join(self.online)
  68 + logger.warning(f'Students still online: {online_students}')
68 logger.critical('----------- !!! Server terminated !!! -----------') 69 logger.critical('----------- !!! Server terminated !!! -----------')
69 70
70 # ----------------------------------------------------------------------- 71 # -----------------------------------------------------------------------
@@ -81,7 +82,7 @@ class App(object): @@ -81,7 +82,7 @@ class App(object):
81 def login(self, uid, try_pw): 82 def login(self, uid, try_pw):
82 if uid not in self.allowed and uid != '0': 83 if uid not in self.allowed and uid != '0':
83 # not allowed 84 # not allowed
84 - logger.warning('Student {}: not allowed to login.'.format(uid)) 85 + logger.warning(f'Student {uid}: not allowed to login.')
85 return False 86 return False
86 87
87 with self.db_session() as s: 88 with self.db_session() as s:
@@ -89,7 +90,7 @@ class App(object): @@ -89,7 +90,7 @@ class App(object):
89 90
90 if student is None: 91 if student is None:
91 # not found 92 # not found
92 - logger.warning('Student {}: not found in database.'.format(uid)) 93 + logger.warning(f'Student {uid}: not found in database.')
93 return False 94 return False
94 95
95 if student.password == '': 96 if student.password == '':
@@ -97,20 +98,20 @@ class App(object): @@ -97,20 +98,20 @@ class App(object):
97 hashed_pw = bcrypt.hashpw(try_pw.encode('utf-8'), bcrypt.gensalt()) 98 hashed_pw = bcrypt.hashpw(try_pw.encode('utf-8'), bcrypt.gensalt())
98 student.password = hashed_pw 99 student.password = hashed_pw
99 s.commit() 100 s.commit()
100 - logger.info('Student {}: first login, password updated.'.format(uid)) 101 + logger.info(f'Student {uid}: first login, password updated.')
101 102
102 elif bcrypt.hashpw(try_pw.encode('utf-8'), student.password) != student.password: 103 elif bcrypt.hashpw(try_pw.encode('utf-8'), student.password) != student.password:
103 # wrong password 104 # wrong password
104 - logger.info('Student {}: wrong password.'.format(uid)) 105 + logger.info(f'Student {uid}: wrong password.')
105 return False 106 return False
106 107
107 # success 108 # success
108 self.allowed.discard(uid) 109 self.allowed.discard(uid)
109 if uid in self.online: 110 if uid in self.online:
110 - logger.warning('Student {}: already logged in.'.format(uid)) 111 + logger.warning(f'Student {uid}: already logged in.')
111 else: 112 else:
112 self.online[uid] = {'student': {'name': student.name, 'number': uid}} 113 self.online[uid] = {'student': {'name': student.name, 'number': uid}}
113 - logger.info('Student {}: logged in.'.format(uid)) 114 + logger.info(f'Student {uid}: logged in.')
114 115
115 return True 116 return True
116 117
@@ -118,17 +119,17 @@ class App(object): @@ -118,17 +119,17 @@ class App(object):
118 def logout(self, uid): 119 def logout(self, uid):
119 self.online.pop(uid, None) # remove from dict if exists 120 self.online.pop(uid, None) # remove from dict if exists
120 # del self.online[uid] 121 # del self.online[uid]
121 - logger.info('Student {}: logged out.'.format(uid)) 122 + logger.info(f'Student {uid}: logged out.')
122 123
123 # ----------------------------------------------------------------------- 124 # -----------------------------------------------------------------------
124 def generate_test(self, uid): 125 def generate_test(self, uid):
125 if uid in self.online: 126 if uid in self.online:
126 - logger.info('Student {}: generating new test.'.format(uid)) 127 + logger.info(f'Student {uid}: generating new test.')
127 student_id = self.online[uid]['student'] 128 student_id = self.online[uid]['student']
128 self.online[uid]['test'] = self.testfactory.generate(student_id) 129 self.online[uid]['test'] = self.testfactory.generate(student_id)
129 return self.online[uid]['test'] 130 return self.online[uid]['test']
130 else: 131 else:
131 - logger.error('Student {}: offline, can''t generate test'.format(uid)) 132 + logger.error(f'Student {uid}: offline, can\'t generate test')
132 return None 133 return None
133 134
134 # ----------------------------------------------------------------------- 135 # -----------------------------------------------------------------------
@@ -165,7 +166,7 @@ class App(object): @@ -165,7 +166,7 @@ class App(object):
165 test_id=t['ref']) for q in t['questions'] if 'grade' in q]) 166 test_id=t['ref']) for q in t['questions'] if 'grade' in q])
166 s.commit() 167 s.commit()
167 168
168 - logger.info('Student {0}: finished test.'.format(uid)) 169 + logger.info(f'Student {uid}: finished test.')
169 return grade 170 return grade
170 171
171 # ----------------------------------------------------------------------- 172 # -----------------------------------------------------------------------
@@ -194,7 +195,7 @@ class App(object): @@ -194,7 +195,7 @@ class App(object):
194 comment='')) 195 comment=''))
195 s.commit() 196 s.commit()
196 197
197 - logger.info('Student {0}: gave up.'.format(uid)) 198 + logger.info(f'Student {uid}: gave up.')
198 return t 199 return t
199 200
200 # ----------------------------------------------------------------------- 201 # -----------------------------------------------------------------------
@@ -271,17 +272,17 @@ class App(object): @@ -271,17 +272,17 @@ class App(object):
271 # --- helpers (change state) 272 # --- helpers (change state)
272 def allow_student(self, uid): 273 def allow_student(self, uid):
273 self.allowed.add(uid) 274 self.allowed.add(uid)
274 - logger.info('Student {}: allowed to login.'.format(uid)) 275 + logger.info(f'Student {uid}: allowed to login.')
275 276
276 def deny_student(self, uid): 277 def deny_student(self, uid):
277 self.allowed.discard(uid) 278 self.allowed.discard(uid)
278 - logger.info('Student {}: denied to login'.format(uid)) 279 + logger.info(f'Student {uid}: denied to login')
279 280
280 def reset_password(self, uid): 281 def reset_password(self, uid):
281 with self.db_session() as s: 282 with self.db_session() as s:
282 u = s.query(Student).filter(Student.id == uid).update({'password': ''}) 283 u = s.query(Student).filter(Student.id == uid).update({'password': ''})
283 s.commit() 284 s.commit()
284 - logger.info('Student {}: password reset to ""'.format(uid)) 285 + logger.info(f'Student {uid}: password reset to ""')
285 286
286 def set_user_agent(self, uid, user_agent=''): 287 def set_user_agent(self, uid, user_agent=''):
287 self.online[uid]['student']['user_agent'] = user_agent 288 self.online[uid]['student']['user_agent'] = user_agent
@@ -295,9 +296,9 @@ class App(object): @@ -295,9 +296,9 @@ class App(object):
295 s.add(Student(id=uid, name=name, password='')) 296 s.add(Student(id=uid, name=name, password=''))
296 s.commit() 297 s.commit()
297 except Exception: 298 except Exception:
298 - logger.error('Insert failed: student {} already exists.'.format(uid)) 299 + logger.error(f'Insert failed: student {uid} already exists.')
299 else: 300 else:
300 - logger.info('New student inserted into database: {}, {}'.format(uid, name)) 301 + logger.info(f'New student inserted: {uid}, {name}')
301 302
302 def set_student_focus(self, uid, value): 303 def set_student_focus(self, uid, value):
303 self.online[uid]['student']['focus'] = value 304 self.online[uid]['student']['focus'] = value
@@ -16,7 +16,7 @@ import tornado.httpserver @@ -16,7 +16,7 @@ import tornado.httpserver
16 from tornado import template, gen 16 from tornado import template, gen
17 17
18 # project 18 # project
19 -from tools import load_yaml, md_to_html 19 +from tools import load_yaml, md_to_html, md_to_html_review
20 from app import App, AppException 20 from app import App, AppException
21 21
22 22
@@ -165,6 +165,20 @@ class TestHandler(BaseHandler): @@ -165,6 +165,20 @@ class TestHandler(BaseHandler):
165 165
166 # --- REVIEW ------------------------------------------------------------- 166 # --- REVIEW -------------------------------------------------------------
167 class ReviewHandler(BaseHandler): 167 class ReviewHandler(BaseHandler):
  168 + templates = {
  169 + 'radio': 'review-question-radio.html',
  170 + 'checkbox': 'review-question-checkbox.html',
  171 + 'text': 'review-question-text.html',
  172 + 'text-regex': 'review-question-text.html',
  173 + 'text-numeric': 'review-question-text.html',
  174 + 'textarea': 'review-question-text.html',
  175 + # -- information panels --
  176 + 'info': 'question-information.html',
  177 + 'warn': 'question-warning.html',
  178 + 'alert': 'question-alert.html',
  179 + 'success': 'question-success.html',
  180 + }
  181 +
168 @tornado.web.authenticated 182 @tornado.web.authenticated
169 def get(self): 183 def get(self):
170 test_id=45 # FIXME 184 test_id=45 # FIXME
@@ -176,14 +190,14 @@ class ReviewHandler(BaseHandler): @@ -176,14 +190,14 @@ class ReviewHandler(BaseHandler):
176 fname = self.testapp.get_json_filename_of_test(test_id) 190 fname = self.testapp.get_json_filename_of_test(test_id)
177 try: 191 try:
178 f = open(path.expanduser(fname)) 192 f = open(path.expanduser(fname))
179 - except FileNotFoundError: 193 + except FileNotFoundError: # FIXME EnvironmentError?
180 logging.error(f'Cannot find "{fname}" for review.') 194 logging.error(f'Cannot find "{fname}" for review.')
181 except Exception as e: 195 except Exception as e:
182 raise e 196 raise e
183 else: 197 else:
184 with f: 198 with f:
185 t = json.load(f) 199 t = json.load(f)
186 - self.render('review.html', t=t) 200 + self.render('review.html', t=t, md=md_to_html_review, templ=self.templates)
187 201
188 202
189 # @cherrypy.expose 203 # @cherrypy.expose
@@ -231,7 +245,6 @@ class AdminHandler(BaseHandler): @@ -231,7 +245,6 @@ class AdminHandler(BaseHandler):
231 245
232 @tornado.web.authenticated 246 @tornado.web.authenticated
233 def post(self): 247 def post(self):
234 - print('admin post')  
235 if self.current_user != '0': 248 if self.current_user != '0':
236 self.redirect('/') 249 self.redirect('/')
237 250
static/js/admin.js
1 $(document).ready(function() { 1 $(document).ready(function() {
  2 + var sorting_direction = -1;
  3 +
2 // button handlers (runs once) 4 // button handlers (runs once)
3 function button_handlers() { 5 function button_handlers() {
  6 + function show_chevron(col, dir) {
  7 + var chevron;
  8 + if (dir > 0)
  9 + chevron = '<i class="fa fa-chevron-up" aria-hidden="true"></i>';
  10 + else
  11 + chevron = '<i class="fa fa-chevron-down" aria-hidden="true"></i>';
  12 +
  13 + $("#col-numero-dir").html(col == "col-numero" ? chevron : "");
  14 + $("#col-nome-dir").html(col == "col-nome" ? chevron : "");
  15 + $("#col-estado-dir").html(col == "col-estado" ? chevron : "");
  16 + $("#col-nota-dir").html(col == "col-nota" ? chevron : "");
  17 + }
  18 +
  19 + $("#col-numero, #col-nome, #col-estado, #col-nota").click(
  20 + function() {
  21 + sorting_direction = -sorting_direction;
  22 + show_chevron(this.id, sorting_direction);
  23 + }
  24 + );
  25 +
4 $("#allow_all").click( 26 $("#allow_all").click(
5 function() { 27 function() {
6 $(":checkbox").prop("checked", true).trigger('change'); 28 $(":checkbox").prop("checked", true).trigger('change');
@@ -43,11 +65,6 @@ $(document).ready(function() { @@ -43,11 +65,6 @@ $(document).ready(function() {
43 // ---------------------------------------------------------------------- 65 // ----------------------------------------------------------------------
44 // checkbox handler to allow/deny students individually 66 // checkbox handler to allow/deny students individually
45 function autorizeStudent(e) { 67 function autorizeStudent(e) {
46 - // $.ajax({  
47 - // type: "POST",  
48 - // url: "/admin",  
49 - // data: {"cmd": "allow", "name": this.name, "value": this.checked}  
50 - // });  
51 if (this.checked) { 68 if (this.checked) {
52 $(this).parent().parent().addClass("active"); 69 $(this).parent().parent().addClass("active");
53 $.ajax({ 70 $.ajax({
@@ -95,44 +112,43 @@ $(document).ready(function() { @@ -95,44 +112,43 @@ $(document).ready(function() {
95 // ---------------------------------------------------------------------- 112 // ----------------------------------------------------------------------
96 function generate_grade_bar(grade) { 113 function generate_grade_bar(grade) {
97 var barcolor; 114 var barcolor;
98 - if (grade < 10) { 115 + if (grade < 10)
99 barcolor = 'bg-danger'; 116 barcolor = 'bg-danger';
100 - }  
101 - else if (grade < 15) { 117 + else if (grade < 15)
102 barcolor = 'bg-warning'; 118 barcolor = 'bg-warning';
103 - }  
104 - else { 119 + else
105 barcolor = 'bg-success'; 120 barcolor = 'bg-success';
106 - }  
107 121
108 - var bar = '<div class="progress"><div class="progress-bar ' + barcolor + '" role="progressbar" aria-valuenow="' + grade + '" aria-valuemin="0" aria-valuemax="20" style="min-width: 2em; width: ' + (5*grade) + '%;">' + grade + '</div></div>';  
109 - return bar 122 + return '<div class="progress"><div class="progress-bar ' + barcolor
  123 + + '" role="progressbar" aria-valuenow="' + grade
  124 + + '" aria-valuemin="0" aria-valuemax="20" style="min-width: 2em; width: '
  125 + + (5*grade) + '%;">' + grade + '</div></div>';
110 } 126 }
111 127
112 // ---------------------------------------------------------------------- 128 // ----------------------------------------------------------------------
113 function populateStudentsTable(students) { 129 function populateStudentsTable(students) {
114 - var n = students.length;  
115 - $("#students-header").html(n)  
116 var rows = ""; 130 var rows = "";
  131 +
117 $.each(students, function(i, d) { 132 $.each(students, function(i, d) {
118 var uid = d['uid']; 133 var uid = d['uid'];
  134 + var checked = d['allowed'] ? 'checked' : '';
  135 + var password_defined = d['password_defined'] ? ' <span class="badge badge-secondary"><i class="fa fa-key" aria-hidden="true"></i></span>' : '';
  136 + var hora_inicio = d['start_time'] ? ' <span class="badge badge-success">' + d['start_time'].slice(11,19) + '</span>': '';
  137 + var estado = password_defined + hora_inicio;
119 138
120 if (d['start_time'] != '') // test 139 if (d['start_time'] != '') // test
121 - rows += '<tr id="' + uid + '" + class="success">'; 140 + rows += '<tr id="' + uid + '" + class="table-success">';
122 else if (d['online']) // online 141 else if (d['online']) // online
123 - rows += '<tr id="' + uid + '" + class="warning">'; 142 + rows += '<tr id="' + uid + '" + class="table-warning">';
124 else if (d['allowed']) // allowed 143 else if (d['allowed']) // allowed
125 - rows += '<tr id="' + uid + '" + class="active">'; 144 + rows += '<tr id="' + uid + '" + class="table-active">';
126 else // offline 145 else // offline
127 rows += '<tr id="' + uid + '" + class="">'; 146 rows += '<tr id="' + uid + '" + class="">';
128 147
129 - rows += '\  
130 - <td><input type="checkbox" name="' + uid + '" value="true"' + (d['allowed'] ? 'checked' : '') + '>' +  
131 - (d['start_time']=='' ? '' : ' <span class="label label-success">teste</span>') +  
132 - // (d['online'] ? '<span class="label label-warning">online</span>' : '') +  
133 - '</td>\  
134 - <td>' + uid + (d['password_defined'] ? ' <i class="fa fa-key" aria-hidden="true"></i>' : '') + '</td>\ 148 + rows += '<td><input type="checkbox" name="' + uid + '" value="true"'
  149 + + checked + '> ' + uid + '</td>\
135 <td>' + d['name'] + '</td>\ 150 <td>' + d['name'] + '</td>\
  151 + <td>' + estado + '</td>\
136 <td>'; 152 <td>';
137 var g = d['grades']; 153 var g = d['grades'];
138 var glength = g.length; 154 var glength = g.length;
@@ -141,8 +157,10 @@ $(document).ready(function() { @@ -141,8 +157,10 @@ $(document).ready(function() {
141 } 157 }
142 rows += '</td></tr>'; 158 rows += '</td></tr>';
143 }); 159 });
  160 +
144 $("#students").html(rows); 161 $("#students").html(rows);
145 - $('[data-toggle="tooltip"]').tooltip(); 162 + // $('[data-toggle="tooltip"]').tooltip(); FIXME
  163 + $("#students-header").html(students.length);
146 } 164 }
147 165
148 // ---------------------------------------------------------------------- 166 // ----------------------------------------------------------------------
@@ -161,7 +179,7 @@ $(document).ready(function() { @@ -161,7 +179,7 @@ $(document).ready(function() {
161 $("#answers_dir").html(data['test']['answers_dir']); 179 $("#answers_dir").html(data['test']['answers_dir']);
162 180
163 // fill online and student tables 181 // fill online and student tables
164 - populateOnlineTable(data["students"]); 182 + // populateOnlineTable(data["students"]);
165 populateStudentsTable(data["students"]) 183 populateStudentsTable(data["students"])
166 184
167 // add event handlers 185 // add event handlers
templates/admin.html
@@ -6,163 +6,157 @@ @@ -6,163 +6,157 @@
6 <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> 6 <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
7 <link rel="icon" href="/static/favicon.ico"> 7 <link rel="icon" href="/static/favicon.ico">
8 8
9 - <!-- Bootstrap --> 9 + <!-- styles -->
10 <link rel="stylesheet" href="/static/bootstrap/css/bootstrap.min.css"> 10 <link rel="stylesheet" href="/static/bootstrap/css/bootstrap.min.css">
11 -  
12 - <!-- optional -->  
13 <link rel="stylesheet" href="/static/font-awesome/css/font-awesome.min.css"> 11 <link rel="stylesheet" href="/static/font-awesome/css/font-awesome.min.css">
14 - <!-- <link rel="stylesheet" href="/static/css/test.css"> -->  
15 -  
16 <style> 12 <style>
17 - body {  
18 - padding-top: 100px;  
19 - } 13 + html {
  14 + font-size: 13px;
  15 + }
  16 + body {
  17 + padding-top: 100px;
  18 + }
20 </style> 19 </style>
21 </head> 20 </head>
22 <!-- ===================================================================== --> 21 <!-- ===================================================================== -->
23 <body> 22 <body>
24 -<nav class="navbar navbar-expand-lg fixed-top navbar-dark bg-dark">  
25 - <a class="navbar-brand" href="#">  
26 - <!-- <i class="fa fa-clock-o" aria-hidden="true"></i> -->  
27 - <span id="clock"> --:-- </span>  
28 - </a>  
29 - 23 +<nav class="navbar navbar-expand-sm fixed-top navbar-dark bg-primary">
  24 + <a class="navbar-brand" href="#">Admin</a>
30 <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNavDropdown" aria-controls="navbarNavDropdown" aria-expanded="false" aria-label="Toggle navigation"> 25 <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNavDropdown" aria-controls="navbarNavDropdown" aria-expanded="false" aria-label="Toggle navigation">
31 <span class="navbar-toggler-icon"></span> 26 <span class="navbar-toggler-icon"></span>
32 </button> 27 </button>
33 28
34 -<!-- <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarText" aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation">  
35 - <span class="navbar-toggler-icon"></span>  
36 - </button>  
37 -  
38 - -->  
39 <div class="collapse navbar-collapse" id="navbarNavDropdown"> 29 <div class="collapse navbar-collapse" id="navbarNavDropdown">
  30 + <!-- left -->
  31 + <span class="navbar-text mr-auto"></span>
  32 +
  33 + <!-- center -->
  34 + <span class="navbar-text mr-auto"><span class="alert alert-info" id="clock"> --:-- </span></span>
  35 +
  36 + <!-- right -->
40 <ul class="navbar-nav"> 37 <ul class="navbar-nav">
41 -<!-- <li class="nav-item">  
42 - <a class="nav-link" href="#">Features</a>  
43 - </li> -->  
44 <li class="nav-item dropdown"> 38 <li class="nav-item dropdown">
45 <a class="nav-link dropdown-toggle" href="#" id="navbarDropdownAluno" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"> 39 <a class="nav-link dropdown-toggle" href="#" id="navbarDropdownAluno" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
46 Aluno 40 Aluno
47 </a> 41 </a>
48 - <div class="dropdown-menu" aria-labelledby="navbarDropdownAluno">  
49 - <a class="dropdown-item" href="#" id="inserir_novo_aluno">Inserir novo...</a>  
50 - <a class="dropdown-item" href="#" id="reset_password">Reset password...</a> 42 + <div class="dropdown-menu dropdown-menu-right" aria-labelledby="navbarDropdownAluno">
  43 + <a class="dropdown-item" href="#" id="novo_aluno" data-toggle="modal" data-target="#novo_aluno_modal">Inserir novo...</a>
  44 + <a class="dropdown-item" href="#" id="reset_password" data-toggle="modal" data-target="#reset_password_modal">Reset password...</a>
51 <a class="dropdown-item" href="#" id="allow_all">Autorizar todos</a> 45 <a class="dropdown-item" href="#" id="allow_all">Autorizar todos</a>
52 <a class="dropdown-item" href="#" id="deny_all">Desautorizar todos</a> 46 <a class="dropdown-item" href="#" id="deny_all">Desautorizar todos</a>
53 </div> 47 </div>
54 </li> 48 </li>
55 </ul> 49 </ul>
56 - <div class="navbar-nav"></div>  
57 -<!-- <span class="navbar-text">  
58 - <i class="fa fa-user" aria-hidden="true"></i>  
59 - <span id="name">Admin</span>  
60 - <span class="caret"></span>  
61 - </span> -->  
62 </div> 50 </div>
63 </nav> 51 </nav>
64 <!-- ===================================================================== --> 52 <!-- ===================================================================== -->
65 <div class="container-fluid"> 53 <div class="container-fluid">
66 <!-- ===================================================================== --> 54 <!-- ===================================================================== -->
67 - <div class="jumbotron">  
68 - <h3 id="title"></h3>  
69 - Ref: <span id="ref"></span><br>  
70 - Enunciado: <span id="filename"></span><br>  
71 - Base de dados: <span id="database"></span><br>  
72 - Testes submetidos: <span id="answers_dir"></span><br>  
73 - <span class="badge badge-secondary"><span id="students-header"></span></span> inscritos<br>  
74 - <span class="badge badge-success"><span id="online-header"></span></span> online 55 + <div class="jumbotron">
  56 + <h3 id="title"></h3>
  57 + Ref: <span id="ref"></span><br>
  58 + Enunciado: <span id="filename"></span><br>
  59 + Base de dados: <span id="database"></span><br>
  60 + Testes submetidos: <span id="answers_dir"></span><br>
  61 + <span class="badge badge-secondary"><span id="students-header">?</span></span> inscritos<br>
  62 + <span class="badge badge-success"><span id="online-header">?</span></span> online
75 63
76 - </div> <!-- jumbotron --> 64 + </div> <!-- jumbotron -->
77 65
78 <!-- ===================================================================== --> 66 <!-- ===================================================================== -->
79 - <!-- <div class="card border-dark"> -->  
80 - <!-- <div class="card-body"> -->  
81 - <table class="table table-condensed noleftmargin">  
82 - <thead class="thead-default">  
83 - <tr>  
84 - <th>Número</th>  
85 - <th>Nome</th>  
86 - <!-- <th>Data de início</th> -->  
87 - <th>Início</th>  
88 - <!-- <th>IP</th> -->  
89 - <th>Estado</th>  
90 - </tr>  
91 - </thead>  
92 - <tbody id="online_students">  
93 - <!-- to be populated -->  
94 - </tbody>  
95 - </table>  
96 - <!-- </div> card-body -->  
97 - <!-- </div> card -->  
98 - 67 + <!-- <table class="table table-condensed noleftmargin">
  68 + <thead class="thead-default">
  69 + <tr>
  70 + <th>Número</th>
  71 + <th>Nome</th>
  72 + <th>Início</th>
  73 + <th>Estado</th>
  74 + </tr>
  75 + </thead>
  76 + <tbody id="online_students">
  77 + to be populated
  78 + </tbody>
  79 + </table>
  80 + -->
99 <!-- ===================================================================== --> 81 <!-- ===================================================================== -->
100 - <!-- <div class="card border-dark"> -->  
101 - <!-- <div class="card-body"> -->  
102 - <table class="table noleftmargin">  
103 - <thead class="thead-default">  
104 - <tr>  
105 - <th>Perm.</th>  
106 - <th>Número</th>  
107 - <th>Nome</th>  
108 - <!-- <th>Estado</th> -->  
109 - <th>Nota</th>  
110 - </tr>  
111 - </thead>  
112 - <tbody id="students">  
113 - <!-- to be populated -->  
114 - </tbody>  
115 - </table>  
116 - <!-- </div> card-body -->  
117 -  
118 - <!-- <div class="card-footer"> -->  
119 - <div class="row">  
120 - <div class="col-sm-4">  
121 - <button id="novo_aluno" class="btn btn-xs btn-primary" data-toggle="modal" data-target="#novo_aluno_modal">Inserir novo aluno</button>  
122 - </div>  
123 - <div class="col-sm-4">  
124 - <div class="input-group input-group-sm">  
125 - <input id="reset_number" type="text" class="form-control" placeholder="Número">  
126 - <span class="input-group-btn">  
127 - <button id="reset_password" class="btn btn-primary" type="button">Reset password!</button>  
128 - </span>  
129 - </div>  
130 - </div>  
131 - </div> <!-- row -->  
132 - <!-- </div> card-footer -->  
133 - <!-- </div> card --> 82 + <table class="table table-sm noleftmargin">
  83 + <thead class="thead-inverse">
  84 + <tr>
  85 + <!-- <th>Perm.</th> -->
  86 + <th class="col-md-2" id="col-numero">Número <span id="col-numero-dir"></span></th>
  87 + <th class="col-md-6" id="col-nome">Nome <span id="col-nome-dir"></span></th>
  88 + <th class="col-md-2" id="col-estado">Estado <span id="col-estado-dir"></span></th>
  89 + <th class="col-md-2" id="col-nota">Nota <span id="col-nota-dir"></span></th>
  90 + </tr>
  91 + </thead>
  92 + <tbody id="students">
  93 + <!-- to be populated -->
  94 + </tbody>
  95 + </table>
134 <!-- ===================================================================== --> 96 <!-- ===================================================================== -->
135 </div> <!-- container --> 97 </div> <!-- container -->
136 <!-- ===================================================================== --> 98 <!-- ===================================================================== -->
137 99
  100 +<!-- modal: inserir novo aluno -->
  101 +<div class="modal" id="novo_aluno_modal">
  102 + <div class="modal-dialog" role="document">
  103 + <div class="modal-content">
138 104
  105 + <div class="modal-header">
  106 + <h5 class="modal-title">Inserir novo aluno</h5>
  107 + <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
  108 + </div>
139 109
140 - <div class="modal" id="novo_aluno_modal">  
141 - <div class="modal-dialog" role="document">  
142 - <div class="modal-content">  
143 -  
144 - <div class="modal-header">  
145 - <h5 class="modal-title">Inserir novo aluno</h5>  
146 - <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button> 110 + <div class="modal-body">
  111 +<!-- <p>Número: <input id="novo_numero" type="text"></p>
  112 + <p>Nome: <input id="novo_nome" type="text"></p>
  113 + -->
  114 + <div class="form-group row">
  115 + <label for="novo_numero" class="col-sm-2 col-form-label">Número</label>
  116 + <div class="col-sm-10">
  117 + <input type="text" class="form-control" id="novo_numero" value="">
147 </div> 118 </div>
148 -  
149 - <div class="modal-body">  
150 - <p>  
151 - Número: <input id="novo_numero" type="text">  
152 - </p>  
153 - <p>  
154 - Nome: <input id="novo_nome" type="text">  
155 - </p> 119 + </div>
  120 + <div class="form-group row">
  121 + <label for="novo_nome" class="col-sm-2 col-form-label">Nome</label>
  122 + <div class="col-sm-10">
  123 + <input type="text" class="form-control" id="novo_nome" value="">
156 </div> 124 </div>
  125 + </div>
157 126
158 - <div class="modal-footer">  
159 - <button type="button" class="btn btn-danger" data-dismiss="modal">Cancelar</button>  
160 - <button id="inserir_novo_aluno" class="btn btn-danger" role="button" data-dismiss="modal">Inserir</button>  
161 - </div> 127 + </div>
  128 +
  129 + <div class="modal-footer">
  130 + <!-- <button type="button" class="btn btn-danger" data-dismiss="modal">Cancelar</button> -->
  131 + <button id="inserir_novo_aluno" class="btn btn-primary" role="button" data-dismiss="modal">Inserir</button>
  132 + </div>
  133 +
  134 + </div>
  135 + </div>
  136 +</div>
  137 +
  138 +<!-- modal: reset password -->
  139 +<div class="modal" id="reset_password_modal">
  140 + <div class="modal-dialog" role="document">
  141 + <div class="modal-content">
  142 +
  143 + <div class="modal-header">
  144 + <h5 class="modal-title">Reset password</h5>
  145 + <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
  146 + </div>
162 147
  148 + <div class="modal-body">
  149 + <div class="input-group input-group-sm">
  150 + <input id="reset_number" type="text" class="form-control" placeholder="Número">
  151 + <span class="input-group-btn">
  152 + <button id="reset_password" class="btn btn-primary" type="button">Reset password!</button>
  153 + </span>
163 </div> 154 </div>
164 </div> 155 </div>
165 </div> 156 </div>
  157 + </div>
  158 +</div>
  159 +
166 160
167 <!-- ===================================================================== --> 161 <!-- ===================================================================== -->
168 162
templates/question.html
@@ -2,7 +2,7 @@ @@ -2,7 +2,7 @@
2 2
3 {% block question %} 3 {% block question %}
4 <div class="card border-dark mb-3"> 4 <div class="card border-dark mb-3">
5 - <h5 class="card-header text-white bg-dark "> 5 + <h5 class="card-header text-white bg-dark">
6 {{ i+1 }}. {{ question['title'] }} 6 {{ i+1 }}. {{ question['title'] }}
7 <div class="pull-right"> 7 <div class="pull-right">
8 <small>Classificar&nbsp;</small> 8 <small>Classificar&nbsp;</small>
templates/review-question-checkbox.html 0 → 100644
@@ -0,0 +1,37 @@ @@ -0,0 +1,37 @@
  1 +{% extends "review-question.html" %}
  2 +{% autoescape %}
  3 +
  4 +{% block answer %}
  5 +<fieldset data-role="controlgroup">
  6 + <ul class="list-group">
  7 + {% for n, opt in enumerate(q['options']) %}
  8 + <li class="list-group-item">
  9 + {% if q['answer'] is not None and str(n) in q['answer'] %}
  10 + {{ md('<i class="fa fa-check-square-o" aria-hidden="true"></i> ' + opt, q)}}
  11 + {% if q['correct'][n] > 0 %}
  12 + <div class="text-right text-success">
  13 + <i class="fa fa-check" aria-hidden="true"></i>
  14 + </div>
  15 + {% else %}
  16 + <div class="text-right text-danger">
  17 + <i class="fa fa-close" aria-hidden="true"></i>
  18 + </div>
  19 + {% end %}
  20 + {% else %}
  21 + {{ md('<i class="fa fa-square-o" aria-hidden="true"></i> ' + opt, q) }}
  22 +
  23 + {% if q['correct'][n] > 0 %}
  24 + <div class="text-right text-info">
  25 + <i class="fa fa-close" aria-hidden="true"></i>
  26 + </div>
  27 + {% elif q['correct'][n] < 0 %}
  28 + <div class="text-right text-success">
  29 + <i class="fa fa-check" aria-hidden="true"></i>
  30 + </div>
  31 + {% end %}
  32 + {% end %}
  33 + </li>
  34 + {% end %}
  35 + </ul>
  36 +</fieldset>
  37 +{% end %}
0 \ No newline at end of file 38 \ No newline at end of file
templates/review-question-radio.html 0 → 100644
@@ -0,0 +1,33 @@ @@ -0,0 +1,33 @@
  1 +{% extends "review-question.html" %}
  2 +{% autoescape %}
  3 +
  4 +{% block answer %}
  5 +<fieldset data-role="controlgroup">
  6 + <ul class="list-group">
  7 + {% for n, opt in enumerate(q['options']) %}
  8 + <li class="list-group-item">
  9 + {% if q['answer'] is not None and str(n)==q['answer'] %}
  10 + {{ md('<i class="fa fa-dot-circle-o" aria-hidden="true"></i> ' + opt, q)}}
  11 + {% if q['correct'][n] > 0 %}
  12 + <div class="text-right text-success">
  13 + <i class="fa fa-check" aria-hidden="true"></i>
  14 + </div>
  15 + {% else %}
  16 + <div class="text-right text-danger">
  17 + <i class="fa fa-close" aria-hidden="true"></i>
  18 + </div>
  19 + {% end %}
  20 + {% else %}
  21 + {{ md('<i class="fa fa-circle-o" aria-hidden="true"></i> ' + opt, q) }}
  22 +
  23 + {% if q['correct'][n] > 0 %}
  24 + <div class="text-right text-info">
  25 + <i class="fa fa-circle" aria-hidden="true"></i>
  26 + </div>
  27 + {% end %}
  28 + {% end %}
  29 + </li>
  30 + {% end %}
  31 + </ul>
  32 +</fieldset>
  33 +{% end %}
0 \ No newline at end of file 34 \ No newline at end of file
templates/review-question-text.html 0 → 100644
@@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
  1 +{% extends "review-question.html" %}
  2 +{% autoescape %}
  3 +
  4 +{% block answer %}
  5 + <pre>{{ q['answer'] if q['answer'] is not None else '' }}</pre>
  6 +{% end %}
0 \ No newline at end of file 7 \ No newline at end of file
templates/review-question.html 0 → 100644
@@ -0,0 +1,62 @@ @@ -0,0 +1,62 @@
  1 +{% autoescape %}
  2 +
  3 +{% block question %}
  4 +<div class="card border-dark mb-3">
  5 +
  6 + <h5 class="card-header text-white bg-dark">
  7 + {{ i+1 }}. {{ q['title'] }}
  8 + <div class="pull-right">
  9 + <small>Classificar&nbsp;</small>
  10 + {% if q['answer'] is not None %}
  11 + <i class="fa fa-check-square-o" aria-hidden="true"></i>
  12 + {% else %}
  13 + <i class="fa fa-square-o" aria-hidden="true"></i>
  14 + {% end %}
  15 + </div>
  16 + </h5>
  17 +
  18 + <div class="card-body">
  19 + <div id="text">
  20 + {{ q['text'] }}
  21 + </div>
  22 +
  23 + {% block answer %}{% end %}
  24 +
  25 + {% if t['show_points'] %}
  26 + <p class="text-right">
  27 + <small>
  28 + (Cotação: {{ q['points'] }} pontos não normalizados)
  29 + </small>
  30 + </p>
  31 + {% end %}
  32 +
  33 + {% if t['state'] == 'FINISHED' %}
  34 + <div class="card-footer">
  35 + {% if q['grade'] > 0.99 %}
  36 + <p class="text-success">
  37 + <i class="fa fa-thumbs-o-up" aria-hidden="true"></i>
  38 + {{ round(q['grade'] * q['points'] / total_points * 20.0, 2)}}
  39 + pontos<br>
  40 + {{ q['comments'] }}
  41 + </p>
  42 + {% elif q['grade'] > 0.49 %}
  43 + <p class="text-warning">
  44 + <i class="fa fa-exclamation-triangle" aria-hidden="true"></i>
  45 + {{ round(q['grade'] * q['points'] / total_points * 20.0, 2) }}
  46 + pontos<br>
  47 + {{ q['comments'] }}
  48 + </p>
  49 + {% else %}
  50 + <p class="text-danger">
  51 + <i class="fa fa-thumbs-o-down" aria-hidden="true"></i>
  52 + {{ round(q['grade'] * q['points'] / total_points * 20.0, 2) }}
  53 + pontos<br>
  54 + {{ q['comments'] }}
  55 + </p>
  56 + {% end %}
  57 + </div>
  58 + {% end %}
  59 + </div>
  60 +
  61 +</div>
  62 +{% end %}
0 \ No newline at end of file 63 \ No newline at end of file
templates/review.html
@@ -27,7 +27,7 @@ @@ -27,7 +27,7 @@
27 </head> 27 </head>
28 <!-- ===================================================================== --> 28 <!-- ===================================================================== -->
29 <body> 29 <body>
30 -<nav class="navbar navbar-expand-lg fixed-top navbar-dark bg-dark"> 30 +<nav class="navbar navbar-expand-sm fixed-top navbar-dark bg-primary">
31 <a class="navbar-brand" href="#">Revisão de prova</a> 31 <a class="navbar-brand" href="#">Revisão de prova</a>
32 </nav> 32 </nav>
33 <!-- ===================================================================== --> 33 <!-- ===================================================================== -->
@@ -52,183 +52,10 @@ @@ -52,183 +52,10 @@
52 {% end %} 52 {% end %}
53 </dl> 53 </dl>
54 </big> 54 </big>
55 - <small>  
56 - <dl class="dl-horizontal">  
57 - <dt>Referência:</dt><dd>{{t['ref']}}</dd>  
58 - <dt>IP:</dt><dd>{{t['student']['ip_address']}}</dd>  
59 - <dt>Browser:</dt><dd>{{t['student']['user_agent']}}</dd>  
60 - </dl>  
61 - </small>  
62 </div> 55 </div>
63 -<!-- ===================================================================== -->  
64 - {% for i,q in enumerate(t['questions']) %}  
65 -  
66 - {% if q['type'] == 'information' %}  
67 - <div class="card card-info">  
68 - <div class="panel-heading clearfix">  
69 - <h4 class="panel-title pull-left">  
70 - <i class="fa fa-info-circle" aria-hidden="true"></i>  
71 - ${q['title']}  
72 - </h4>  
73 - </div>  
74 - <div class="panel-body">  
75 - ${md_to_html_review(q['text'], q)}  
76 - </div>  
77 - </div>  
78 - % elif q['type'] == 'warning':  
79 - <div class="alert alert-warning drop-shadow" role="alert">  
80 - <h4>  
81 - <i class="fa fa-question-circle" aria-hidden="true"></i>  
82 - ${q['title']}  
83 - </h4>  
84 - <p>  
85 - ${md_to_html_review(q['text'], q)}  
86 - </p>  
87 - </div>  
88 - % elif q['type'] == 'alert':  
89 - <div class="alert alert-danger drop-shadow" role="alert">  
90 - <h4>  
91 - <i class="fa fa-exclamation-triangle" aria-hidden="true"></i>  
92 - ${q['title']}  
93 - </h4>  
94 - <p>  
95 - ${md_to_html_review(q['text'], q)}  
96 - </p>  
97 - </div>  
98 - % else:  
99 - <div class="panel panel-primary drop-shadow">  
100 - <div class="panel-heading clearfix">  
101 - <h4 class="panel-title pull-left">  
102 - ${q['title']}  
103 - </h4>  
104 - <div class="pull-right">  
105 - Classificar&nbsp;  
106 - % if q['answer'] is not None:  
107 - <i class="fa fa-check-square-o" aria-hidden="true"></i>  
108 - % else:  
109 - <i class="fa fa-square-o" aria-hidden="true"></i>  
110 - % endif  
111 - </div>  
112 - </div>  
113 - <div class="panel-body" id="example${i}">  
114 - <div class="question">  
115 - ${md_to_html_review(q['text'], q)}  
116 - </div>  
117 -  
118 - % if q['type'] == 'radio':  
119 - <ul class="list-group">  
120 - % for opt in q['options']:  
121 - <li class="list-group-item">  
122 - % if q['answer'] is not None and str(loop.index) == q['answer']:  
123 - ${md_to_html_review('<i class="fa fa-dot-circle-o" aria-hidden="true"></i> ' + opt, q)}  
124 -  
125 - % if q['correct'][loop.index] > 0:  
126 - <div class="text-right text-success">  
127 - <i class="fa fa-check" aria-hidden="true"></i>  
128 - </div>  
129 - % else:  
130 - <div class="text-right text-danger">  
131 - <i class="fa fa-close" aria-hidden="true"></i>  
132 - </div>  
133 - % endif  
134 -  
135 - % else:  
136 - ${md_to_html_review('<i class="fa fa-circle-o" aria-hidden="true"></i> ' + opt, q)}  
137 -  
138 - % if q['correct'][loop.index] > 0:  
139 - <div class="text-right text-info">  
140 - <i class="fa fa-circle" aria-hidden="true"></i>  
141 - </div>  
142 - % endif  
143 - % endif  
144 - </li>  
145 - % endfor  
146 - </ul>  
147 - % elif q['type'] == 'checkbox':  
148 - <ul class="list-group">  
149 - % for opt in q['options']:  
150 - <li class="list-group-item">  
151 - % if q['answer'] is not None and str(loop.index) in q['answer']:  
152 - ${md_to_html_review('<i class="fa fa-check-square-o" aria-hidden="true"></i> ' + opt, q)}  
153 -  
154 - % if q['correct'][loop.index] > 0:  
155 - <div class="text-right text-success">  
156 - <i class="fa fa-check" aria-hidden="true"></i>  
157 - </div>  
158 - % elif q['correct'][loop.index] < 0:  
159 - <div class="text-right text-danger">  
160 - <i class="fa fa-close" aria-hidden="true"></i>  
161 - </div>  
162 - % endif  
163 -  
164 - % else:  
165 - ${md_to_html_review('<i class="fa fa-square-o" aria-hidden="true"></i> ' + opt, q)}  
166 -  
167 - % if q['correct'][loop.index] > 0:  
168 - <div class="text-right text-danger">  
169 - <i class="fa fa-close" aria-hidden="true"></i>  
170 - </div>  
171 - % elif q['correct'][loop.index] < 0:  
172 - <div class="text-right text-success">  
173 - <i class="fa fa-check" aria-hidden="true"></i>  
174 - </div>  
175 - % endif  
176 -  
177 - % endif  
178 - </li>  
179 - % endfor  
180 - </ul>  
181 - % elif q['type'] in ('text', 'text_regex', 'text_numeric', 'textarea'):  
182 - <pre>${q['answer'] if q['answer'] is not None else ''}</pre>  
183 - % endif  
184 -  
185 - % if t['show_hints']:  
186 - % if 'hint' in q:  
187 - <button class="btn btn-sm btn-warning" type="button" data-toggle="collapse" data-target="#hint-${q['ref']}" aria-expanded="false" aria-controls="hint-${q['ref']}">  
188 - Ajuda  
189 - </button>  
190 - <div class="collapse" id="hint-${q['ref']}">  
191 - <div class="well">  
192 - ${md_to_html_review(q['hint'], q)}  
193 - </div>  
194 - </div>  
195 - % endif # hint  
196 - % endif  
197 -  
198 - % if t['show_points']:  
199 - <p class="text-right">  
200 - <small>(Cotação: ${round(q['points'] / total_points * 20.0, 2)} pontos)</small>  
201 - <p>  
202 - % endif  
203 -  
204 - </div> <!-- panel-body -->  
205 -  
206 - % if t['state'] == 'FINISHED':  
207 - <div class="panel-footer">  
208 - % if q['grade'] > 0.99:  
209 - <p class="text-success">  
210 - <i class="fa fa-thumbs-o-up" aria-hidden="true"></i>  
211 - ${round(q['grade'] * q['points'] / total_points * 20.0, 2)} pontos<br>  
212 - ${q['comments']}  
213 - </p>  
214 - % elif q['grade'] > 0.49:  
215 - <p class="text-warning">  
216 - <i class="fa fa-exclamation-triangle" aria-hidden="true"></i>  
217 - ${round(q['grade'] * q['points'] / total_points * 20.0, 2)} pontos<br>  
218 - ${q['comments']}  
219 - </p>  
220 - % else:  
221 - <p class="text-danger">  
222 - <i class="fa fa-thumbs-o-down" aria-hidden="true"></i>  
223 - ${round(q['grade'] * q['points'] / total_points * 20.0, 2)} pontos<br>  
224 - ${q['comments']}  
225 - </p>  
226 - % endif  
227 - </div>  
228 - % endif  
229 56
230 - </div> <!-- panel -->  
231 - % endif # question type 57 + {% for i, q in enumerate(t['questions']) %}
  58 + {% module Template(templ[q['type']], i=i, q=q, md=md, t=t) %}
232 {% end %} 59 {% end %}
233 </div> <!-- container --> 60 </div> <!-- container -->
234 61
templates/test.html
@@ -78,9 +78,9 @@ @@ -78,9 +78,9 @@
78 <div class="col-9"> 78 <div class="col-9">
79 <button type="button" class="btn btn-success btn-lg btn-block" data-toggle="modal" data-target="#confirmar" id="form-button-submit">Submeter teste</button> 79 <button type="button" class="btn btn-success btn-lg btn-block" data-toggle="modal" data-target="#confirmar" id="form-button-submit">Submeter teste</button>
80 </div> 80 </div>
81 - <div class="col-3"> 81 +<!-- <div class="col-3">
82 <button type="button" class="btn btn-danger btn-lg btn-block" data-toggle="modal" data-target="#sair" id="form-button-sair">Desisto</button> 82 <button type="button" class="btn btn-danger btn-lg btn-block" data-toggle="modal" data-target="#sair" id="form-button-sair">Desisto</button>
83 - </div> 83 + </div> -->
84 </div> 84 </div>
85 </form> 85 </form>
86 <hr> 86 <hr>
@@ -195,7 +195,7 @@ class Test(dict): @@ -195,7 +195,7 @@ class Test(dict):
195 self['finish_time'] = None 195 self['finish_time'] = None
196 self['state'] = 'ONGOING' 196 self['state'] = 'ONGOING'
197 self['comment'] = '' 197 self['comment'] = ''
198 - logger.info(f'Student {self["student"]["number"]}: starting test.') 198 + logger.info(f'Student {self["student"]["number"]}: starting test.')
199 199
200 # ----------------------------------------------------------------------- 200 # -----------------------------------------------------------------------
201 # Removes all answers from the test (clean) 201 # Removes all answers from the test (clean)