1
#!/usr/bin/env python3
2

3
# Contest Management System - http://cms-dev.github.io/
4
# Copyright © 2011-2017 Luca Wehrstedt <luca.wehrstedt@gmail.com>
5
#
6
# This program is free software: you can redistribute it and/or modify
7
# it under the terms of the GNU Affero General Public License as
8
# published by the Free Software Foundation, either version 3 of the
9
# License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU Affero General Public License for more details.
15
#
16
# You should have received a copy of the GNU Affero General Public License
17
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
18

19 1
import argparse
20 1
import functools
21 1
import json
22 1
import logging
23 1
import os
24 1
import pprint
25 1
import re
26 1
import shutil
27 1
import time
28 1
from datetime import datetime
29

30 1
import gevent
31 1
from gevent.pywsgi import WSGIServer
32 1
from werkzeug.exceptions import HTTPException, BadRequest, Unauthorized, \
33
    Forbidden, NotFound, NotAcceptable, UnsupportedMediaType
34 1
from werkzeug.routing import Map, Rule
35 0
from werkzeug.wrappers import Request, Response
36 0
from werkzeug.wsgi import responder, wrap_file, SharedDataMiddleware, \
37
    DispatcherMiddleware
38

39
# Needed for initialization. Do not remove.
40 0
import cmsranking.Logger  # noqa
41 0
from cmscommon.eventsource import EventSource
42 0
from cmsranking.Config import Config
43 0
from cmsranking.Contest import Contest
44 0
from cmsranking.Entity import InvalidData
45 0
from cmsranking.Scoring import ScoringStore
46 0
from cmsranking.Store import Store
47 0
from cmsranking.Subchange import Subchange
48 0
from cmsranking.Submission import Submission
49 0
from cmsranking.Task import Task
50 0
from cmsranking.Team import Team
51 0
from cmsranking.User import User
52

53

54 0
logger = logging.getLogger(__name__)
55

56

57 1
class CustomUnauthorized(Unauthorized):
58

59 1
    def __init__(self, realm_name):
60 0
        super().__init__()
61 0
        self.realm_name = realm_name
62

63 1
    def get_response(self, environ=None):
64 0
        response = super().get_response(environ)
65
        # XXX With werkzeug-0.9 a full-featured Response object is
66
        # returned: there is no need for this.
67 0
        response = Response.force_type(response)
68 0
        response.www_authenticate.set_basic(self.realm_name)
69 0
        return response
70

71

72 1
class StoreHandler:
73

74 1
    def __init__(self, store, username, password, realm_name):
75 1
        self.store = store
76 1
        self.username = username
77 1
        self.password = password
78 1
        self.realm_name = realm_name
79

80 1
        self.router = Map([
81
            Rule("/<key>", methods=["GET"], endpoint="get"),
82
            Rule("/", methods=["GET"], endpoint="get_list"),
83
            Rule("/<key>", methods=["PUT"], endpoint="put"),
84
            Rule("/", methods=["PUT"], endpoint="put_list"),
85
            Rule("/<key>", methods=["DELETE"], endpoint="delete"),
86
            Rule("/", methods=["DELETE"], endpoint="delete_list"),
87
        ], encoding_errors="strict")
88

89 1
    def __call__(self, environ, start_response):
90 1
        return self.wsgi_app(environ, start_response)
91

92 1
    @responder
93
    def wsgi_app(self, environ, start_response):
94 1
        route = self.router.bind_to_environ(environ)
95 1
        try:
96 1
            endpoint, args = route.match()
97 0
        except HTTPException as exc:
98 0
            return exc
99

100 1
        request = Request(environ)
101 1
        request.encoding_errors = "strict"
102

103 1
        response = Response()
104

105 1
        try:
106 1
            if endpoint == "get":
107 0
                self.get(request, response, args["key"])
108 1
            elif endpoint == "get_list":
109 0
                self.get_list(request, response)
110 1
            elif endpoint == "put":
111 0
                self.put(request, response, args["key"])
