From e5c3fad812afe3bbf75f6b8a2afeb6cbc1ef7041 Mon Sep 17 00:00:00 2001 From: s3lph Date: Mon, 3 Feb 2020 20:44:58 +0100 Subject: [PATCH] Further bottle porting --- doc | 2 +- matemat/webserver/cron.py | 3 + matemat/webserver/pagelets/admin.py | 2 +- matemat/webserver/session/sessions.py | 2 +- matemat/webserver/test/abstract_httpd_test.py | 173 ++---------------- matemat/webserver/test/test_config.py | 2 +- matemat/webserver/test/test_httpd.py | 108 ----------- matemat/webserver/test/test_pagelet_init.py | 68 ------- matemat/webserver/test/test_session.py | 43 ++--- static/css/matemat.css | 21 ++- templates/admin_all.html | 2 +- templates/admin_restricted.html | 4 +- templates/base.html | 1 - templates/modproduct.html | 2 +- templates/moduser.html | 2 +- templates/productlist.html | 14 +- templates/touchkey.html | 2 +- templates/userlist.html | 2 +- 18 files changed, 66 insertions(+), 387 deletions(-) delete mode 100644 matemat/webserver/test/test_httpd.py delete mode 100644 matemat/webserver/test/test_pagelet_init.py diff --git a/doc b/doc index 0fcf424..8d6d6b6 160000 --- a/doc +++ b/doc @@ -1 +1 @@ -Subproject commit 0fcf4244f90d93cbc7be8e670aeaa30288d24ae3 +Subproject commit 8d6d6b6fece9d0b2dedc11ad41d4b96554a8ea36 diff --git a/matemat/webserver/cron.py b/matemat/webserver/cron.py index 3324d8a..ce78acf 100644 --- a/matemat/webserver/cron.py +++ b/matemat/webserver/cron.py @@ -3,6 +3,8 @@ from typing import Callable from datetime import timedelta from threading import Event, Timer, Thread +from matemat.webserver import Logger + _CRON_STATIC_EVENT: Event = Event() @@ -67,6 +69,7 @@ def cron(weeks: int = 0, # This function is called once in the specified interval def cron(): + logger = Logger.instance() # Reschedule the job t: Timer = _GlobalEventTimer(delta.total_seconds(), _CRON_STATIC_EVENT, cron) t.start() diff --git a/matemat/webserver/pagelets/admin.py b/matemat/webserver/pagelets/admin.py index 1ad302c..95c130f 100644 --- a/matemat/webserver/pagelets/admin.py +++ b/matemat/webserver/pagelets/admin.py @@ -247,7 +247,7 @@ def handle_admin_change(args: FormsDict, files: FormsDict, db: MatematDatabase): elif change == 'defaultimg': # Iterate the possible images to set for category in 'users', 'products': - if category not in args: + if category not in files: continue # Read the raw image data from the request default: bytes = files[category].file.read() diff --git a/matemat/webserver/session/sessions.py b/matemat/webserver/session/sessions.py index c1ee82c..55f0549 100644 --- a/matemat/webserver/session/sessions.py +++ b/matemat/webserver/session/sessions.py @@ -18,7 +18,7 @@ def start() -> str: """ Start a new session, or resume the session identified by the session cookie sent in the HTTP request. - :return: A tuple consisting of the session ID (a UUID string), and the session timeout date. + :return: The session ID. """ # Reference date for session timeout now = datetime.utcnow() diff --git a/matemat/webserver/test/abstract_httpd_test.py b/matemat/webserver/test/abstract_httpd_test.py index bdec6bc..373f39d 100644 --- a/matemat/webserver/test/abstract_httpd_test.py +++ b/matemat/webserver/test/abstract_httpd_test.py @@ -1,103 +1,18 @@ - +import threading +from http.client import HTTPResponse from typing import Any, Callable, Dict, Tuple, Union import unittest.mock -from io import BytesIO +import urllib.request from tempfile import TemporaryDirectory from abc import ABC from datetime import datetime -from http.server import HTTPServer import logging import jinja2 -from matemat.webserver import pagelet, RequestArguments - - -class HttpResponse: - """ - A really basic HTTP response container and parser class, just good enough for unit testing a HTTP server, if even. - - DO NOT USE THIS OUTSIDE UNIT TESTING! - - Usage: - response = HttpResponse() - while response.parse_phase != 'done' - response.parse() - print(response.statuscode) - """ - - def __init__(self) -> None: - # The HTTP status code of the response - self.statuscode: int = 0 - # HTTP headers set in the response - self.headers: Dict[str, str] = { - 'Content-Length': 0 - } - self.pagelet: str = None - # The response body - self.body: bytes = bytes() - # Parsing phase, one of 'begin', 'hdr', 'body' or 'done' - self.parse_phase = 'begin' - # Buffer for uncompleted lines - self.buffer: bytes = bytes() - - def __finalize(self): - self.parse_phase = 'done' - self.pagelet = self.headers.get('X-Test-Pagelet', None) - - def parse(self, fragment: bytes) -> None: - """ - Parse a new fragment of data. This function does nothing if the parsed HTTP response is already complete. - - DO NOT USE THIS OUTSIDE UNIT TESTING! - - :param fragment: The data fragment to parse. - """ - # response packet complete, nothing to do - if self.parse_phase == 'done': - return - # If in the body phase, simply decode and append to the body, while the body is not complete yet - elif self.parse_phase == 'body': - self.body += fragment - if len(self.body) >= int(self.headers['Content-Length']): - self.__finalize() - return - if b'\r\n' not in fragment: - # If the fragment does not contain a CR-LF, add it to the buffer, we only want to parse whole lines - self.buffer = self.buffer + fragment - else: - if not fragment.endswith(b'\r\n'): - # Special treatment for no trailing CR-LF: Add remainder to buffer - head, tail = fragment.rsplit(b'\r\n', 1) - data: bytes = (self.buffer + head) - self.buffer = tail - else: - data: bytes = (self.buffer + fragment) - self.buffer = bytes() - # Iterate the lines that are ready to be parsed - for line in data.split(b'\r\n'): - # The 'begin' phase indicates that the parser is waiting for the HTTP status line - if self.parse_phase == 'begin': - if line.startswith(b'HTTP/'): - # Parse the statuscode and advance to header parsing - _, statuscode, _ = line.decode('utf-8').split(' ', 2) - self.statuscode = int(statuscode) - self.parse_phase = 'hdr' - elif self.parse_phase == 'hdr': - # Parse a header line and add it to the header dict - if len(line) > 0: - k, v = line.decode('utf-8').split(':', 1) - self.headers[k.strip()] = v.strip() - else: - # Empty line separates header from body - self.parse_phase = 'body' - elif self.parse_phase == 'body': - # if there is a remainder in the data packet, it is (part of) the body, add to body string - self.body += line - if len(self.body) >= int(self.headers['Content-Length']): - self.__finalize() +import bottle class MockServer: @@ -129,76 +44,7 @@ class MockServer: # sh: logging.StreamHandler = logging.StreamHandler() # sh.setFormatter(logging.Formatter('%(asctime)s %(name)s [%(levelname)s]: %(message)s')) # self.logger.addHandler(sh) - - -class MockSocket(bytes): - """ - A mock implementation of a socket.socket for http.server.BaseHTTPRequestHandler. - - The bytes inheritance is due to a broken type annotation in BaseHTTPRequestHandler. - """ - - def __init__(self) -> None: - super().__init__() - # The request string - self.__request = bytes() - # The parsed response - self.__packet = HttpResponse() - - def set_request(self, request: bytes) -> None: - """ - Sets the HTTP request to send to the server. - - :param request: The request - """ - self.__request: bytes = request - - def makefile(self, mode: str, size: int) -> BytesIO: - """ - Required by http.server.HTTPServer. - - :return: A dummy buffer IO object instead of a network socket file handle. - """ - return BytesIO(self.__request) - - def sendall(self, b: bytes) -> None: - """ - Required by http.server.HTTPServer. - - :param b: The data to send to the client. Will be parsed directly instead. - """ - self.__packet.parse(b) - - def get_response(self) -> HttpResponse: - """ - Fetches the parsed HTTP response generated by the server. - - :return: The response object. - """ - return self.__packet - - -def test_pagelet(path: str): - - def with_testing_headers(fun: Callable[[str, - str, - RequestArguments, - Dict[str, Any], - Dict[str, str], - Dict[str, str]], - Union[bytes, str, Tuple[int, str]]]): - @pagelet(path) - def testing_wrapper(method: str, - path: str, - args: RequestArguments, - session_vars: Dict[str, Any], - headers: Dict[str, str], - pagelet_variables: Dict[str, str]): - headers['X-Test-Pagelet'] = fun.__name__ - result = fun(method, path, args, session_vars, headers, pagelet_variables) - return result - return testing_wrapper - return with_testing_headers + bottle.run(host='::1', port='8888') class AbstractHttpdTest(ABC, unittest.TestCase): @@ -215,8 +61,13 @@ class AbstractHttpdTest(ABC, unittest.TestCase): def setUp(self) -> None: self.tempdir: TemporaryDirectory = TemporaryDirectory(prefix='matemat.', dir='/tmp/') - self.server: HTTPServer = MockServer(webroot=self.tempdir.name) - self.client_sock: MockSocket = MockSocket() + self.server = threading.Thread(target=bottle.run, kwargs={'host': '::1', 'port': 8888, 'debug': True}) + self.server.start() def tearDown(self): + self.server._stop() self.tempdir.cleanup() + + @staticmethod + def request(query) -> HTTPResponse: + return urllib.request.urlopen(f'http://[::1]:8888{query}') diff --git a/matemat/webserver/test/test_config.py b/matemat/webserver/test/test_config.py index 4fee839..cdbf28b 100644 --- a/matemat/webserver/test/test_config.py +++ b/matemat/webserver/test/test_config.py @@ -8,7 +8,7 @@ from io import StringIO import logging import sys -from matemat.webserver import parse_config_file +from matemat.webserver.config import parse_config_file _EMPTY_CONFIG = '' diff --git a/matemat/webserver/test/test_httpd.py b/matemat/webserver/test/test_httpd.py deleted file mode 100644 index dbb8cad..0000000 --- a/matemat/webserver/test/test_httpd.py +++ /dev/null @@ -1,108 +0,0 @@ -from typing import Any, Dict, Union - -from matemat.exceptions import HttpException -from matemat.webserver import HttpHandler, RequestArguments, PageletResponse -from matemat.webserver.test.abstract_httpd_test import AbstractHttpdTest, test_pagelet - - -@test_pagelet('/just/testing/http_exception') -def test_pagelet_http_exception(method: str, - path: str, - args: RequestArguments, - session_vars: Dict[str, Any], - headers: Dict[str, str], - pagelet_variables: Dict[str, str]) -> Union[bytes, str, PageletResponse]: - raise HttpException(int(str(args.exc)), 'Test Exception') - - -@test_pagelet('/just/testing/value_error') -def test_pagelet_value_error(method: str, - path: str, - args: RequestArguments, - session_vars: Dict[str, Any], - headers: Dict[str, str], - pagelet_variables: Dict[str, str]) -> Union[bytes, str, PageletResponse]: - raise ValueError('test') - - -@test_pagelet('/just/testing/permission_error') -def test_pagelet_permission_error(method: str, - path: str, - args: RequestArguments, - session_vars: Dict[str, Any], - headers: Dict[str, str], - pagelet_variables: Dict[str, str]) -> Union[bytes, str, PageletResponse]: - raise PermissionError('test') - - -@test_pagelet('/just/testing/other_error') -def test_pagelet_other_error(method: str, - path: str, - args: RequestArguments, - session_vars: Dict[str, Any], - headers: Dict[str, str], - pagelet_variables: Dict[str, str]) -> Union[bytes, str, PageletResponse]: - raise TypeError('test') - - -class TestHttpd(AbstractHttpdTest): - - def test_httpd_get_illegal_path(self): - self.client_sock.set_request(b'GET /foo?bar?baz HTTP/1.1\r\n\r\n') - HttpHandler(self.client_sock, ('::1', 45678), self.server) - packet = self.client_sock.get_response() - self.assertEqual(400, packet.statuscode) - - def test_httpd_post_illegal_path(self): - self.client_sock.set_request(b'POST /foo?bar?baz HTTP/1.1\r\n' - b'Content-Length: 0\r\n' - b'Content-Type: application/x-www-form-urlencoded\r\n\r\n') - HttpHandler(self.client_sock, ('::1', 45678), self.server) - packet = self.client_sock.get_response() - self.assertEqual(400, packet.statuscode) - - def test_httpd_post_illegal_header(self): - self.client_sock.set_request(b'POST /foo?bar=baz HTTP/1.1\r\n' - b'Content-Length: 0\r\n' - b'Content-Type: application/octet-stream\r\n\r\n') - HttpHandler(self.client_sock, ('::1', 45678), self.server) - packet = self.client_sock.get_response() - self.assertEqual(400, packet.statuscode) - - def test_httpd_post_request_too_big(self): - self.client_sock.set_request(b'POST /foo?bar=baz HTTP/1.1\r\n' - b'Content-Length: 1000001\r\n' - b'Content-Type: application/octet-stream\r\n\r\n') - HttpHandler(self.client_sock, ('::1', 45678), self.server) - packet = self.client_sock.get_response() - self.assertEqual(413, packet.statuscode) - - def test_httpd_exception_http_400(self): - self.client_sock.set_request(b'GET /just/testing/http_exception?exc=400 HTTP/1.1\r\n\r\n') - HttpHandler(self.client_sock, ('::1', 45678), self.server) - packet = self.client_sock.get_response() - self.assertEqual(400, packet.statuscode) - - def test_httpd_exception_http_500(self): - self.client_sock.set_request(b'GET /just/testing/http_exception?exc=500 HTTP/1.1\r\n\r\n') - HttpHandler(self.client_sock, ('::1', 45678), self.server) - packet = self.client_sock.get_response() - self.assertEqual(500, packet.statuscode) - - def test_httpd_exception_value_error(self): - self.client_sock.set_request(b'GET /just/testing/value_error HTTP/1.1\r\n\r\n') - HttpHandler(self.client_sock, ('::1', 45678), self.server) - packet = self.client_sock.get_response() - self.assertEqual(400, packet.statuscode) - - def test_httpd_exception_permission_error(self): - self.client_sock.set_request(b'GET /just/testing/permission_error HTTP/1.1\r\n\r\n') - HttpHandler(self.client_sock, ('::1', 45678), self.server) - packet = self.client_sock.get_response() - self.assertEqual(403, packet.statuscode) - - def test_httpd_exception_other_error(self): - self.client_sock.set_request(b'GET /just/testing/other_error HTTP/1.1\r\n\r\n') - HttpHandler(self.client_sock, ('::1', 45678), self.server) - packet = self.client_sock.get_response() - self.assertEqual(500, packet.statuscode) diff --git a/matemat/webserver/test/test_pagelet_init.py b/matemat/webserver/test/test_pagelet_init.py deleted file mode 100644 index 0b2c45a..0000000 --- a/matemat/webserver/test/test_pagelet_init.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import Any, Dict - -import unittest -import http.client - -import logging -import threading - -from matemat.webserver import MatematWebserver, RequestArguments, pagelet_init, pagelet - - -@pagelet('/just/testing/init') -def init_test_pagelet(method: str, - path: str, - args: RequestArguments, - session_vars: Dict[str, Any], - headers: Dict[str, str], - pagelet_variables: Dict[str, str]): - return pagelet_variables['Unit-Test'] - - -_INIT_FAIL = False - - -@pagelet_init -def init(config: Dict[str, str], - logger: logging.Logger): - if _INIT_FAIL: - raise ValueError('This error should be raised!') - config['Unit-Test'] = 'Pagelet Init Test' - - -class TestPageletInitialization(unittest.TestCase): - - def setUp(self): - self.srv = MatematWebserver('::1', 0, '/nonexistent', '/nonexistent', {}, {}, - logging.NOTSET, logging.NullHandler()) - self.srv_port = int(self.srv._httpd.socket.getsockname()[1]) - self.timer = threading.Timer(5.0, self.srv._httpd.shutdown) - self.timer.start() - - def tearDown(self): - self.timer.cancel() - if self.srv is not None: - self.srv._httpd.socket.close() - global _INIT_FAIL - _INIT_FAIL = False - - def test_pagelet_init_ok(self): - """ - Test successful pagelet initialization - """ - thread = threading.Thread(target=self.srv.start) - thread.start() - con = http.client.HTTPConnection(f'[::1]:{self.srv_port}') - con.request('GET', '/just/testing/init') - response = con.getresponse().read() - self.srv._httpd.shutdown() - self.assertEqual(b'Pagelet Init Test', response) - - def test_pagelet_init_fail(self): - """ - Test unsuccessful pagelet initialization - """ - global _INIT_FAIL - _INIT_FAIL = True - with self.assertRaises(ValueError): - self.srv.start() diff --git a/matemat/webserver/test/test_session.py b/matemat/webserver/test/test_session.py index 224d18f..0ada390 100644 --- a/matemat/webserver/test/test_session.py +++ b/matemat/webserver/test/test_session.py @@ -1,26 +1,22 @@ -from typing import Any, Dict - from datetime import datetime, timedelta from time import sleep -from matemat.webserver import HttpHandler, RequestArguments -from matemat.webserver.test.abstract_httpd_test import AbstractHttpdTest, test_pagelet +from bottle import route, run + +from matemat.webserver import session +from matemat.webserver.test.abstract_httpd_test import AbstractHttpdTest -@test_pagelet('/just/testing/sessions') -def session_test_pagelet(method: str, - path: str, - args: RequestArguments, - session_vars: Dict[str, Any], - headers: Dict[str, str], - pagelet_variables: Dict[str, str]): - session_vars['test'] = 'hello, world!' - headers['Content-Type'] = 'text/plain' - return 'session test' +@route('/just/testing/sessions') +def session_test_pagelet(): + s = session.start() + session.put(s, 'test', 'hello, world!') + return f'{s}' class TestSession(AbstractHttpdTest): + """ Test session handling of the Matemat webserver. """ @@ -29,18 +25,12 @@ class TestSession(AbstractHttpdTest): # Reference date to make sure the session expiry lies in the future refdate: datetime = datetime.utcnow() + timedelta(seconds=3500) # Send a mock GET request for '/just/testing/sessions' - self.client_sock.set_request(b'GET /just/testing/sessions HTTP/1.1\r\n\r\n') - # Trigger request handling - handler = HttpHandler(self.client_sock, ('::1', 45678), self.server) - # Fetch the parsed response - packet = self.client_sock.get_response() - # Make sure a full HTTP response was parsed - self.assertEqual('done', packet.parse_phase) - # Make sure the request was served by the test pagelet - self.assertEqual('session_test_pagelet', packet.pagelet) - self.assertEqual(200, packet.statuscode) - session_id: str = list(handler.server.session_vars.keys())[0] + packet = self.request('/just/testing/sessions') + # Make sure the request was served by the test pagelet + self.assertEqual(200, packet.code) + + session_id: str = packet.read().decode() # Make sure a cookie was set - assuming that only one was set self.assertIn('Set-Cookie', packet.headers) # Split into the cookie itself @@ -54,9 +44,6 @@ class TestSession(AbstractHttpdTest): _, expdatestr = expiry.split('=', 1) expdate = datetime.strptime(expdatestr, '%a, %d %b %Y %H:%M:%S GMT') self.assertTrue(expdate > refdate) - # Make sure the session exists on the server - self.assertIn('test', handler.session_vars) - self.assertEqual('hello, world!', handler.session_vars['test']) def test_resume_session(self): # Test session expiry date diff --git a/static/css/matemat.css b/static/css/matemat.css index 2778f5c..a554c6d 100644 --- a/static/css/matemat.css +++ b/static/css/matemat.css @@ -1,7 +1,7 @@ .thumblist-item { display: inline-block; - margin: 10px; - padding: 10px; + margin: 5px; + padding: 5px; background: #f0f0f0; text-decoration: none; } @@ -11,14 +11,14 @@ } .thumblist-item .imgcontainer { - width: 150px; - height: 150px; + width: 100px; + height: 100px; position: relative; } .thumblist-item .imgcontainer img { - max-width: 150px; - max-height: 150px; + max-width: 100px; + max-height: 100px; position: absolute; top: 50%; left: 50%; @@ -30,6 +30,15 @@ font-weight: bolder; } +.thumblist-stock { + position: absolute; + z-index: 10; + bottom: 0; + right: 0; + background: #f0f0f0; + padding: 10px; +} + @media print { footer { position: fixed; diff --git a/templates/admin_all.html b/templates/admin_all.html index 55a2cc6..47f613b 100644 --- a/templates/admin_all.html +++ b/templates/admin_all.html @@ -31,7 +31,7 @@

Avatar

- Avatar of {{ authuser.name }}
+ Avatar of {{ authuser.name }}

diff --git a/templates/admin_restricted.html b/templates/admin_restricted.html index 72a262c..31f1240 100644 --- a/templates/admin_restricted.html +++ b/templates/admin_restricted.html @@ -94,12 +94,12 @@



diff --git a/templates/base.html b/templates/base.html index 414f711..f39da0f 100644 --- a/templates/base.html +++ b/templates/base.html @@ -43,7 +43,6 @@
  • {{ setupname|safe }}
  • Matemat {{ __version__ }} -
  • © 2018 s3lph
  • MIT License {# This used to be a link to the GitLab repo. However, users of the testing environment always clicked that link and couldn't come back, because the UI was running in touch-only kiosk mode. #} diff --git a/templates/modproduct.html b/templates/modproduct.html index 338df82..ab49953 100644 --- a/templates/modproduct.html +++ b/templates/modproduct.html @@ -24,7 +24,7 @@


    diff --git a/templates/moduser.html b/templates/moduser.html index b31b5c3..241f072 100644 --- a/templates/moduser.html +++ b/templates/moduser.html @@ -42,7 +42,7 @@


    diff --git a/templates/productlist.html b/templates/productlist.html index b763bd5..552856d 100644 --- a/templates/productlist.html +++ b/templates/productlist.html @@ -12,8 +12,13 @@ Your balance: {{ authuser.balance|chf }}
    {# Links to deposit two common amounts of cash. TODO: Will be replaced by a nicer UI later (#20) #} - Deposit CHF 1
    - Deposit CHF 10
    + + +
    {% for product in products %} {# Show an item per product, consisting of the name, image, price and stock, triggering a purchase on click #} @@ -26,9 +31,10 @@ {% else %} {{ product.price_non_member|chf }} {% endif %} - ; Stock: {{ product.stock }}
    +
    - Picture of {{ product.name }} + Picture of {{ product.name }} + {{ product.stock }}
    diff --git a/templates/touchkey.html b/templates/touchkey.html index 0d4934f..cb29708 100644 --- a/templates/touchkey.html +++ b/templates/touchkey.html @@ -4,7 +4,7 @@ {{ super() }} diff --git a/templates/userlist.html b/templates/userlist.html index cc42af8..4c73611 100644 --- a/templates/userlist.html +++ b/templates/userlist.html @@ -14,7 +14,7 @@ {{ user.name }}
    - Avatar of {{ user.name }} + Avatar of {{ user.name }}