Further bottle porting
This commit is contained in:
parent
8e8f159150
commit
e5c3fad812
18 changed files with 66 additions and 387 deletions
2
doc
2
doc
|
@ -1 +1 @@
|
|||
Subproject commit 0fcf4244f90d93cbc7be8e670aeaa30288d24ae3
|
||||
Subproject commit 8d6d6b6fece9d0b2dedc11ad41d4b96554a8ea36
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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(<read from somewhere>)
|
||||
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}')
|
||||
|
|
|
@ -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 = ''
|
||||
|
||||
|
|
|
@ -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)
|
|
@ -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()
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -31,7 +31,7 @@
|
|||
<h2>Avatar</h2>
|
||||
|
||||
<form id="admin-avatar-form" method="post" action="/admin?change=avatar" enctype="multipart/form-data" accept-charset="UTF-8">
|
||||
<img src="/upload/thumbnails/users/{{ authuser.id }}.png" alt="Avatar of {{ authuser.name }}" /><br/>
|
||||
<img src="/static/upload/thumbnails/users/{{ authuser.id }}.png" alt="Avatar of {{ authuser.name }}" /><br/>
|
||||
|
||||
<label for="admin-avatar-avatar">Upload new file: </label>
|
||||
<input id="admin-avatar-avatar" type="file" name="avatar" accept="image/*" /><br/>
|
||||
|
|
|
@ -94,12 +94,12 @@
|
|||
|
||||
<form id="admin-default-images-form" method="post" action="/admin?adminchange=defaultimg" enctype="multipart/form-data" accept-charset="UTF-8">
|
||||
<label for="admin-default-images-user">
|
||||
<img src="/upload/thumbnails/users/default.png" alt="Default user avatar" />
|
||||
<img src="/static/upload/thumbnails/users/default.png" alt="Default user avatar" />
|
||||
</label><br/>
|
||||
<input id="admin-default-images-user" type="file" name="users" accept="image/*" /><br/>
|
||||
|
||||
<label for="admin-default-images-product">
|
||||
<img src="/upload/thumbnails/products/default.png" alt="Default product avatar" />
|
||||
<img src="/static/upload/thumbnails/products/default.png" alt="Default product avatar" />
|
||||
</label><br/>
|
||||
<input id="admin-default-images-product" type="file" name="products" accept="image/*" /><br/>
|
||||
|
||||
|
|
|
@ -43,7 +43,6 @@
|
|||
<ul>
|
||||
<li> {{ setupname|safe }}
|
||||
<li> Matemat {{ __version__ }}
|
||||
<li> © 2018 s3lph
|
||||
<li> 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. #}
|
||||
|
|
|
@ -24,7 +24,7 @@
|
|||
<input id="modproduct-balance" name="stock" type="text" value="{{ product.stock }}" /><br/>
|
||||
|
||||
<label for="modproduct-image">
|
||||
<img height="150" src="/upload/thumbnails/products/{{ product.id }}.png" alt="Image of {{ product.name }}" />
|
||||
<img height="150" src="/static/upload/thumbnails/products/{{ product.id }}.png" alt="Image of {{ product.name }}" />
|
||||
</label><br/>
|
||||
<input id="modproduct-image" type="file" name="image" accept="image/*" /><br/>
|
||||
|
||||
|
|
|
@ -42,7 +42,7 @@
|
|||
<input id="moduser-account-balance-reason" type="text" name="reason" placeholder="Shows up on receipt" /><br/>
|
||||
|
||||
<label for="moduser-account-avatar">
|
||||
<img src="/upload/thumbnails/users/{{ user.id }}.png" alt="Avatar of {{ user.name }}" />
|
||||
<img src="/static/upload/thumbnails/users/{{ user.id }}.png" alt="Avatar of {{ user.name }}" />
|
||||
</label><br/>
|
||||
<input id="moduser-account-avatar" type="file" name="avatar" accept="image/*" /><br/>
|
||||
|
||||
|
|
|
@ -12,8 +12,13 @@
|
|||
Your balance: {{ authuser.balance|chf }}
|
||||
<br/>
|
||||
{# Links to deposit two common amounts of cash. TODO: Will be replaced by a nicer UI later (#20) #}
|
||||
<a href="/deposit?n=100">Deposit CHF 1</a><br/>
|
||||
<a href="/deposit?n=1000">Deposit CHF 10</a><br/>
|
||||
<div class="thumblist-item">
|
||||
<a href="/deposit?n=100">Deposit CHF 1</a>
|
||||
</div>
|
||||
<div class="thumblist-item">
|
||||
<a href="/deposit?n=1000">Deposit CHF 10</a>
|
||||
</div>
|
||||
<br/>
|
||||
|
||||
{% 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 }}</span><br/>
|
||||
</span><br/>
|
||||
<div class="imgcontainer">
|
||||
<img src="/upload/thumbnails/products/{{ product.id }}.png" alt="Picture of {{ product.name }}"/>
|
||||
<img src="/static/upload/thumbnails/products/{{ product.id }}.png" alt="Picture of {{ product.name }}"/>
|
||||
<span class="thumblist-stock">{{ product.stock }}</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
{{ super() }}
|
||||
<style>
|
||||
svg {
|
||||
width: 600px;
|
||||
width: 400px;
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
<a href="/touchkey?uid={{ user.id }}&username={{ user.name }}">
|
||||
<span class="thumblist-title">{{ user.name }}</span><br/>
|
||||
<div class="imgcontainer">
|
||||
<img src="/upload/thumbnails/users/{{ user.id }}.png" alt="Avatar of {{ user.name }}"/>
|
||||
<img src="/static/upload/thumbnails/users/{{ user.id }}.png" alt="Avatar of {{ user.name }}"/>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
|
Loading…
Reference in a new issue