112 1
            elif endpoint == "put_list":
113 1
                self.put_list(request, response)
114 0
            elif endpoint == "delete":
115 0
                self.delete(request, response, args["key"])
116 0
            elif endpoint == "delete_list":
117 0
                self.delete_list(request, response)
118
            else:
119 0
                raise RuntimeError()
120 0
        except HTTPException as exc:
121 0
            return exc
122

123 1
        return response
124

125 1
    def authorized(self, request):
126 1
        return request.authorization is not None and \
127
            request.authorization.type == "basic" and \
128
            request.authorization.username == self.username and \
129
            request.authorization.password == self.password
130

131 1
    def get(self, request, response, key):
132
        # Limit charset of keys.
133 0
        if re.match("^[A-Za-z0-9_]+$", key) is None:
134 0
            return NotFound()
135 0
        if key not in self.store:
136 0
            raise NotFound()
137

138 0
        response.status_code = 200
139 0
        response.headers['Timestamp'] = "%0.6f" % time.time()
140 0
        response.mimetype = "application/json"
141 0
        response.data = json.dumps(self.store.retrieve(key))
142

143 1
    def get_list(self, request, response):
144 0
        response.status_code = 200
145 0
        response.headers['Timestamp'] = "%0.6f" % time.time()
146 0
        response.mimetype = "application/json"
147 0
        response.data = json.dumps(self.store.retrieve_list())
148

149 1
    def put(self, request, response, key):
150
        # Limit charset of keys.
151 0
        if re.match("^[A-Za-z0-9_]+$", key) is None:
152 0
            return Forbidden()
153 0
        if not self.authorized(request):
154 0
            logger.warning("Unauthorized request.",
155
                           extra={'location': request.url,
156
                                  'details': repr(request.authorization)})
157 0
            raise CustomUnauthorized(self.realm_name)
158 0
        if request.mimetype != "application/json":
159 0
            logger.warning("Unsupported MIME type.",
160
                           extra={'location': request.url,
161
                                  'details': request.mimetype})
162 0
            raise UnsupportedMediaType()
163

164 0
        try:
165 0
            data = json.load(request.stream)
166 0
        except (TypeError, ValueError):
167 0
            logger.warning("Wrong JSON.",
168
                           extra={'location': request.url})
169 0
            raise BadRequest()
170

171 0
        try:
172 0
            if key not in self.store:
173 0
                self.store.create(key, data)
174
            else:
175 0
                self.store.update(key, data)
176 0
        except InvalidData as err:
177 0
            logger.warning("Invalid data: %s" % str(err), exc_info=False,
178
                           extra={'location': request.url,
179
                                  'details': pprint.pformat(data)})
180 0
            raise BadRequest()
181

182 0
        response.status_code = 204
183

184 1
    def put_list(self, request, response):
185 1
        if not self.authorized(request):
186 0
            logger.info("Unauthorized request.",
187
                        extra={'location': request.url,
188
                               'details': repr(request.authorization)})
189 0
            raise CustomUnauthorized(self.realm_name)
190 1
        if request.mimetype != "application/json":
191 0
            logger.warning("Unsupported MIME type.",
192
                           extra={'location': request.url,
193
                                  'details': request.mimetype})
194 0
            raise UnsupportedMediaType()
195

196 1
        try:
197 1
            data = json.load(request.stream)
198 0
        except (TypeError, ValueError):
199 0
            logger.warning("Wrong JSON.",
200
                           extra={'location': request.url})
201 0
            raise BadRequest()
202

203 1
        try:
204 1
            self.store.merge_list(data)
205 0
        except InvalidData as err:
206 0
            logger.warning("Invalid data: %s" % str(err), exc_info=False,
207
                           extra={'location': request.url,
208
                                  'details': pprint.pformat(data)})
209 0
            raise BadRequest()
210

211 1
        response.status_code = 204
212

213 1
    def delete(self, request, response, key):
214
        # Limit charset of keys.
215 0
        if re.match("^[A-Za-z0-9_]+$", key) is None:
216 0
            return NotFound()
217 0
        if key not in self.store:
218 0
            raise NotFound()
219 0
        if not self.authorized(request):
220 0
            logger.info("Unauthorized request.",
221
                        extra={'location': request.url,
222
                               'details': repr(request.authorization)})
223 0
            raise CustomUnauthorized(self.realm_name)
224

225 0
        self.store.delete(key)
226

227 0
        response.status_code = 204
228

229 1
    def delete_list(self, request, response):
230 0
        if not self.authorized(request):
231 0
            logger.info("Unauthorized request.",
232
                        extra={'location': request.url,
233
                               'details': repr(request.authorization)})
234 0
            raise CustomUnauthorized(self.realm_name)
235

236 0
        self.store.delete_list()
237

238 0
        response.status_code = 204
239

240

241 1
class DataWatcher(EventSource):
242
    """Receive the messages from the entities store and redirect them."""
243

244 1
    def __init__(self, stores, buffer_size):
245 1
        self._CACHE_SIZE = buffer_size
246 1
        EventSource.__init__(self)
247

248 1
        stores["contest"].add_create_callback(
249
            functools.partial(self.callback, "contest", "create"))
250 1
        stores["contest"].add_update_callback(
251
            functools.partial(self.callback, "contest", "update"))
252 1
        stores["contest"].add_delete_callback(
253
            functools.partial(self.callback, "contest", "delete"))
254

255 1
        stores["task"].add_create_callback(
256
            functools.partial(self.callback, "task", "create"))
257 1
        stores["task"].add_update_callback(
258
            functools.partial(self.callback, "task", "update"))
259 1
        stores["task"].add_delete_callback(
260
            functools.partial(self.callback, "task", "delete"))
261

262 1
        stores["team"].add_create_callback(
263
            functools.partial(self.callback, "team", "create"))
264 1
        stores["team"].add_update_callback(
265
            functools.partial(self.callback, "team", "update"))
266 1
        stores["team"].add_delete_callback(
267
            functools.partial(self.callback, "team", "delete"))
268

269 1
        stores["user"].add_create_callback(
270
            functools.partial(self.callback, "user", "create"))
271 1
        stores["user"].add_update_callback(
272
            functools.partial(self.callback, "user", "update"))
273 1
        stores["user"].add_delete_callback(
274
            functools.partial(self.callback, "user", "delete"))
275

276 1
        stores["scoring"].add_score_callback(self.score_callback)
277

278 1
    def callback(self, entity, event, key, *args):
279 1
        self.send(entity, "%s %s" % (event, key))
280

281 1
    def score_callback(self, user, task, score):
282
        # FIXME Use score_precision.
283 1
        self.send("score", "%s %s %0.2f" % (user, task, score))
284

285

286 1
class SubListHandler:
287

288 1
    def __init__(self, stores):
289 1
        self.task_store = stores["task"]
290 1
        self.scoring_store = stores["scoring"]
291

292 1
        self.router = Map([
293
            Rule("/<user_id>", methods=["GET"], endpoint="sublist"),
294
        ], encoding_errors="strict")
295

296 1
    def __call__(self, environ, start_response):
297 0
        return self.wsgi_app(environ, start_response)
298

299 1
    def wsgi_app(self, environ, start_response):
300 0
        route = self.router.bind_to_environ(environ)
301 0
        try:
302 0
            endpoint, args = route.match()
303 0
        except HTTPException as exc:
304 0
            return exc(environ, start_response)
305

306 0
        assert endpoint == "sublist"
307

308 0
        request = Request(environ)
309 0
        request.encoding_errors = "strict"
310

311 0
        if request.accept_mimetypes.quality("application/json") <= 0:
312 0
            raise NotAcceptable()
313

314 0
        result = list()
315 0
        for task_id in self.task_store._store.keys():
316 0
            result.extend(
317
                self.scoring_store.get_submissions(
318
                    args["user_id"], task_id
319
                ).values()
320
            )
321 0
        result.sort(key=lambda x: (x.task, x.time))
322 0
        result = list(a.__dict__ for a in result)
323

324 0
        response = Response()
325 0
        response.status_code = 200
326 0
        response.mimetype = "application/json"
327 0
        response.data = json.dumps(result)
328

329 0
        return response(environ, start_response)
330

331

332 1
class HistoryHandler:
333

334 1
    def __init__(self, stores):
335 1
        self.scoring_store = stores["scoring"]
336

337 1
    def __call__(self, environ, start_response):
338 0
        return self.wsgi_app(environ, start_response)
339

340 1
    def wsgi_app(self, environ, start_response):
341 0
        request = Request(environ)
342 0
        request.encoding_errors = "strict"
343

344 0
        if request.accept_mimetypes.quality("application/json") <= 0:
345 0
            raise NotAcceptable()
346

347 0
        result = list(self.scoring_store.get_global_history())
348

349 0
        response = Response()
350 0
        response.status_code = 200
351 0
        response.mimetype = "application/json"
352 0
        response.data = json.dumps(result)
353

354 0
        return response(environ, start_response)
355

356

357 1
class ScoreHandler:
358

359 1
    def __init__(self, stores):
360 1
        self.scoring_store = stores["scoring"]
361

362 1
    def __call__(self, environ, start_response):
363 0
        return self.wsgi_app(environ, start_response)
364

365 1
    def wsgi_app(self, environ, start_response):
366 0
        request = Request(environ)
367 0
        request.encoding_errors = "strict"
368

369 0
        if request.accept_mimetypes.quality("application/json") <= 0:
370 0
            raise NotAcceptable()
371

372 0
        result = dict()
373 0
        for u_id, tasks in self.scoring_store._scores.items():
374 0
            for t_id, score in tasks.items():
375 0
                if score.get_score() > 0.0:
376 0
                    result.setdefault(u_id, dict())[t_id] = score.get_score()
377

378 0
        response = Response()
379 0
        response.status_code = 200
380 0
        response.headers['Timestamp'] = "%0.6f" % time.time()
381 0
        response.mimetype = "application/json"
382 0
        response.data = json.dumps(result)
383

384 0
        return response(environ, start_response)
385

386

387 1
class ImageHandler:
388 1
    EXT_TO_MIME = {
389
        'png': 'image/png',
390
        'jpg': 'image/jpeg',
391
        'gif': 'image/gif',
392
        'bmp': 'image/bmp'
393
    }
394

395 1
    MIME_TO_EXT = dict((v, k) for k, v in EXT_TO_MIME.items())
396

397 1
    def __init__(self, location, fallback):
398 1
        self.location = location
399 1
        self.fallback = fallback
400

401 1
        self.router = Map([
402
            Rule("/<name>", methods=["GET"], endpoint="get"),
403
        ], encoding_errors="strict")
404

405 1
    def __call__(self, environ, start_response):
406 0
        return self.wsgi_app(environ, start_response)
407

408 1
    @responder
409
    def wsgi_app(self, environ, start_response):
410 0
        route = self.router.bind_to_environ(environ)
411 0
        try:
412 0
            endpoint, args = route.match()
413 0
        except HTTPException as exc:
414 0
            return exc
415

416 0
        location = self.location % args
417

418 0
        request = Request(environ)
419 0
        request.encoding_errors = "strict"
420

421 0
        response = Response()
422

423 0
        available = list()
424 0
        for extension, mimetype in self.EXT_TO_MIME.items():
425 0
            if os.path.isfile(location + '.' + extension):
426 0
                available.append(mimetype)
427 0
        mimetype = request.accept_mimetypes.best_match(available)
428 0
        if mimetype is not None:
429 0
            path = "%s.%s" % (location, self.MIME_TO_EXT[mimetype])
430
        else:
431 0
            path = self.fallback
432 0
            mimetype = 'image/png'  # FIXME Hardcoded type.
433

434 0
        response.status_code = 200
435 0
        response.mimetype = mimetype
436 0
        response.last_modified = \
437
            datetime.utcfromtimestamp(os.path.getmtime(path))\
438
                    .replace(microsecond=0)
439

440
        # TODO check for If-Modified-Since and If-None-Match
441

442 0
        response.response = wrap_file(environ, open(path, 'rb'))
443 0
        response.direct_passthrough = True
444

445 0
        return response
446

447

448 1
class RootHandler:
449

450 1
    def __init__(self, location):
451 1
        self.path = os.path.join(location, "Ranking.html")
452

453 1
    def __call__(self, environ, start_response):
454 0
        return self.wsgi_app(environ, start_response)
455

456 1
    @responder
457
    def wsgi_app(self, environ, start_response):
458 0
        request = Request(environ)
459 0
        request.encoding_errors = "strict"
460

461 0
        response = Response()
462 0
        response.status_code = 200
463 0
        response.mimetype = "text/html"
464 0
        response.last_modified = \
465
            datetime.utcfromtimestamp(os.path.getmtime(self.path))\
466
                    .replace(microsecond=0)
467
        # TODO check for If-Modified-Since and If-None-Match
468 0
        response.response = wrap_file(environ, open(self.path, 'rb'))
469 0
        response.direct_passthrough = True
470

471 0
        return response
472

473

474 1
class RoutingHandler:
475

476 1
    def __init__(self, root_handler, event_handler, logo_handler,
477
                 score_handler, history_handler):
478 1
        self.router = Map([
479
            Rule("/", methods=["GET"], endpoint="root"),
480
            Rule("/history", methods=["GET"], endpoint="history"),
481
            Rule("/scores", methods=["GET"], endpoint="scores"),
482
            Rule("/events", methods=["GET"], endpoint="events"),
483
            Rule("/logo", methods=["GET"], endpoint="logo"),
484
        ], encoding_errors="strict")
485

486 1
        self.event_handler = event_handler
487 1
        self.logo_handler = logo_handler
488 1
        self.score_handler = score_handler
489 1
        self.history_handler = history_handler
490 1
        self.root_handler = root_handler
491

492 1
    def __call__(self, environ, start_response):
493 0
        return self.wsgi_app(environ, start_response)
494

495 1
    def wsgi_app(self, environ, start_response):
496 0
        route = self.router.bind_to_environ(environ)
497 0
        try:
498 0
            endpoint, args = route.match()
499 0
        except HTTPException as exc:
500 0
            return exc(environ, start_response)
501

502 0
        if endpoint == "events":
503 0
            return self.event_handler(environ, start_response)
504 0
        elif endpoint == "logo":
505 0
            return self.logo_handler(environ, start_response)
506 0
        elif endpoint == "root":
507 0
            return self.root_handler(environ, start_response)
508 0
        elif endpoint == "scores":
509 0
            return self.score_handler(environ, start_response)
510 0
        elif endpoint == "history":
511 0
            return self.history_handler(environ, start_response)
512

513

514 0
def main():
515
    """Entry point for RWS.
516

517
    return (int): exit code (0 on success, 1 on error)
518

519
    """
520 1
    parser = argparse.ArgumentParser(
521
        description="Ranking for CMS.")
522 1
    parser.add_argument("--config", type=argparse.FileType("rt"),
523
                        help="override config file")
524 1
    parser.add_argument("-d", "--drop", action="store_true",
525
                        help="drop the data already stored")
526 1
    parser.add_argument("-y", "--yes", action="store_true",
527
                        help="do not require confirmation on dropping data")
528 1
    args = parser.parse_args()
529

530 1
    config = Config()
531 1
    config.load(args.config)
532

533 1
    if args.drop:
534 0
        if args.yes:
535 0
            ans = 'y'
536
        else:
537 0
            ans = input("Are you sure you want to delete directory %s? [y/N] " %
538
                        config.lib_dir).strip().lower()
539 0
        if ans in ['y', 'yes']:
540 0
            print("Removing directory %s." % config.lib_dir)
541 0
            shutil.rmtree(config.lib_dir)
542
        else:
543 0
            print("Not removing directory %s." % config.lib_dir)
544 0
        return 0
545

546 1
    stores = dict()
547

548 1
    stores["subchange"] = Store(
549
        Subchange, os.path.join(config.lib_dir, 'subchanges'), stores)
550 1
    stores["submission"] = Store(
551
        Submission, os.path.join(config.lib_dir, 'submissions'), stores,
552
        [stores["subchange"]])
553 1
    stores["user"] = Store(
554
        User, os.path.join(config.lib_dir, 'users'), stores,
555
        [stores["submission"]])
556 1
    stores["team"] = Store(
557
        Team, os.path.join(config.lib_dir, 'teams'), stores,
558
        [stores["user"]])
559 1
    stores["task"] = Store(
560
        Task, os.path.join(config.lib_dir, 'tasks'), stores,
561
        [stores["submission"]])
562 1
    stores["contest"] = Store(
563
        Contest, os.path.join(config.lib_dir, 'contests'), stores,
564
        [stores["task"]])
565

566 1
    stores["contest"].load_from_disk()
567 1
    stores["task"].load_from_disk()
568 1
    stores["team"].load_from_disk()
569 1
    stores["user"].load_from_disk()
570 1
    stores["submission"].load_from_disk()
571 1
    stores["subchange"].load_from_disk()
572

573 1
    stores["scoring"] = ScoringStore(stores)
574 1
    stores["scoring"].init_store()
575

576 1
    toplevel_handler = RoutingHandler(
577
        RootHandler(config.web_dir),
578
        DataWatcher(stores, config.buffer_size),
579
        ImageHandler(
580
            os.path.join(config.lib_dir, '%(name)s'),
581
            os.path.join(config.web_dir, 'img', 'logo.png')),
582
        ScoreHandler(stores),
583
        HistoryHandler(stores))
584

585 1
    wsgi_app = SharedDataMiddleware(DispatcherMiddleware(
586
        toplevel_handler, {
587
            '/contests': StoreHandler(
588
                stores["contest"],
589
                config.username, config.password, config.realm_name),
590
            '/tasks': StoreHandler(
591
                stores["task"],
592
                config.username, config.password, config.realm_name),
593
            '/teams': StoreHandler(
594
                stores["team"],
595
                config.username, config.password, config.realm_name),
596
            '/users': StoreHandler(
597
                stores["user"],
598
                config.username, config.password, config.realm_name),
599
            '/submissions': StoreHandler(
600
                stores["submission"],
601
                config.username, config.password, config.realm_name),
602
            '/subchanges': StoreHandler(
603
                stores["subchange"],
604
                config.username, config.password, config.realm_name),
605
            '/faces': ImageHandler(
606
                os.path.join(config.lib_dir, 'faces', '%(name)s'),
607
                os.path.join(config.web_dir, 'img', 'face.png')),
608
            '/flags': ImageHandler(
609
                os.path.join(config.lib_dir, 'flags', '%(name)s'),
610
                os.path.join(config.web_dir, 'img', 'flag.png')),
611
            '/sublist': SubListHandler(stores),
612
        }), {'/': config.web_dir})
613

614 1
    servers = list()
615 1
    if config.http_port is not None:
616 1
        http_server = WSGIServer(
617
            (config.bind_address, config.http_port), wsgi_app)
618 1
        servers.append(http_server)
619 1
    if config.https_port is not None:
620 0
        https_server = WSGIServer(
621
            (config.bind_address, config.https_port), wsgi_app,
622
            certfile=config.https_certfile, keyfile=config.https_keyfile)
623 0
        servers.append(https_server)
624

625 1
    try:
626 1
        gevent.joinall(list(gevent.spawn(s.serve_forever) for s in servers))
627 0
    except KeyboardInterrupt:
628 0
        pass
629
    finally:
630 1
        gevent.joinall(list(gevent.spawn(s.stop) for s in servers))
631 0
    return 0

Read our documentation on viewing source code .

Loading