Initial commit
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
**/__pycache__/
|
||||||
|
|
||||||
|
config/secret.toml
|
||||||
|
apps/*/config/secret.toml
|
||||||
3
README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Vassili's personal website and web app demos
|
||||||
|
|
||||||
|
This is a small collection of my personal projects
|
||||||
3
apps/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
from .account import app as account
|
||||||
|
from .calendar import app as calendar
|
||||||
|
from .reporter import app as reporter
|
||||||
0
apps/account/__init__.py
Normal file
48
apps/account/app.py
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
from aiohttp import web
|
||||||
|
import aiohttp_security
|
||||||
|
|
||||||
|
import security
|
||||||
|
|
||||||
|
def init_app(app):
|
||||||
|
routes = web.RouteTableDef()
|
||||||
|
|
||||||
|
@routes.get('/in')
|
||||||
|
async def get_login(request):
|
||||||
|
status = request.query.get('status', '401')
|
||||||
|
if status == '401':
|
||||||
|
message = "Please enter the password.<br><code>userpass</code> for regular access,<br><code>adminpass</code> for elevated permissions."
|
||||||
|
elif status == '403':
|
||||||
|
message = "Elevated permissions required.<br><code>adminpass</code> for elevated permissions."
|
||||||
|
else:
|
||||||
|
message = "This shouldn't happen."
|
||||||
|
response = request.config_dict['templates']["login.html"].safe_substitute({
|
||||||
|
'title': 'Log in to try the demo',
|
||||||
|
'message': message,
|
||||||
|
'url': request.query.get('url', '/')
|
||||||
|
})
|
||||||
|
return web.Response(text=response, content_type="text/html")
|
||||||
|
|
||||||
|
@routes.post('/in')
|
||||||
|
async def post_login(request):
|
||||||
|
postdata = await request.post()
|
||||||
|
url = postdata.get('url', '/')
|
||||||
|
identity = security.try_password(postdata["password"])
|
||||||
|
if identity:
|
||||||
|
redirect_response = web.HTTPFound(url)
|
||||||
|
await aiohttp_security.remember(request, redirect_response, identity)
|
||||||
|
raise redirect_response
|
||||||
|
else:
|
||||||
|
response = request.config_dict['templates']["login.html"].safe_substitute({
|
||||||
|
'title': 'Log in to try the demo',
|
||||||
|
'message': "Incorrect password.<br><code>userpass</code> for regular access,<br><code>adminpass</code> for elevated permissions.",
|
||||||
|
'url': url
|
||||||
|
})
|
||||||
|
return web.Response(text=response, content_type="text/html")
|
||||||
|
|
||||||
|
@routes.get('/out')
|
||||||
|
async def get_logout(request):
|
||||||
|
redirect_response = web.HTTPFound('/')
|
||||||
|
await aiohttp_security.forget(request, redirect_response)
|
||||||
|
raise redirect_response
|
||||||
|
|
||||||
|
app.add_routes(routes)
|
||||||
6
apps/account/static/css/login.css
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.form-signin {
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
26
apps/account/templates/login.html
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${title}</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="/static/bootstrap/bootstrap.min.css">
|
||||||
|
<link rel="stylesheet" type="text/css" href="./static/css/login.css">
|
||||||
|
<link rel="shortcut icon" type="image/png" href="/static/favicon.png">
|
||||||
|
<link rel="shortcut icon" sizes="192x192" href="/static/favicon.png">
|
||||||
|
<link rel="apple-touch-icon" href="/static/favicon.png">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="d-flex align-items-center py-4">
|
||||||
|
<main class="form-signin w-100 m-auto">
|
||||||
|
<form method="post" action="/log/in" autocomplete="off" id="login">
|
||||||
|
<h3 class="h3 mb-3 font-weight-normal text-center">${message}</h3>
|
||||||
|
<input type="password" id="password" name="password" class="form-control mb-3" placeholder="Password" required autofocus>
|
||||||
|
<input type="hidden" id="url" name="url" value="${url}">
|
||||||
|
<button class="btn btn-lg btn-primary w-100" type="submit">Sign in</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
0
apps/calendar/__init__.py
Normal file
118
apps/calendar/app.py
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
from aiohttp import web
|
||||||
|
import aiohttp_security
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
import security
|
||||||
|
|
||||||
|
def logevent(request, query):
|
||||||
|
real_ip = request.headers.get('X-Real-IP', 'unknown')
|
||||||
|
print(f"IP: {real_ip} - Request: {query}")
|
||||||
|
|
||||||
|
def datetime_format(postdata):
|
||||||
|
formatted = {}
|
||||||
|
for s in ("timefrom", "timeto"):
|
||||||
|
formatted[s] = datetime.datetime.strptime(postdata[s], '%H:%M').strftime('%H:%M:%S')
|
||||||
|
return formatted
|
||||||
|
|
||||||
|
def init_app(app):
|
||||||
|
async def db_query(query, params=()):
|
||||||
|
await app['cur'].execute(query, params)
|
||||||
|
q = app['cur'].mogrify(query, params)
|
||||||
|
r = await app['cur'].fetchall()
|
||||||
|
result = [{k: (str(v) if isinstance(v, datetime.date) or isinstance(v, datetime.timedelta) else v) for k, v in row.items()} for row in r]
|
||||||
|
return (q, result)
|
||||||
|
|
||||||
|
routes = web.RouteTableDef()
|
||||||
|
|
||||||
|
@routes.get('')
|
||||||
|
async def bare_redirect(request):
|
||||||
|
raise web.HTTPFound(app['prefix'])
|
||||||
|
|
||||||
|
@routes.get('/')
|
||||||
|
async def home(request):
|
||||||
|
await aiohttp_security.check_permission(request, 'user')
|
||||||
|
response = request.app['templates']["index.html"].safe_substitute({'disabled': '', 'user': 'user'})
|
||||||
|
return web.Response(text=response, content_type="text/html")
|
||||||
|
|
||||||
|
@routes.get('/admin')
|
||||||
|
async def admin(request):
|
||||||
|
await aiohttp_security.check_permission(request, 'admin')
|
||||||
|
response = request.app['templates']["index.html"].safe_substitute({'disabled': 'disabled', 'user': 'admin'})
|
||||||
|
return web.Response(text=response, content_type="text/html")
|
||||||
|
|
||||||
|
@routes.get('/events/day/{day}')
|
||||||
|
async def get_events_daily(request):
|
||||||
|
await aiohttp_security.check_permission(request, 'user')
|
||||||
|
querystring = "SELECT * FROM events WHERE deleted=false AND %s BETWEEN datefrom AND dateto;"
|
||||||
|
if request.match_info['day']:
|
||||||
|
query, response = await db_query(querystring, (request.match_info['day'],))
|
||||||
|
else:
|
||||||
|
query, response = await db_query(querystring.replace("%s", "CURDATE()"))
|
||||||
|
return web.json_response(response)
|
||||||
|
|
||||||
|
@routes.get('/events/month/{month}')
|
||||||
|
async def get_events_monthly(request):
|
||||||
|
await aiohttp_security.check_permission(request, 'user')
|
||||||
|
querystring = "SELECT * FROM events WHERE deleted=false AND %s BETWEEN DATE_FORMAT(datefrom, '%%Y-%%m') AND DATE_FORMAT(dateto, '%%Y-%%m');"
|
||||||
|
if request.match_info['month']:
|
||||||
|
query, response = await db_query(querystring, (request.match_info['month'],))
|
||||||
|
else:
|
||||||
|
query, response = await db_query(querystring.replace("%s", "DATE_FORMAT(CURDATE(), '%%Y-%%m')"))
|
||||||
|
return web.json_response(response)
|
||||||
|
|
||||||
|
@routes.get('/admin/day/{day}')
|
||||||
|
async def get_events_daily(request):
|
||||||
|
await aiohttp_security.check_permission(request, 'admin')
|
||||||
|
querystring = "SELECT * FROM events WHERE %s BETWEEN datefrom AND dateto;"
|
||||||
|
if request.match_info['day']:
|
||||||
|
query, response = await db_query(querystring, (request.match_info['day'],))
|
||||||
|
else:
|
||||||
|
query, response = await db_query(querystring.replace("%s", "CURDATE()"))
|
||||||
|
return web.json_response(response)
|
||||||
|
|
||||||
|
@routes.get('/admin/month/{month}')
|
||||||
|
async def get_events_monthly(request):
|
||||||
|
await aiohttp_security.check_permission(request, 'admin')
|
||||||
|
querystring = "SELECT * FROM events WHERE %s BETWEEN DATE_FORMAT(datefrom, '%%Y-%%m') AND DATE_FORMAT(dateto, '%%Y-%%m');"
|
||||||
|
if request.match_info['month']:
|
||||||
|
query, response = await db_query(querystring, (request.match_info['month'],))
|
||||||
|
else:
|
||||||
|
query, response = await db_query(querystring.replace("%s", "DATE_FORMAT(CURDATE(), '%%Y-%%m')"))
|
||||||
|
return web.json_response(response)
|
||||||
|
|
||||||
|
@routes.post('/event/add')
|
||||||
|
async def event_add(request):
|
||||||
|
await aiohttp_security.check_permission(request, 'user')
|
||||||
|
postdata = await request.post()
|
||||||
|
formatted = datetime_format(postdata)
|
||||||
|
querystring = "INSERT INTO events(name, message, timefrom, timeto, timetbd, datefrom, dateto, datetbd) VALUES(%s,%s,%s,%s,%s,%s,%s,%s);"
|
||||||
|
params = (postdata["name"], postdata["message"], formatted["timefrom"], formatted["timeto"], "timetbd" in postdata, postdata["datefrom"], postdata["dateto"], "datetbd" in postdata)
|
||||||
|
query, response = await db_query(querystring, params)
|
||||||
|
# await update(request)
|
||||||
|
logevent(request, query)
|
||||||
|
raise web.HTTPFound(app['prefix'])
|
||||||
|
|
||||||
|
@routes.post('/event/{id}/edit')
|
||||||
|
async def event_edit(request):
|
||||||
|
await aiohttp_security.check_permission(request, 'user')
|
||||||
|
postdata = await request.post()
|
||||||
|
formatted = datetime_format(postdata)
|
||||||
|
querystring = "UPDATE events SET name=%s, message=%s, timefrom=%s, timeto=%s, timetbd=%s, datefrom=%s, dateto=%s, datetbd=%s WHERE id=%s;"
|
||||||
|
params = (postdata["name"], postdata["message"], formatted["timefrom"], formatted["timeto"], "timetbd" in postdata,
|
||||||
|
postdata["datefrom"], postdata["dateto"], "datetbd" in postdata, request.match_info['id'])
|
||||||
|
query, response = await db_query(querystring, params)
|
||||||
|
# await update(request)
|
||||||
|
logevent(request, query)
|
||||||
|
raise web.HTTPFound(app['prefix'])
|
||||||
|
|
||||||
|
@routes.post('/event/{id}/delete')
|
||||||
|
async def event_delete(request):
|
||||||
|
await aiohttp_security.check_permission(request, 'user')
|
||||||
|
postdata = await request.post()
|
||||||
|
querystring = "UPDATE events SET deleted=true WHERE id=%s;"
|
||||||
|
query, response = await db_query(querystring, (request.match_info['id'],))
|
||||||
|
# await update(request)
|
||||||
|
logevent(request, query)
|
||||||
|
raise web.HTTPFound(app['prefix'])
|
||||||
|
|
||||||
|
app.add_routes(routes)
|
||||||
2
apps/calendar/config/config.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
[app]
|
||||||
|
name = "Event Calendar"
|
||||||
4
apps/calendar/static/calendar-week.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-calendar-week" viewBox="0 0 16 16">
|
||||||
|
<path d="M11 6.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5zm-3 0a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5zm-5 3a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5zm3 0a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5z"/>
|
||||||
|
<path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5M1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 659 B |
17
apps/calendar/static/css/common.css
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
* {
|
||||||
|
font-family: 'Montserrat', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.min-height-0 {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
form .form-control {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
form .form-control:focus {
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.form-signin {
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
148
apps/calendar/static/css/style.css
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
/*---------- HEAD ----------*/
|
||||||
|
.v-select {
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
border-radius: .3rem;
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
#selected_date {
|
||||||
|
width: 25vw;
|
||||||
|
min-width: 210px;
|
||||||
|
}
|
||||||
|
#checkout {
|
||||||
|
width: 20vw;
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*---------- DAILY ---------*/
|
||||||
|
#allday {
|
||||||
|
width: 250px;
|
||||||
|
max-width: 30%;
|
||||||
|
}
|
||||||
|
#allday .event {
|
||||||
|
min-height: 10vh;
|
||||||
|
}
|
||||||
|
#timings {
|
||||||
|
background-color: white;
|
||||||
|
color: #AAAAAA; /* How I feel about this */
|
||||||
|
font-size: 2vmin;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#timings div {
|
||||||
|
z-index: 10;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#timings span {
|
||||||
|
font-size: 3vmin;
|
||||||
|
color: #696969;
|
||||||
|
}
|
||||||
|
|
||||||
|
#events {
|
||||||
|
z-index: 20;
|
||||||
|
flex: 1 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
background-color: white;
|
||||||
|
box-shadow: 1px -1px lightgrey;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 3vmin;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event .title {
|
||||||
|
font-size: 4vmin;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-color {
|
||||||
|
width: 1vmin;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timetbd {
|
||||||
|
background-image: linear-gradient(to bottom, white, rgba(var(--bs-light-rgb), 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
.timetbd .border-color {
|
||||||
|
background-image: linear-gradient(to bottom, rgba(0,0,0,0), rgba(var(--bs-light-rgb), 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#now {
|
||||||
|
position: absolute;
|
||||||
|
height: 2px;
|
||||||
|
left:0;right:0;
|
||||||
|
background-color: red;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*--------- MONTHLY --------*/
|
||||||
|
#monthlycalendar {
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
}
|
||||||
|
#monthlycalendar .row {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 11rem;
|
||||||
|
}
|
||||||
|
.cell {
|
||||||
|
padding: .5rem;
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
}
|
||||||
|
.notcurrent {
|
||||||
|
color: #888;
|
||||||
|
background-color: #f8f8f8;
|
||||||
|
}
|
||||||
|
.today {
|
||||||
|
color: #32acea;
|
||||||
|
border-color: #32acea;
|
||||||
|
}
|
||||||
|
.weekend { color: #f88; }
|
||||||
|
|
||||||
|
#daynames {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
#daynames .col {
|
||||||
|
padding: 0!important;
|
||||||
|
}
|
||||||
|
.monthevent {
|
||||||
|
position: absolute;
|
||||||
|
color: #fff;
|
||||||
|
padding: .25rem;
|
||||||
|
border-radius: .25rem;
|
||||||
|
width: auto;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.datetbd2 {
|
||||||
|
border-image: linear-gradient(to right, #32acea, #F0F0F0) 1 100%;
|
||||||
|
background-image: linear-gradient(to right, white, #F0F0F0);
|
||||||
|
}
|
||||||
|
.expandable {
|
||||||
|
background: url("/calendar/static/png/16_down.png") repeat-x bottom;
|
||||||
|
}
|
||||||
|
.contractable {
|
||||||
|
background: url("/calendar/static/png/16_up.png") repeat-x bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*---------- FORM ----------*/
|
||||||
|
textarea { resize: none; }
|
||||||
|
|
||||||
|
#name{
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
}
|
||||||
|
#message{
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
}
|
||||||
|
#eventFooter {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
/*#eventform input[type=checkbox] {
|
||||||
|
transform: scale(2);
|
||||||
|
}*/
|
||||||
BIN
apps/calendar/static/favicon.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
18
apps/calendar/static/js/admin.js
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
const update = () => {
|
||||||
|
let request = new XMLHttpRequest();
|
||||||
|
if (selected_cal_type == "daily") {
|
||||||
|
request.open('GET', "/calendar/admin/day/" + selected_date.format('YYYY-MM-DD'), true);
|
||||||
|
request.onload = () => { day_onload(request) };
|
||||||
|
}
|
||||||
|
else if (selected_cal_type == "monthly") {
|
||||||
|
request.open('GET', "/calendar/admin/month/" + selected_date.format('YYYY-MM'), true);
|
||||||
|
request.onload = () => { month_onload(request) };
|
||||||
|
}
|
||||||
|
request.send();
|
||||||
|
//update_timeout = setTimeout(update, 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
init_times(selected_date);
|
||||||
|
init_event_listeners();
|
||||||
|
update();
|
||||||
|
//listen();
|
||||||
21
apps/calendar/static/js/cookie.js
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
class Cookie {
|
||||||
|
static set(name,value,expiry) {
|
||||||
|
let expires = expiry ? "; expires="+expiry.toGMTString() : "";
|
||||||
|
document.cookie = name+"="+value+expires+"; path=/";
|
||||||
|
}
|
||||||
|
|
||||||
|
static get(name) {
|
||||||
|
let nameEQ = name + "=";
|
||||||
|
let ca = document.cookie.split(';');
|
||||||
|
for(let i=0;i < ca.length;i++) {
|
||||||
|
let c = ca[i];
|
||||||
|
while (c.charAt(0)==' ') c = c.substring(1,c.length);
|
||||||
|
if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length,c.length);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static erase(name) {
|
||||||
|
Cookie.set(name,"",new Date());
|
||||||
|
}
|
||||||
|
};
|
||||||
1
apps/calendar/static/js/dayjs.customParseFormat.js
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).dayjs_plugin_customParseFormat=t()}(this,(function(){"use strict";var e={LTS:"h:mm:ss A",LT:"h:mm A",L:"MM/DD/YYYY",LL:"MMMM D, YYYY",LLL:"MMMM D, YYYY h:mm A",LLLL:"dddd, MMMM D, YYYY h:mm A"},t=/(\[[^[]*\])|([-:/.()\s]+)|(A|a|YYYY|YY?|MM?M?M?|Do|DD?|hh?|HH?|mm?|ss?|S{1,3}|z|ZZ?)/g,n=/\d\d/,r=/\d\d?/,i=/\d*[^\s\d-_:/()]+/,o={},s=function(e){return(e=+e)+(e>68?1900:2e3)};var a=function(e){return function(t){this[e]=+t}},f=[/[+-]\d\d:?(\d\d)?|Z/,function(e){(this.zone||(this.zone={})).offset=function(e){if(!e)return 0;if("Z"===e)return 0;var t=e.match(/([+-]|\d\d)/g),n=60*t[1]+(+t[2]||0);return 0===n?0:"+"===t[0]?-n:n}(e)}],h=function(e){var t=o[e];return t&&(t.indexOf?t:t.s.concat(t.f))},u=function(e,t){var n,r=o.meridiem;if(r){for(var i=1;i<=24;i+=1)if(e.indexOf(r(i,0,t))>-1){n=i>12;break}}else n=e===(t?"pm":"PM");return n},d={A:[i,function(e){this.afternoon=u(e,!1)}],a:[i,function(e){this.afternoon=u(e,!0)}],S:[/\d/,function(e){this.milliseconds=100*+e}],SS:[n,function(e){this.milliseconds=10*+e}],SSS:[/\d{3}/,function(e){this.milliseconds=+e}],s:[r,a("seconds")],ss:[r,a("seconds")],m:[r,a("minutes")],mm:[r,a("minutes")],H:[r,a("hours")],h:[r,a("hours")],HH:[r,a("hours")],hh:[r,a("hours")],D:[r,a("day")],DD:[n,a("day")],Do:[i,function(e){var t=o.ordinal,n=e.match(/\d+/);if(this.day=n[0],t)for(var r=1;r<=31;r+=1)t(r).replace(/\[|\]/g,"")===e&&(this.day=r)}],M:[r,a("month")],MM:[n,a("month")],MMM:[i,function(e){var t=h("months"),n=(h("monthsShort")||t.map((function(e){return e.slice(0,3)}))).indexOf(e)+1;if(n<1)throw new Error;this.month=n%12||n}],MMMM:[i,function(e){var t=h("months").indexOf(e)+1;if(t<1)throw new Error;this.month=t%12||t}],Y:[/[+-]?\d+/,a("year")],YY:[n,function(e){this.year=s(e)}],YYYY:[/\d{4}/,a("year")],Z:f,ZZ:f};function c(n){var r,i;r=n,i=o&&o.formats;for(var s=(n=r.replace(/(\[[^\]]+])|(LTS?|l{1,4}|L{1,4})/g,(function(t,n,r){var o=r&&r.toUpperCase();return n||i[r]||e[r]||i[o].replace(/(\[[^\]]+])|(MMMM|MM|DD|dddd)/g,(function(e,t,n){return t||n.slice(1)}))}))).match(t),a=s.length,f=0;f<a;f+=1){var h=s[f],u=d[h],c=u&&u[0],l=u&&u[1];s[f]=l?{regex:c,parser:l}:h.replace(/^\[|\]$/g,"")}return function(e){for(var t={},n=0,r=0;n<a;n+=1){var i=s[n];if("string"==typeof i)r+=i.length;else{var o=i.regex,f=i.parser,h=e.slice(r),u=o.exec(h)[0];f.call(t,u),e=e.replace(u,"")}}return function(e){var t=e.afternoon;if(void 0!==t){var n=e.hours;t?n<12&&(e.hours+=12):12===n&&(e.hours=0),delete e.afternoon}}(t),t}}return function(e,t,n){n.p.customParseFormat=!0,e&&e.parseTwoDigitYear&&(s=e.parseTwoDigitYear);var r=t.prototype,i=r.parse;r.parse=function(e){var t=e.date,r=e.utc,s=e.args;this.$u=r;var a=s[1];if("string"==typeof a){var f=!0===s[2],h=!0===s[3],u=f||h,d=s[2];h&&(d=s[2]),o=this.$locale(),!f&&d&&(o=n.Ls[d]),this.$d=function(e,t,n){try{if(["x","X"].indexOf(t)>-1)return new Date(("X"===t?1e3:1)*e);var r=c(t)(e),i=r.year,o=r.month,s=r.day,a=r.hours,f=r.minutes,h=r.seconds,u=r.milliseconds,d=r.zone,l=new Date,m=s||(i||o?1:l.getDate()),M=i||l.getFullYear(),Y=0;i&&!o||(Y=o>0?o-1:l.getMonth());var p=a||0,v=f||0,D=h||0,g=u||0;return d?new Date(Date.UTC(M,Y,m,p,v,D,g+60*d.offset*1e3)):n?new Date(Date.UTC(M,Y,m,p,v,D,g)):new Date(M,Y,m,p,v,D,g)}catch(e){return new Date("")}}(t,a,r),this.init(),d&&!0!==d&&(this.$L=this.locale(d).$L),u&&t!=this.format(a)&&(this.$d=new Date("")),o={}}else if(a instanceof Array)for(var l=a.length,m=1;m<=l;m+=1){s[1]=a[m-1];var M=n.apply(this,s);if(M.isValid()){this.$d=M.$d,this.$L=M.$L,this.init();break}m===l&&(this.$d=new Date(""))}else i.call(this,e)}}}));
|
||||||
1
apps/calendar/static/js/dayjs.duration.js
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
!function(t,s){"object"==typeof exports&&"undefined"!=typeof module?module.exports=s():"function"==typeof define&&define.amd?define(s):(t="undefined"!=typeof globalThis?globalThis:t||self).dayjs_plugin_duration=s()}(this,(function(){"use strict";var t,s,n=1e3,i=6e4,e=36e5,r=864e5,o=/\[([^\]]+)]|Y{1,4}|M{1,4}|D{1,2}|d{1,4}|H{1,2}|h{1,2}|a|A|m{1,2}|s{1,2}|Z{1,2}|SSS/g,u=31536e6,h=2592e6,a=/^(-|\+)?P(?:([-+]?[0-9,.]*)Y)?(?:([-+]?[0-9,.]*)M)?(?:([-+]?[0-9,.]*)W)?(?:([-+]?[0-9,.]*)D)?(?:T(?:([-+]?[0-9,.]*)H)?(?:([-+]?[0-9,.]*)M)?(?:([-+]?[0-9,.]*)S)?)?$/,d={years:u,months:h,days:r,hours:e,minutes:i,seconds:n,milliseconds:1,weeks:6048e5},c=function(t){return t instanceof p},f=function(t,s,n){return new p(t,n,s.$l)},m=function(t){return s.p(t)+"s"},l=function(t){return t<0},$=function(t){return l(t)?Math.ceil(t):Math.floor(t)},y=function(t){return Math.abs(t)},g=function(t,s){return t?l(t)?{negative:!0,format:""+y(t)+s}:{negative:!1,format:""+t+s}:{negative:!1,format:""}},p=function(){function l(t,s,n){var i=this;if(this.$d={},this.$l=n,void 0===t&&(this.$ms=0,this.parseFromMilliseconds()),s)return f(t*d[m(s)],this);if("number"==typeof t)return this.$ms=t,this.parseFromMilliseconds(),this;if("object"==typeof t)return Object.keys(t).forEach((function(s){i.$d[m(s)]=t[s]})),this.calMilliseconds(),this;if("string"==typeof t){var e=t.match(a);if(e){var r=e.slice(2).map((function(t){return null!=t?Number(t):0}));return this.$d.years=r[0],this.$d.months=r[1],this.$d.weeks=r[2],this.$d.days=r[3],this.$d.hours=r[4],this.$d.minutes=r[5],this.$d.seconds=r[6],this.calMilliseconds(),this}}return this}var y=l.prototype;return y.calMilliseconds=function(){var t=this;this.$ms=Object.keys(this.$d).reduce((function(s,n){return s+(t.$d[n]||0)*d[n]}),0)},y.parseFromMilliseconds=function(){var t=this.$ms;this.$d.years=$(t/u),t%=u,this.$d.months=$(t/h),t%=h,this.$d.days=$(t/r),t%=r,this.$d.hours=$(t/e),t%=e,this.$d.minutes=$(t/i),t%=i,this.$d.seconds=$(t/n),t%=n,this.$d.milliseconds=t},y.toISOString=function(){var t=g(this.$d.years,"Y"),s=g(this.$d.months,"M"),n=+this.$d.days||0;this.$d.weeks&&(n+=7*this.$d.weeks);var i=g(n,"D"),e=g(this.$d.hours,"H"),r=g(this.$d.minutes,"M"),o=this.$d.seconds||0;this.$d.milliseconds&&(o+=this.$d.milliseconds/1e3);var u=g(o,"S"),h=t.negative||s.negative||i.negative||e.negative||r.negative||u.negative,a=e.format||r.format||u.format?"T":"",d=(h?"-":"")+"P"+t.format+s.format+i.format+a+e.format+r.format+u.format;return"P"===d||"-P"===d?"P0D":d},y.toJSON=function(){return this.toISOString()},y.format=function(t){var n=t||"YYYY-MM-DDTHH:mm:ss",i={Y:this.$d.years,YY:s.s(this.$d.years,2,"0"),YYYY:s.s(this.$d.years,4,"0"),M:this.$d.months,MM:s.s(this.$d.months,2,"0"),D:this.$d.days,DD:s.s(this.$d.days,2,"0"),H:this.$d.hours,HH:s.s(this.$d.hours,2,"0"),m:this.$d.minutes,mm:s.s(this.$d.minutes,2,"0"),s:this.$d.seconds,ss:s.s(this.$d.seconds,2,"0"),SSS:s.s(this.$d.milliseconds,3,"0")};return n.replace(o,(function(t,s){return s||String(i[t])}))},y.as=function(t){return this.$ms/d[m(t)]},y.get=function(t){var s=this.$ms,n=m(t);return"milliseconds"===n?s%=1e3:s="weeks"===n?$(s/d[n]):this.$d[n],0===s?0:s},y.add=function(t,s,n){var i;return i=s?t*d[m(s)]:c(t)?t.$ms:f(t,this).$ms,f(this.$ms+i*(n?-1:1),this)},y.subtract=function(t,s){return this.add(t,s,!0)},y.locale=function(t){var s=this.clone();return s.$l=t,s},y.clone=function(){return f(this.$ms,this)},y.humanize=function(s){return t().add(this.$ms,"ms").locale(this.$l).fromNow(!s)},y.milliseconds=function(){return this.get("milliseconds")},y.asMilliseconds=function(){return this.as("milliseconds")},y.seconds=function(){return this.get("seconds")},y.asSeconds=function(){return this.as("seconds")},y.minutes=function(){return this.get("minutes")},y.asMinutes=function(){return this.as("minutes")},y.hours=function(){return this.get("hours")},y.asHours=function(){return this.as("hours")},y.days=function(){return this.get("days")},y.asDays=function(){return this.as("days")},y.weeks=function(){return this.get("weeks")},y.asWeeks=function(){return this.as("weeks")},y.months=function(){return this.get("months")},y.asMonths=function(){return this.as("months")},y.years=function(){return this.get("years")},y.asYears=function(){return this.as("years")},l}();return function(n,i,e){t=e,s=e().$utils(),e.duration=function(t,s){var n=e.locale();return f(t,{$l:n},s)},e.isDuration=c;var r=i.prototype.add,o=i.prototype.subtract;i.prototype.add=function(t,s){return c(t)&&(t=t.asMilliseconds()),r.bind(this)(t,s)},i.prototype.subtract=function(t,s){return c(t)&&(t=t.asMilliseconds()),o.bind(this)(t,s)}}}));
|
||||||
1
apps/calendar/static/js/dayjs.isBetween.js
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
!function(e,i){"object"==typeof exports&&"undefined"!=typeof module?module.exports=i():"function"==typeof define&&define.amd?define(i):(e="undefined"!=typeof globalThis?globalThis:e||self).dayjs_plugin_isBetween=i()}(this,(function(){"use strict";return function(e,i,t){i.prototype.isBetween=function(e,i,s,f){var n=t(e),o=t(i),r="("===(f=f||"()")[0],u=")"===f[1];return(r?this.isAfter(n,s):!this.isBefore(n,s))&&(u?this.isBefore(o,s):!this.isAfter(o,s))||(r?this.isBefore(n,s):!this.isAfter(n,s))&&(u?this.isAfter(o,s):!this.isBefore(o,s))}}}));
|
||||||
1
apps/calendar/static/js/dayjs.min.js
vendored
Normal file
1
apps/calendar/static/js/dayjs.minMax.js
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
!function(e,n){"object"==typeof exports&&"undefined"!=typeof module?module.exports=n():"function"==typeof define&&define.amd?define(n):(e="undefined"!=typeof globalThis?globalThis:e||self).dayjs_plugin_minMax=n()}(this,(function(){"use strict";return function(e,n,t){var i=function(e,n){if(!n||!n.length||!n[0]||1===n.length&&!n[0].length)return null;var t;1===n.length&&n[0].length>0&&(n=n[0]);t=n[0];for(var i=1;i<n.length;i+=1)n[i].isValid()&&!n[i][e](t)||(t=n[i]);return t};t.max=function(){var e=[].slice.call(arguments,0);return i("isAfter",e)},t.min=function(){var e=[].slice.call(arguments,0);return i("isBefore",e)}}}));
|
||||||
511
apps/calendar/static/js/script.js
Normal file
|
|
@ -0,0 +1,511 @@
|
||||||
|
dayjs.extend(window.dayjs_plugin_customParseFormat)
|
||||||
|
dayjs.extend(window.dayjs_plugin_minMax)
|
||||||
|
dayjs.extend(window.dayjs_plugin_isBetween)
|
||||||
|
dayjs.extend(window.dayjs_plugin_duration)
|
||||||
|
dayjs.isSameOrBefore = (e,t) => {return e.isSame(t) || e.isBefore(t)}
|
||||||
|
dayjs.isSameOrAfter = (e,t) => {return e.isSame(t) || e.isAfter(t)}
|
||||||
|
|
||||||
|
const el = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
let selected_cal_type = Cookie.get('caltype') ?? "daily";
|
||||||
|
el("caltype").value = selected_cal_type;
|
||||||
|
|
||||||
|
const date = Cookie.get('date');
|
||||||
|
let selected_date = date ? dayjs(date) : dayjs().startOf('day');
|
||||||
|
el('selected_date').value = selected_date.format('YYYY-MM-DD');
|
||||||
|
|
||||||
|
let selected_event = null;
|
||||||
|
let update_timeout = null;
|
||||||
|
let lastPicker = null;
|
||||||
|
let week_starts = [];
|
||||||
|
let weeks = [];
|
||||||
|
|
||||||
|
let startOfWorkDay, endOfWorkDay, startOfDay, endOfDay, startOfMonth, endOfMonth, startOfCalMonth, endOfCalMonth, lastEvent;
|
||||||
|
|
||||||
|
const init_times = (today) => {
|
||||||
|
startOfWorkDay = today.hour(8).minute(0).second(0);
|
||||||
|
endOfWorkDay = today.hour(17).minute(0).second(0);
|
||||||
|
lastEvent = today.hour(16).minute(30).second(0);
|
||||||
|
startOfDay = today.startOf('day');
|
||||||
|
endOfDay = today.endOf('day');
|
||||||
|
startOfMonth = today.startOf('month');
|
||||||
|
endOfMonth = today.endOf('month');
|
||||||
|
startOfCalMonth = dayjs(startOfMonth).startOf('week');
|
||||||
|
endOfCalMonth = dayjs(endOfMonth).endOf('week');
|
||||||
|
}
|
||||||
|
|
||||||
|
const create_daily_event = (event) => {
|
||||||
|
let node = document.createElement("DIV");
|
||||||
|
node.classList.add("event");
|
||||||
|
if (event.timetbd && !event.datetbd) {
|
||||||
|
node.classList.add("timetbd");
|
||||||
|
}
|
||||||
|
|
||||||
|
let border = document.createElement("DIV");
|
||||||
|
border.classList.add("border-color");
|
||||||
|
border.style.backgroundColor = event.colour;
|
||||||
|
node.appendChild(border);
|
||||||
|
|
||||||
|
let content = document.createElement("DIV");
|
||||||
|
content.classList.add("p-1", "flex-grow-1");
|
||||||
|
let html = `<span class='title'>${event.name}</span>`;
|
||||||
|
if (event.message) {
|
||||||
|
html += `<span> - ${event.message.replaceAll('\n','<br>')}</span>`;
|
||||||
|
}
|
||||||
|
content.innerHTML = html;
|
||||||
|
node.appendChild(content);
|
||||||
|
|
||||||
|
node.addEventListener("click", () => { eventedit(event); } );
|
||||||
|
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
const create_monthly_event = (left, top, right, event, week) => {
|
||||||
|
let node = document.createElement("DIV");
|
||||||
|
node.className = "monthevent text-truncate";
|
||||||
|
if (event.datetbd) {
|
||||||
|
node.className += " datetbd";
|
||||||
|
}
|
||||||
|
node.title = event.message ? `${event.name} - ${event.message}` : event.name || ' ';
|
||||||
|
node.innerHTML = node.title;
|
||||||
|
|
||||||
|
node.style.left = left + "%";
|
||||||
|
node.style.top = top + "rem";
|
||||||
|
node.style.right = right + "%";
|
||||||
|
node.style.backgroundColor = event.colour;
|
||||||
|
|
||||||
|
node.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
eventedit(event);
|
||||||
|
} );
|
||||||
|
|
||||||
|
week.append(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
const collide = (first, second, domain) => {
|
||||||
|
return ( first[domain + "fromeffective"].isBetween(second[domain + "fromeffective"], second[domain + "toeffective"], null, '[]') ||
|
||||||
|
second[domain + "fromeffective"].isBetween( first[domain + "fromeffective"], first[domain + "toeffective"], null, '[]'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const conflict = (events, domain) => {
|
||||||
|
let conflict_list = [];
|
||||||
|
for (let i = 0; i < events.length; i++) {
|
||||||
|
conflict_list[i] = [];
|
||||||
|
for (let j = 0; j < events.length; j++) {
|
||||||
|
if ((i != j) && collide(events[i], events[j], domain)) {
|
||||||
|
conflict_list[i].push(j);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return conflict_list;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connected_components = (adj) => {
|
||||||
|
let visited = new Array(adj.length)
|
||||||
|
for(let i=0; i<adj.length; ++i) {
|
||||||
|
visited[i] = false;
|
||||||
|
}
|
||||||
|
let components = [];
|
||||||
|
for(let i=0; i<adj.length; ++i) {
|
||||||
|
if(visited[i]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let to_visit = [i];
|
||||||
|
let cc = [i];
|
||||||
|
visited[i] = true;
|
||||||
|
while(to_visit.length > 0) {
|
||||||
|
let v = to_visit.pop();
|
||||||
|
for(let j=0; j<adj[v].length; ++j) {
|
||||||
|
let u = adj[v][j]
|
||||||
|
if(visited[u]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
visited[u] = true;
|
||||||
|
to_visit.push(u);
|
||||||
|
cc.push(u);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
components.push(cc);
|
||||||
|
}
|
||||||
|
return components;
|
||||||
|
}
|
||||||
|
|
||||||
|
const event_fits = (events, index, stream, domain) => {
|
||||||
|
for (let i = 0; i < stream.length; i++) {
|
||||||
|
if (collide(events[stream[i]], events[index], domain)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fit_events = (components, events, domain) => {
|
||||||
|
components.forEach((component) => {
|
||||||
|
let events_per_stream = [ [] ];
|
||||||
|
component.forEach((index) => {
|
||||||
|
let i = 0;
|
||||||
|
while (true) {
|
||||||
|
if (event_fits(events, index, events_per_stream[i], domain)) {
|
||||||
|
events_per_stream[i].push(index)
|
||||||
|
events[index].unitoffset = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
if (i == events_per_stream.length) {
|
||||||
|
events_per_stream.push([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
component.forEach((index) => {
|
||||||
|
events[index].unitwidth = events_per_stream.length;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
//Helpers for daily calendar
|
||||||
|
const setup_ticker = () => {
|
||||||
|
let ticker = ['<div id="now"></div>'];
|
||||||
|
|
||||||
|
for (let i = dayjs(startOfWorkDay); dayjs.isSameOrBefore(i, endOfWorkDay); i = i.add(30, 'minutes')) {
|
||||||
|
if (i.minute() == 0) {
|
||||||
|
ticker.push(`<div><span>${i.format("h:mm")}</span> ${i.format("A")}</div>`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
ticker.push(`<div><span> </span>${i.format("h:mm")}</div>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
el('timings').innerHTML = ticker.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const setup_daily = (dailyEvents, allDayEvents) => {
|
||||||
|
el('main').replaceChildren(el('t-daily').content.cloneNode(true));
|
||||||
|
el('main').classList.remove("flex-column");
|
||||||
|
el('main').classList.add("flex-row");
|
||||||
|
setup_ticker();
|
||||||
|
el('events').addEventListener("dblclick", eventadd);
|
||||||
|
|
||||||
|
let conflict_list = conflict(dailyEvents, "time");
|
||||||
|
let components = connected_components(conflict_list);
|
||||||
|
|
||||||
|
fit_events(components, dailyEvents, "time");
|
||||||
|
|
||||||
|
dailyEvents.forEach(event => {
|
||||||
|
let left = (99/event.unitwidth) * (event.unitoffset) + 1;
|
||||||
|
let top = (event.timefromeffective - startOfWorkDay) / (endOfWorkDay - startOfWorkDay) * 100;
|
||||||
|
let width = 99/event.unitwidth - 1;
|
||||||
|
let height = (event.timetoeffective - event.timefromeffective) / (endOfWorkDay - startOfWorkDay) * 100 - 0.2;
|
||||||
|
let node = create_daily_event(event);
|
||||||
|
node.classList.add("position-absolute");
|
||||||
|
node.style.width = width + "%";
|
||||||
|
node.style.height = height + "%";
|
||||||
|
node.style.top = top + "%";
|
||||||
|
node.style.left = left + "%";
|
||||||
|
el("events").append(node);
|
||||||
|
});
|
||||||
|
|
||||||
|
allDayEvents.forEach(event => {
|
||||||
|
let node = create_daily_event(event);
|
||||||
|
el("allday").append(node);
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
//Helpers for monthly calendar
|
||||||
|
const setup_day_names = () => {
|
||||||
|
let day_names = '';
|
||||||
|
|
||||||
|
for (let word of ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]) {
|
||||||
|
day_names += `<div class='col text-truncate text-center'>${word}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
el('daynames').innerHTML = day_names;
|
||||||
|
}
|
||||||
|
|
||||||
|
const setup_day_cells = () => {
|
||||||
|
let day_cells = '';
|
||||||
|
week_starts = [];
|
||||||
|
weeks = [];
|
||||||
|
let today = dayjs(startOfCalMonth);
|
||||||
|
let node = null;
|
||||||
|
|
||||||
|
for (; dayjs.isSameOrBefore(today, endOfCalMonth); today = today.add(1, 'days')) {
|
||||||
|
if (today.day() == 0) {
|
||||||
|
node = document.createElement("DIV");
|
||||||
|
node.className = "row";
|
||||||
|
node.eventcounter = 0;
|
||||||
|
node.expanded = false;
|
||||||
|
node.expandable = false;
|
||||||
|
node.addEventListener("click", (e) => {
|
||||||
|
let target = e.currentTarget;
|
||||||
|
if (! target.expandable) { return; }
|
||||||
|
target.expanded = !target.expanded;
|
||||||
|
let newheight = target.expanded ? Math.max((target.eventcounter + 1) * 2.5 + 1, 11) : 11;
|
||||||
|
target.style.height = newheight + "rem";
|
||||||
|
if (target.expanded) {
|
||||||
|
target.classList.remove("expandable");
|
||||||
|
target.classList.add("contractable");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
target.classList.add("expandable");
|
||||||
|
target.classList.remove("contractable");
|
||||||
|
}
|
||||||
|
} );
|
||||||
|
weeks.push(node);
|
||||||
|
week_starts.push(dayjs(today));
|
||||||
|
}
|
||||||
|
let current = (today.isBefore(startOfMonth) || today.isAfter(endOfMonth)) ? "notcurrent" : "";
|
||||||
|
let thisday = today.isSame(dayjs(), 'day') ? "today" : "";
|
||||||
|
let weekend = (today.day() == 0 || today.day() == 6) ? "weekend" : "";
|
||||||
|
day_cells += `<div class='col text-truncate cell ${current} ${thisday} ${weekend}'><h5>${today.date()}</h5></div>`;
|
||||||
|
if (today.day() == 6) {
|
||||||
|
node.innerHTML = day_cells;
|
||||||
|
el('monthlycalendar').append(node);
|
||||||
|
day_cells = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
week_starts.push(dayjs(today));
|
||||||
|
}
|
||||||
|
|
||||||
|
const breakup = (events) => {
|
||||||
|
let brokenupevents = [];
|
||||||
|
for (let event of events) {
|
||||||
|
for (let weekstart of week_starts) {
|
||||||
|
if (weekstart.isBetween(event.datefromeffective, event.datetoeffective, null, '[]')) {
|
||||||
|
if (!event.broken) {
|
||||||
|
let mod = { ...event };
|
||||||
|
mod.datetoeffective = dayjs(weekstart).subtract(1, 'days');
|
||||||
|
if (dayjs.isSameOrAfter(mod.datetoeffective, mod.datefromeffective)) {
|
||||||
|
brokenupevents.push(mod);
|
||||||
|
}
|
||||||
|
event.broken = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mod = { ...event };
|
||||||
|
mod.datefromeffective = dayjs(weekstart);
|
||||||
|
mod.datetoeffective = dayjs.min(mod.datetoeffective, dayjs(weekstart).add(6, "days"));
|
||||||
|
brokenupevents.push(mod);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!event.broken) {
|
||||||
|
brokenupevents.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return brokenupevents;
|
||||||
|
}
|
||||||
|
|
||||||
|
const setup_monthly = (events) => {
|
||||||
|
el('main').replaceChildren(el('t-monthly').content.cloneNode(true));
|
||||||
|
el('main').classList.remove("flex-row");
|
||||||
|
el('main').classList.add("flex-column");
|
||||||
|
setup_day_names();
|
||||||
|
setup_day_cells();
|
||||||
|
|
||||||
|
let brokenupevents = breakup(events);
|
||||||
|
let conflict_list = conflict(brokenupevents, "date");
|
||||||
|
let components = connected_components(conflict_list);
|
||||||
|
|
||||||
|
fit_events(components, brokenupevents, "date");
|
||||||
|
|
||||||
|
for (let event of brokenupevents) {
|
||||||
|
for (let i = 0; i < week_starts.length - 1; i++) {
|
||||||
|
if (event.datefromeffective.isBetween(week_starts[i], week_starts[i+1], null, '[)')) {
|
||||||
|
let leftdaysdiff = dayjs.duration(event.datefromeffective.diff(week_starts[i])).asDays();
|
||||||
|
let left = leftdaysdiff / 7 * 100 + 0.5;
|
||||||
|
let top = 2.5*(event.unitoffset+1);
|
||||||
|
let rightdaysdiff = dayjs.duration(week_starts[i+1].diff(event.datetoeffective)).asDays() - 1;
|
||||||
|
let right = rightdaysdiff / 7 * 100 + 0.5;
|
||||||
|
let numevents = Math.max(weeks[i].eventcounter, event.unitoffset+1);
|
||||||
|
if (numevents > 3) {
|
||||||
|
weeks[i].eventcounter = numevents;
|
||||||
|
weeks[i].expandable = true;
|
||||||
|
weeks[i].classList.add("expandable");
|
||||||
|
}
|
||||||
|
create_monthly_event(left, top, right, event, weeks[i]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const update_indicator = () => {
|
||||||
|
let curtime = (dayjs() - startOfWorkDay) / (endOfWorkDay - startOfWorkDay) * 100;
|
||||||
|
el('now').style.top = curtime + "%";
|
||||||
|
el('now').style.display = curtime >= 0 && curtime < 100 ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
const day_onload = (request) => {
|
||||||
|
let data = null;
|
||||||
|
if (request.status >= 200 && request.status < 400) {
|
||||||
|
data = JSON.parse(request.responseText);
|
||||||
|
}
|
||||||
|
//else { }
|
||||||
|
let dailyEvents = [];
|
||||||
|
let allDayEvents = [];
|
||||||
|
for (let event of data) {
|
||||||
|
const tf = dayjs(event.timefrom, "H:mm:ss");
|
||||||
|
event.datefrom = dayjs(event.datefrom);
|
||||||
|
event.timefrom = dayjs(event.datefrom).hour(tf.hour()).minute(tf.minute());
|
||||||
|
const tt = dayjs(event.timeto, "H:mm:ss");
|
||||||
|
event.dateto = dayjs(event.dateto);
|
||||||
|
event.timeto = dayjs(event.dateto).hour(tt.hour()).minute(tt.minute());
|
||||||
|
event.colour ??= event.deleted ? "#ff0000" : "#32acea";
|
||||||
|
|
||||||
|
const startsBefore = event.datefrom.isBefore(startOfDay) || dayjs.isSameOrBefore(event.timefrom, startOfWorkDay);
|
||||||
|
const endsAfter = event.dateto.isAfter(endOfDay) || dayjs.isSameOrAfter(event.timeto, endOfWorkDay) || event.datetbd;
|
||||||
|
|
||||||
|
event.timefromeffective = startsBefore ? startOfWorkDay : dayjs.min(lastEvent, event.timefrom);
|
||||||
|
|
||||||
|
if (endsAfter || dayjs(event.timefromeffective).add(30, 'minutes').isAfter(endOfWorkDay)) {
|
||||||
|
event.timetoeffective = endOfWorkDay;
|
||||||
|
}
|
||||||
|
else if (event.timetbd) {
|
||||||
|
if (event.dateto.isBefore(dayjs(), 'day')) {
|
||||||
|
event.timetoeffective = endOfWorkDay;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
event.timetoeffective = dayjs.min(dayjs().add(1, 'hour'), endOfWorkDay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
event.timetoeffective = dayjs.max(dayjs(event.timefromeffective).add(30, 'minutes'), event.timeto);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startsBefore && endsAfter) {
|
||||||
|
allDayEvents.push(event);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
dailyEvents.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setup_daily(dailyEvents, allDayEvents);
|
||||||
|
update_indicator();
|
||||||
|
}
|
||||||
|
|
||||||
|
const month_onload = (request) => {
|
||||||
|
let data = null;
|
||||||
|
if (request.status >= 200 && request.status < 400) {
|
||||||
|
data = JSON.parse(request.responseText);
|
||||||
|
}
|
||||||
|
//else { }
|
||||||
|
for (let event of data) {
|
||||||
|
let tf = dayjs(event.timefrom, "H:mm:ss");
|
||||||
|
event.datefrom = dayjs(event.datefrom);
|
||||||
|
event.timefrom = dayjs(event.datefrom).hour(tf.hour()).minute(tf.minute());
|
||||||
|
let tt = dayjs(event.timeto, "H:mm:ss");
|
||||||
|
event.dateto = dayjs(event.dateto);
|
||||||
|
event.timeto = dayjs(event.dateto).hour(tt.hour()).minute(tt.minute());
|
||||||
|
event.datefromeffective = event.datefrom;
|
||||||
|
event.datetoeffective = event.dateto;
|
||||||
|
if (event.datefrom.isBefore(startOfCalMonth)) {
|
||||||
|
event.datefromeffective = startOfCalMonth;
|
||||||
|
}
|
||||||
|
if (event.dateto.isAfter(endOfCalMonth)) {
|
||||||
|
event.datetoeffective = endOfCalMonth;
|
||||||
|
}
|
||||||
|
if (event.datetbd) {
|
||||||
|
event.datetoeffective = endOfDay;
|
||||||
|
}
|
||||||
|
event.colour = event.deleted ? "#ff0000" : "#32acea";
|
||||||
|
}
|
||||||
|
setup_monthly(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
const adjustField = (field) => {
|
||||||
|
el(`${field}to`).prop( "readOnly", el(`${field}tbd`).prop("checked") );
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventadd = () => {
|
||||||
|
selectedEvent = null;
|
||||||
|
let timenow = dayjs().format("HH:mm");
|
||||||
|
el('name').value = "";
|
||||||
|
el('message').value = "";
|
||||||
|
el('timefrom').value = timenow;
|
||||||
|
el('datefrom').value = selected_date.format('YYYY-MM-DD');
|
||||||
|
el('timeto').value = timenow;
|
||||||
|
el('dateto').value = selected_date.format('YYYY-MM-DD');
|
||||||
|
el('timetbd').checked = false;
|
||||||
|
el('datetbd').checked = false;
|
||||||
|
//adjustField('time');
|
||||||
|
//adjustField('date');
|
||||||
|
if (el('eventdelete')) {
|
||||||
|
el('eventdelete').style.display = 'none';
|
||||||
|
}
|
||||||
|
const myModal = new bootstrap.Modal(el('eventModal'))
|
||||||
|
myModal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventedit = (event) => {
|
||||||
|
selectedEvent = event.id;
|
||||||
|
el('name').value = event.name;
|
||||||
|
el('message').value = event.message;
|
||||||
|
el('timefrom').value = event.timefrom.format("HH:mm");
|
||||||
|
el('datefrom').value = event.datefrom.format('YYYY-MM-DD');
|
||||||
|
el('timeto').value = event.timeto.format("HH:mm");
|
||||||
|
el('dateto').value = event.dateto.format('YYYY-MM-DD');
|
||||||
|
el('timetbd').checked = event.timetbd;
|
||||||
|
el('datetbd').checked = event.datetbd;
|
||||||
|
|
||||||
|
if (el('eventdelete')) {
|
||||||
|
el('eventdelete').style.display = 'block';
|
||||||
|
}
|
||||||
|
const myModal = new bootstrap.Modal(el('eventModal'))
|
||||||
|
myModal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
const init_event_listeners = () => {
|
||||||
|
el('selected_date').addEventListener('input', (e) => {
|
||||||
|
selected_date = dayjs(e.target.value);
|
||||||
|
init_times(selected_date);
|
||||||
|
Cookie.set('date', selected_date.format('YYYY-MM-DD'), selected_date.endOf('day').toDate());
|
||||||
|
lastPicker = null;
|
||||||
|
clearTimeout(update_timeout);
|
||||||
|
update();
|
||||||
|
});
|
||||||
|
|
||||||
|
const showPicker = (e) => {
|
||||||
|
if (e.currentTarget == lastPicker) {
|
||||||
|
lastPicker = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastPicker = e.currentTarget;
|
||||||
|
e.currentTarget.showPicker();
|
||||||
|
}
|
||||||
|
let pickers = ['selected_date', 'timefrom', 'datefrom', 'timeto', 'dateto'];
|
||||||
|
for (let picker of pickers) {
|
||||||
|
el(picker).addEventListener('click', showPicker);
|
||||||
|
el(picker).addEventListener('blur', (e) => {lastPicker = null;})
|
||||||
|
}
|
||||||
|
|
||||||
|
el('caltype').addEventListener("change", (e) => {
|
||||||
|
selected_cal_type = el('caltype').value;
|
||||||
|
Cookie.set('caltype', selected_cal_type);
|
||||||
|
clearTimeout(update_timeout);
|
||||||
|
update();
|
||||||
|
});
|
||||||
|
|
||||||
|
el('eventModal').addEventListener('shown.bs.modal', () => {
|
||||||
|
el('name').focus();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const init_user_listeners = () => {
|
||||||
|
el('checkout').addEventListener("click", eventadd);
|
||||||
|
|
||||||
|
const eventForm = el('eventform');
|
||||||
|
|
||||||
|
eventForm.addEventListener("submit", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
eventForm.action = selectedEvent ? `/calendar/event/${selectedEvent}/edit` : '/calendar/event/add';
|
||||||
|
eventForm.submit();
|
||||||
|
});
|
||||||
|
|
||||||
|
el('eventdelete').addEventListener("click", (e) => {
|
||||||
|
eventForm.action = `/calendar/event/${selectedEvent}/delete`;
|
||||||
|
eventForm.submit();
|
||||||
|
});
|
||||||
|
el('eventsubmit').addEventListener("click", (e) => {
|
||||||
|
eventForm.action = selectedEvent ? `/calendar/event/${selectedEvent}/edit` : '/calendar/event/add';
|
||||||
|
eventForm.submit();
|
||||||
|
});
|
||||||
|
}
|
||||||
31
apps/calendar/static/js/user.js
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
const update = () => {
|
||||||
|
let request = new XMLHttpRequest();
|
||||||
|
if (selected_cal_type == "daily") {
|
||||||
|
request.open('GET', "/calendar/events/day/" + selected_date.format('YYYY-MM-DD'), true);
|
||||||
|
request.onload = () => { day_onload(request) };
|
||||||
|
}
|
||||||
|
else if (selected_cal_type == "monthly") {
|
||||||
|
request.open('GET', "/calendar/events/month/" + selected_date.format('YYYY-MM'), true);
|
||||||
|
request.onload = () => { month_onload(request) };
|
||||||
|
}
|
||||||
|
request.send();
|
||||||
|
update_timeout = setTimeout(update, 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
init_times(selected_date);
|
||||||
|
init_event_listeners();
|
||||||
|
init_user_listeners();
|
||||||
|
update();
|
||||||
|
//listen();
|
||||||
|
|
||||||
|
//Secret!
|
||||||
|
if ( window.addEventListener ) {
|
||||||
|
let kkeys = [], konami = "38,38,40,40,37,39,37,39,66,65";
|
||||||
|
window.addEventListener("keydown", (e) => {
|
||||||
|
kkeys.push( e.keyCode );
|
||||||
|
if ( kkeys.toString().indexOf( konami ) >= 0 ) {
|
||||||
|
alert("nice")
|
||||||
|
kkeys = [];
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
BIN
apps/calendar/static/png/16_down.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
apps/calendar/static/png/16_up.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
apps/calendar/static/thumbnail.png
Normal file
|
After Width: | Height: | Size: 89 KiB |
127
apps/calendar/templates/index.html
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
<title>Event Calendar</title>
|
||||||
|
<link rel="icon" type="image/png" href="./static/favicon.png" />
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Montserrat" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="/static/bootstrap/bootstrap.min.css">
|
||||||
|
<link rel="stylesheet" href="./static/css/common.css">
|
||||||
|
<link rel="stylesheet" href="./static/css/style.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="bg-light">
|
||||||
|
|
||||||
|
<div id="wrapper" class="d-flex flex-column vh-100">
|
||||||
|
<header class="d-flex flex-wrap align-items-stretch flex-shrink-0 p-2 row-gap-2">
|
||||||
|
<div class="flex-grow-1 display-6 d-flex align-items-center cea-blue">
|
||||||
|
<select class="v-select d-inline-block mx-2 fw-medium cea-blue" id="caltype">
|
||||||
|
<option value="daily">Daily</option>
|
||||||
|
<option value="monthly">Monthly</option>
|
||||||
|
</select>
|
||||||
|
<span>Calendar</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex">
|
||||||
|
<input type="date" id="selected_date" class="form-control h-100 fs-3 me-2">
|
||||||
|
<button type="button" class="btn btn-primary h-100 text-center fs-3 lh-1 text-nowrap" id="checkout" $disabled>
|
||||||
|
New Event
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="d-flex flex-grow-1 min-height-0" id="main">
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer></footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template id="t-daily">
|
||||||
|
<div id="allday" class="d-flex flex-column row-gap-3 overflow-y-auto px-2 border">
|
||||||
|
<div class="sticky-top pt-2 bg-light">
|
||||||
|
<div class="fs-4">All day</div>
|
||||||
|
<hr class="m-0">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="timings" class="d-flex flex-column position-relative justify-content-between px-1 text-end"></div>
|
||||||
|
<div id="events" class="position-relative border"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="t-monthly">
|
||||||
|
<div class='container-fluid'>
|
||||||
|
<div id='daynames' class='row bg-dark text-white p-1'></div>
|
||||||
|
</div>
|
||||||
|
<div class='container-fluid' id='monthlycalendar'></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="modal fade" id="eventModal" tabindex="-1" role="dialog" aria-labelledby="eventModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="eventModalLabel">New Event</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form method="post" id="eventform">
|
||||||
|
<div class="mb-3">
|
||||||
|
<input type="text" name="name" id="name" class="form-control" placeholder="Name" required="true" autofocus>
|
||||||
|
<textarea name="message" id="message" class="form-control" rows="3" placeholder="Message (optional)"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3 row">
|
||||||
|
<label class="col-sm-2 col-form-label">From</label>
|
||||||
|
<div class="col-sm-5">
|
||||||
|
<input type="time" name="timefrom" id="timefrom" class="form-control" required="true">
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-5">
|
||||||
|
<input type="date" name="datefrom" id="datefrom" class="form-control" required="true">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3 row">
|
||||||
|
<label class="col-sm-2 col-form-label">To</label>
|
||||||
|
<div class="col-sm-5">
|
||||||
|
<input type="time" name="timeto" id="timeto" class="form-control" required="true">
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-5">
|
||||||
|
<input type="date" name="dateto" id="dateto" class="form-control" required="true">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="form-check col-sm-5 offset-sm-2">
|
||||||
|
<input class="form-check-input" type="checkbox" name="timetbd" id="timetbd">
|
||||||
|
<label class="form-check-label" for="timetbd">End time TBD</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check col-sm-5">
|
||||||
|
<input class="form-check-input" type="checkbox" name="datetbd" id="datetbd">
|
||||||
|
<label class="form-check-label" for="datetbd">End day TBD</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" id="eventFooter">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-6">
|
||||||
|
<button type="button" class="btn btn-danger btn-lg w-100" id="eventdelete" $disabled>Delete</button>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<button type="button" class="btn btn-primary btn-lg w-100" id="eventsubmit" $disabled>Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="./static/js/cookie.js"></script>
|
||||||
|
<script src="./static/js/dayjs.min.js"></script>
|
||||||
|
<script src="./static/js/dayjs.customParseFormat.js"></script>
|
||||||
|
<script src="./static/js/dayjs.minMax.js"></script>
|
||||||
|
<script src="./static/js/dayjs.isBetween.js"></script>
|
||||||
|
<script src="./static/js/dayjs.duration.js"></script>
|
||||||
|
<script src="/static/bootstrap/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="./static/js/script.js"></script>
|
||||||
|
<script src="./static/js/${user}.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
0
apps/reporter/__init__.py
Normal file
330
apps/reporter/app.py
Normal file
|
|
@ -0,0 +1,330 @@
|
||||||
|
from aiohttp import web
|
||||||
|
import aiohttp_security
|
||||||
|
import datetime
|
||||||
|
import simplejson
|
||||||
|
|
||||||
|
import tomllib
|
||||||
|
from pathlib import Path
|
||||||
|
from inspect import getmembers, isfunction
|
||||||
|
|
||||||
|
import security
|
||||||
|
from . import queries
|
||||||
|
from . import subapps
|
||||||
|
|
||||||
|
ONE_DAY = datetime.timedelta(days=1)
|
||||||
|
|
||||||
|
class Reports:
|
||||||
|
def __init__(self, report_path='reports'):
|
||||||
|
self.reports = {}
|
||||||
|
query = { key: value for key, value in getmembers(queries, isfunction) if not key.startswith("_") }
|
||||||
|
|
||||||
|
for file_path in Path(report_path).glob('**/*.toml'):
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
self.reports[file_path.stem] = tomllib.load(f)
|
||||||
|
if file_path.stem not in query:
|
||||||
|
print(f"No query function defined for report {file_path.stem}")
|
||||||
|
else:
|
||||||
|
self.reports[file_path.stem]['query'] = query[file_path.stem]
|
||||||
|
|
||||||
|
def __getitem__(self, item):
|
||||||
|
return self.reports[item]
|
||||||
|
|
||||||
|
def __contains__(self, item):
|
||||||
|
return item in self.reports
|
||||||
|
|
||||||
|
def __len__(self):
|
||||||
|
return len(self.reports)
|
||||||
|
|
||||||
|
def get(self, item, default=None):
|
||||||
|
if item in self.reports:
|
||||||
|
return self.reports[item]
|
||||||
|
else:
|
||||||
|
return default
|
||||||
|
|
||||||
|
def build_menu(app):
|
||||||
|
menu_structure = {'left': [], 'right': []}
|
||||||
|
for menu in app['config']["menus"]:
|
||||||
|
menu_items = []
|
||||||
|
for heading in menu['headings']:
|
||||||
|
items = []
|
||||||
|
for item in heading['items']:
|
||||||
|
if item == "divider":
|
||||||
|
items.append('\t\t\t\t\t\t\t<li><hr class="dropdown-divider"></li>\n')
|
||||||
|
else:
|
||||||
|
if menu["type"] == "reports":
|
||||||
|
url = f"/reporter/report/{item}"
|
||||||
|
lock = '<i class="bi bi-lock-fill me-1"></i>' if app['reports'][item].get('permissions') else ''
|
||||||
|
name = lock + app['reports'][item]['title']
|
||||||
|
else:
|
||||||
|
url = f"/{item[0]}"
|
||||||
|
name = item[1]
|
||||||
|
items.append(f'\t\t\t\t\t\t\t<li><a class="dropdown-item" href="{url}">{name}</a></li>\n')
|
||||||
|
parts = {
|
||||||
|
'id': heading['name'].lower().replace(' ', '_'),
|
||||||
|
'name': heading['name'],
|
||||||
|
'items': ''.join(items),
|
||||||
|
'align': 'dropdown-menu-end' if menu['align'] == 'right' else ''
|
||||||
|
}
|
||||||
|
menu_items.append(app['templates']['navbar-menu.html'].safe_substitute(parts))
|
||||||
|
menu_structure[menu['align']].append(''.join(menu_items))
|
||||||
|
return app['templates']['navbar.html'].safe_substitute({
|
||||||
|
'title': app['config']['app']['name'],
|
||||||
|
'leftmenu' : '\n'.join(menu_structure['left']),
|
||||||
|
'rightmenu': '\n'.join(menu_structure['right'])
|
||||||
|
})
|
||||||
|
|
||||||
|
def init_app(app):
|
||||||
|
app['reports'] = Reports('apps/reporter/reports')
|
||||||
|
app['start'] = datetime.datetime.now()
|
||||||
|
app['stats'] = {'reports_ran': 0, 'status': 'Normal'}
|
||||||
|
app['menu'] = build_menu(app)
|
||||||
|
|
||||||
|
async def db_query(query, params=()):
|
||||||
|
await app['cur'].execute(query, params)
|
||||||
|
q = app['cur'].mogrify(query, params)
|
||||||
|
r = await app['cur'].fetchall()
|
||||||
|
result = [{k: (str(v) if isinstance(v, datetime.date) or isinstance(v, datetime.timedelta) else v) for k, v in row.items()} for row in r]
|
||||||
|
return (q, result)
|
||||||
|
|
||||||
|
routes = web.RouteTableDef()
|
||||||
|
|
||||||
|
app.add_subapp('/admin', subapps.admin)
|
||||||
|
|
||||||
|
@routes.get('')
|
||||||
|
async def bare_redirect(request):
|
||||||
|
raise web.HTTPFound(app['prefix'])
|
||||||
|
|
||||||
|
@routes.get('/')
|
||||||
|
async def home(request):
|
||||||
|
await aiohttp_security.check_permission(request, 'user')
|
||||||
|
uptime = datetime.datetime.now() - request.app['start']
|
||||||
|
user = await aiohttp_security.authorized_userid(request)
|
||||||
|
|
||||||
|
logout_button = '<a href="/log/out">Log out</a>'
|
||||||
|
role_specific = {
|
||||||
|
'LOCAL': ['<a href="/log/in">Log in</a>'],
|
||||||
|
'USER': [logout_button],
|
||||||
|
'ADMIN': ['<a href="/reporter/admin">Admin console</a>', logout_button]
|
||||||
|
}
|
||||||
|
|
||||||
|
parts = {
|
||||||
|
'title': request.config_dict['config']['app']['name'],
|
||||||
|
'menu': request.config_dict['menu'],
|
||||||
|
'version': request.config_dict['config']['app']['version'],
|
||||||
|
'reports': len(request.config_dict['reports']),
|
||||||
|
'uptime': datetime.timedelta(days=uptime.days, seconds=uptime.seconds),
|
||||||
|
'reports_ran': request.app['stats']['reports_ran'],
|
||||||
|
'usertype': security.account_types[user],
|
||||||
|
'role_specific': '<br>'.join(role_specific.get(user, []))
|
||||||
|
}
|
||||||
|
return web.Response(text=request.config_dict['templates']['home.html'].safe_substitute(parts), content_type="text/html")
|
||||||
|
|
||||||
|
# Autocomplete suggestions
|
||||||
|
@routes.get('/suggest/{table}')
|
||||||
|
async def get_suggestions(request):
|
||||||
|
await aiohttp_security.check_permission(request, 'user')
|
||||||
|
table = request.match_info['table']
|
||||||
|
|
||||||
|
if table == 'customer':
|
||||||
|
query = "SELECT Customer + ' - ' + Name FROM CompanyC.dbo.ArCustomer"
|
||||||
|
elif table == 'supplier':
|
||||||
|
query = "SELECT Supplier + ' - ' + SupplierName FROM CompanyC.dbo.ApSupplier"
|
||||||
|
else:
|
||||||
|
return web.json_response([])
|
||||||
|
|
||||||
|
params = ()
|
||||||
|
await request.config_dict['cur'].execute(query, params)
|
||||||
|
q = request.config_dict['cur'].mogrify(query, params)
|
||||||
|
results = await request.config_dict['cur'].fetchall()
|
||||||
|
|
||||||
|
return web.json_response([row[0] for row in results], dumps=simplejson.dumps)
|
||||||
|
|
||||||
|
@routes.get('/report/{report}')
|
||||||
|
async def load_report(request):
|
||||||
|
name = request.match_info['report']
|
||||||
|
if name not in request.config_dict['reports']:
|
||||||
|
return web.HTTPNotFound()
|
||||||
|
report = request.config_dict['reports'][name]
|
||||||
|
|
||||||
|
permissions = report.get("permissions", 'user')
|
||||||
|
await aiohttp_security.check_permission(request, permissions)
|
||||||
|
|
||||||
|
options = []
|
||||||
|
if 'options' in report:
|
||||||
|
for option in report['options']:
|
||||||
|
controls = []
|
||||||
|
for control in option['controls']:
|
||||||
|
if control['type'] in ['checkbox', 'option']:
|
||||||
|
if len(request.query) == 0 or (len(request.query) == 1 and 'run' in request.query):
|
||||||
|
checked = control.get('default', '')
|
||||||
|
elif control['type'] == 'checkbox':
|
||||||
|
checked = 'checked' if f"{option['group']}:{control['key']}" in request.query else ''
|
||||||
|
elif control['type'] == 'option':
|
||||||
|
checked = 'checked' if request.query.get(option['group']) == control['key'] else ''
|
||||||
|
details = {
|
||||||
|
'group' : option['group'],
|
||||||
|
'key' : control['key'],
|
||||||
|
'label' : control['label'],
|
||||||
|
'checked': checked
|
||||||
|
}
|
||||||
|
controls.append( request.config_dict['templates'][f"options/{control['type']}.html"].safe_substitute(details) )
|
||||||
|
elif control['type'] == 'select':
|
||||||
|
#TODO this
|
||||||
|
pass
|
||||||
|
elif control['type'] in ['date', 'month']:
|
||||||
|
value = request.query.get(f"{option['group']}:{control['key']}")
|
||||||
|
|
||||||
|
if value is None:
|
||||||
|
default = control.get('default')
|
||||||
|
if control['type'] == 'date':
|
||||||
|
if default == 'last_business_day':
|
||||||
|
yesterday = datetime.date.today() - ONE_DAY
|
||||||
|
days_after_friday = max(yesterday.weekday() - 4, 0)
|
||||||
|
value = (yesterday - days_after_friday*ONE_DAY).isoformat()
|
||||||
|
elif default == 'first_of_this_month':
|
||||||
|
value = datetime.date.today().replace(day=1).isoformat()
|
||||||
|
elif default == '36_months_ago':
|
||||||
|
start = datetime.date.today() - relativedelta(years=3)
|
||||||
|
value = start.isoformat()
|
||||||
|
elif default == '5_years_ago':
|
||||||
|
start = datetime.date.today() - relativedelta(years=5)
|
||||||
|
value = start.isoformat()
|
||||||
|
elif default == '1996-07-04':
|
||||||
|
value = datetime.date(1996,7,4).isoformat()
|
||||||
|
elif default == '1997-02-12':
|
||||||
|
value = datetime.date(1997,2,12).isoformat()
|
||||||
|
else:
|
||||||
|
value = datetime.date.today().isoformat()
|
||||||
|
elif control['type'] == 'month':
|
||||||
|
if default == 'last_month':
|
||||||
|
last_month = datetime.date.today().replace(day=1) - ONE_DAY
|
||||||
|
value = last_month.strftime('%Y-%m')
|
||||||
|
elif default == '12_months_ago':
|
||||||
|
start = datetime.date.today() - relativedelta(years=1)
|
||||||
|
value = start.strftime('%Y-%m')
|
||||||
|
elif default == '36_months_ago':
|
||||||
|
start = datetime.date.today() - relativedelta(years=3)
|
||||||
|
value = start.strftime('%Y-%m')
|
||||||
|
else:
|
||||||
|
value = datetime.date.today().strftime('%Y-%m')
|
||||||
|
|
||||||
|
details = {
|
||||||
|
'group' : option['group'],
|
||||||
|
'key' : control['key'],
|
||||||
|
'label' : control['label'],
|
||||||
|
'type' : control['type'],
|
||||||
|
'value' : f"value='{value}'",
|
||||||
|
'suggest': ''
|
||||||
|
}
|
||||||
|
controls.append( request.config_dict['templates']['options/date.html'].safe_substitute(details) )
|
||||||
|
elif control['type'] == 'number':
|
||||||
|
value = request.query.get(f"{option['group']}:{control['key']}")
|
||||||
|
|
||||||
|
if value is None:
|
||||||
|
default = control.get('default')
|
||||||
|
if default == 'this_year':
|
||||||
|
value = datetime.date.today().year
|
||||||
|
elif default == 'this_month':
|
||||||
|
value = datetime.date.today().month
|
||||||
|
else:
|
||||||
|
value = 1
|
||||||
|
|
||||||
|
o_min = control.get('min')
|
||||||
|
o_max = control.get('max')
|
||||||
|
|
||||||
|
details = {
|
||||||
|
'group' : option['group'],
|
||||||
|
'key' : control['key'],
|
||||||
|
'label' : control['label'],
|
||||||
|
'type' : control['type'],
|
||||||
|
'min' : f"min='{o_min}'" if o_min else '',
|
||||||
|
'max' : f"max='{o_max}'" if o_max else '',
|
||||||
|
'value' : f"value='{value}'"
|
||||||
|
}
|
||||||
|
controls.append( request.config_dict['templates']['options/number.html'].safe_substitute(details) )
|
||||||
|
elif control['type'] in ['text']:
|
||||||
|
_id = f"{option['group']}:{control['key']}"
|
||||||
|
if _id in request.query:
|
||||||
|
value = urllib.parse.unquote(request.query[_id])
|
||||||
|
else:
|
||||||
|
value = control.get('default', '')
|
||||||
|
|
||||||
|
details = {
|
||||||
|
'group': option['group'],
|
||||||
|
'key' : control['key'],
|
||||||
|
'label': control['label'],
|
||||||
|
'type' : control['type'],
|
||||||
|
'value': f"value='{value}'" if value else ''
|
||||||
|
}
|
||||||
|
suggest = control.get('suggest')
|
||||||
|
|
||||||
|
if suggest:
|
||||||
|
controls.append( request.config_dict['templates']['options/search.html'].safe_substitute(details | { 'suggest': ",".join(suggest) }) )
|
||||||
|
else:
|
||||||
|
controls.append( request.config_dict['templates']['options/date.html'].safe_substitute(details) )
|
||||||
|
|
||||||
|
|
||||||
|
helper = request.config_dict['templates']['options/checkbox-helper.html'].safe_substitute({'group': option['group']})
|
||||||
|
fieldset = {
|
||||||
|
'group': option['group'],
|
||||||
|
'helper': helper if 'helper' in option else '',
|
||||||
|
'name': option['name'],
|
||||||
|
'controls': ''.join(controls)
|
||||||
|
}
|
||||||
|
options.append( request.config_dict['templates']['options/fieldset.html'].safe_substitute(fieldset) )
|
||||||
|
|
||||||
|
parts = {
|
||||||
|
'title': f"{report['title']} - {request.config_dict['config']['app']['name']}",
|
||||||
|
'report_title': report['title'],
|
||||||
|
'report_description': report['description'].replace('\n', '<br>'),
|
||||||
|
'options': ''.join(options),
|
||||||
|
'menu': request.config_dict['menu'],
|
||||||
|
'autorun': 'true' if 'run' in request.query else 'false'
|
||||||
|
}
|
||||||
|
|
||||||
|
return web.Response(text=request.config_dict['templates']['report.html'].safe_substitute(parts), content_type="text/html")
|
||||||
|
|
||||||
|
@routes.post('/report/{report}')
|
||||||
|
async def run_report(request):
|
||||||
|
|
||||||
|
name = request.match_info['report']
|
||||||
|
if name not in request.config_dict['reports']:
|
||||||
|
return web.HTTPNotFound()
|
||||||
|
report = request.config_dict['reports'][name]
|
||||||
|
|
||||||
|
permissions = report.get("permissions", 'user')
|
||||||
|
await aiohttp_security.check_permission(request, permissions)
|
||||||
|
|
||||||
|
user_options = {}
|
||||||
|
postdata = await request.post()
|
||||||
|
for fieldname, value in postdata.items():
|
||||||
|
if ':' in fieldname:
|
||||||
|
group, key = fieldname.split(':')
|
||||||
|
group_options = user_options.get(group, {})
|
||||||
|
group_options[key] = value
|
||||||
|
user_options[group] = group_options
|
||||||
|
else:
|
||||||
|
user_options[fieldname] = value
|
||||||
|
|
||||||
|
query_response = await report['query'](request.config_dict['cur'], user_options)
|
||||||
|
report_options = {
|
||||||
|
'formatting': report.get('formatting', True),
|
||||||
|
'group_by': report.get('group_by')
|
||||||
|
}
|
||||||
|
|
||||||
|
if query_response['status'] == 'ok':
|
||||||
|
request.app['stats']['reports_ran'] += 1
|
||||||
|
return web.json_response({
|
||||||
|
'status': 'ok',
|
||||||
|
'title': report['title'],
|
||||||
|
'period': query_response.get('period'),
|
||||||
|
'columns': query_response.get('columns', report.get('columns')),
|
||||||
|
'subcolumns': report.get('subcolumns'),
|
||||||
|
'rows': query_response['rows'],
|
||||||
|
'subrows': query_response.get('subrows'),
|
||||||
|
'options': report_options
|
||||||
|
}, dumps=simplejson.dumps)
|
||||||
|
else:
|
||||||
|
return web.json_response({'status': 'error', 'errors': query_response['errors']})
|
||||||
|
|
||||||
|
app.add_routes(routes)
|
||||||
10
apps/reporter/config/config.toml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
[app]
|
||||||
|
name = "Report Tool"
|
||||||
|
version = "1.3"
|
||||||
|
|
||||||
|
[[menus]]
|
||||||
|
type = "reports"
|
||||||
|
align = "left"
|
||||||
|
[[menus.headings]]
|
||||||
|
name = "Reports"
|
||||||
|
items = ["orders_by_employee", "orders_by_location", "orders_by_product", "orders_by_supplier", "pivot_table"]
|
||||||
4
apps/reporter/config/secret.toml.example
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
[database]
|
||||||
|
db = "reporter"
|
||||||
|
user = "user"
|
||||||
|
password = "pass"
|
||||||
282
apps/reporter/queries.py
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
from decimal import Decimal
|
||||||
|
import datetime
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
|
async def orders_by_employee(cursor, options):
|
||||||
|
errors = {}
|
||||||
|
|
||||||
|
if options["date"]["from"] == '' or options["date"]["to"] == '':
|
||||||
|
errors["date"] = "Please enter order date range."
|
||||||
|
|
||||||
|
if "category" not in options:
|
||||||
|
errors["category"] = "Please choose at least one product category."
|
||||||
|
|
||||||
|
if len(errors) > 0:
|
||||||
|
return {'status': 'error', 'errors': errors}
|
||||||
|
|
||||||
|
query = """\
|
||||||
|
SELECT
|
||||||
|
CONCAT(E.firstname, ' ', E.lastname) AS name,
|
||||||
|
O.orderid,
|
||||||
|
O.orderdate,
|
||||||
|
C.customername,
|
||||||
|
inter.value
|
||||||
|
FROM employees AS E
|
||||||
|
INNER JOIN orders AS O ON E.employeeid = O.employeeid
|
||||||
|
LEFT JOIN customers AS C on O.customerid = C.customerid
|
||||||
|
INNER JOIN (
|
||||||
|
SELECT
|
||||||
|
OD.orderid,
|
||||||
|
SUM(OD.quantity * P.price) AS value
|
||||||
|
FROM orderdetails AS OD
|
||||||
|
LEFT JOIN products AS P ON OD.productid = P.productid
|
||||||
|
WHERE P.categoryid IN ({})
|
||||||
|
GROUP BY OD.orderid
|
||||||
|
) AS inter ON o.orderid = inter.orderid
|
||||||
|
WHERE O.orderdate BETWEEN %s AND %s;""".format(",".join(['%s'] * len(options["category"])))
|
||||||
|
|
||||||
|
await cursor.execute(query, [*options["category"].keys(), options["date"]["from"], options["date"]["to"]])
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for row in rows:
|
||||||
|
row = list(row)
|
||||||
|
row[2] = row[2].date().strftime('%Y-%m-%d')
|
||||||
|
results.append(row)
|
||||||
|
|
||||||
|
return {'status': 'ok', 'rows': results, 'period': f'{options["date"]["from"]} - {options["date"]["to"]}'}
|
||||||
|
|
||||||
|
async def orders_by_location(cursor, options):
|
||||||
|
errors = {}
|
||||||
|
|
||||||
|
if "location" not in options:
|
||||||
|
errors["location"] = "Please choose how to group locations."
|
||||||
|
|
||||||
|
if options["date"]["from"] == '' or options["date"]["to"] == '':
|
||||||
|
errors["date"] = "Please enter order date range."
|
||||||
|
|
||||||
|
if "category" not in options:
|
||||||
|
errors["category"] = "Please choose at least one product category."
|
||||||
|
|
||||||
|
if len(errors) > 0:
|
||||||
|
return {'status': 'error', 'errors': errors}
|
||||||
|
|
||||||
|
location_query = {
|
||||||
|
'city': "CONCAT(C.country, ' - ', C.city)",
|
||||||
|
'country': "C.country"
|
||||||
|
}
|
||||||
|
query = """\
|
||||||
|
SELECT
|
||||||
|
{},
|
||||||
|
C.customername,
|
||||||
|
O.orderid,
|
||||||
|
O.orderdate,
|
||||||
|
inter.value
|
||||||
|
FROM orders AS O
|
||||||
|
LEFT JOIN customers AS C on O.customerid = C.customerid
|
||||||
|
INNER JOIN (
|
||||||
|
SELECT
|
||||||
|
OD.orderid,
|
||||||
|
SUM(OD.quantity * P.price) AS value
|
||||||
|
FROM orderdetails AS OD
|
||||||
|
LEFT JOIN products AS P ON OD.productid = P.productid
|
||||||
|
WHERE P.categoryid IN ({})
|
||||||
|
GROUP BY OD.orderid
|
||||||
|
) AS inter ON o.orderid = inter.orderid
|
||||||
|
WHERE O.orderdate BETWEEN %s AND %s;\
|
||||||
|
""".format(location_query[options["location"]], ",".join(['%s'] * len(options["category"])))
|
||||||
|
|
||||||
|
await cursor.execute(query, [*options["category"].keys(), options["date"]["from"], options["date"]["to"]])
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for row in rows:
|
||||||
|
row = list(row)
|
||||||
|
row[3] = row[3].date().strftime('%Y-%m-%d')
|
||||||
|
results.append(row)
|
||||||
|
|
||||||
|
return {'status': 'ok', 'rows': results, 'period': f'{options["date"]["from"]} - {options["date"]["to"]}'}
|
||||||
|
|
||||||
|
async def orders_by_product(cursor, options):
|
||||||
|
errors = {}
|
||||||
|
|
||||||
|
if options["date"]["from"] == '' or options["date"]["to"] == '':
|
||||||
|
errors["date"] = "Please enter order date range."
|
||||||
|
|
||||||
|
if "category" not in options:
|
||||||
|
errors["category"] = "Please choose at least one product category."
|
||||||
|
|
||||||
|
if len(errors) > 0:
|
||||||
|
return {'status': 'error', 'errors': errors}
|
||||||
|
|
||||||
|
query = """\
|
||||||
|
SELECT
|
||||||
|
P.productname,
|
||||||
|
C.customername,
|
||||||
|
O.orderid,
|
||||||
|
O.orderdate,
|
||||||
|
OD.quantity,
|
||||||
|
OD.quantity * P.price AS value
|
||||||
|
FROM orderdetails AS OD
|
||||||
|
LEFT JOIN orders AS O ON OD.orderid = O.orderid
|
||||||
|
LEFT JOIN products AS P ON OD.productid = P.productid
|
||||||
|
LEFT JOIN customers AS C ON O.customerid = C.customerid
|
||||||
|
WHERE P.categoryid IN ({})
|
||||||
|
AND O.orderdate BETWEEN %s AND %s;\
|
||||||
|
""".format(",".join(['%s'] * len(options["category"])))
|
||||||
|
|
||||||
|
await cursor.execute(query, [*options["category"].keys(), options["date"]["from"], options["date"]["to"]])
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for row in rows:
|
||||||
|
row = list(row)
|
||||||
|
row[3] = row[3].date().strftime('%Y-%m-%d')
|
||||||
|
results.append(row)
|
||||||
|
|
||||||
|
return {'status': 'ok', 'rows': results, 'period': f'{options["date"]["from"]} - {options["date"]["to"]}'}
|
||||||
|
|
||||||
|
async def orders_by_supplier(cursor, options):
|
||||||
|
errors = {}
|
||||||
|
|
||||||
|
if options["date"]["from"] == '' or options["date"]["to"] == '':
|
||||||
|
errors["date"] = "Please enter order date range."
|
||||||
|
|
||||||
|
if "category" not in options:
|
||||||
|
errors["category"] = "Please choose at least one product category."
|
||||||
|
|
||||||
|
if len(errors) > 0:
|
||||||
|
return {'status': 'error', 'errors': errors}
|
||||||
|
|
||||||
|
query = """\
|
||||||
|
SELECT
|
||||||
|
S.suppliername,
|
||||||
|
P.productname,
|
||||||
|
C.customername,
|
||||||
|
O.orderid,
|
||||||
|
O.orderdate,
|
||||||
|
OD.quantity,
|
||||||
|
OD.quantity * P.price AS value
|
||||||
|
FROM orderdetails AS OD
|
||||||
|
LEFT JOIN orders AS O ON OD.orderid = O.orderid
|
||||||
|
LEFT JOIN products AS P ON OD.productid = P.productid
|
||||||
|
LEFT JOIN customers AS C ON O.customerid = C.customerid
|
||||||
|
LEFT JOIN suppliers AS S ON P.supplierid = S.supplierid
|
||||||
|
WHERE P.categoryid IN ({})
|
||||||
|
AND O.orderdate BETWEEN %s AND %s;\
|
||||||
|
""".format(",".join(['%s'] * len(options["category"])))
|
||||||
|
|
||||||
|
await cursor.execute(query, [*options["category"].keys(), options["date"]["from"], options["date"]["to"]])
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for row in rows:
|
||||||
|
row = list(row)
|
||||||
|
row[4] = row[4].date().strftime('%Y-%m-%d')
|
||||||
|
results.append(row)
|
||||||
|
|
||||||
|
return {'status': 'ok', 'rows': results, 'period': f'{options["date"]["from"]} - {options["date"]["to"]}'}
|
||||||
|
|
||||||
|
async def pivot_table(cursor, options):
|
||||||
|
errors = {}
|
||||||
|
valid_rows = set(['month', 'year', 'employee', 'shipper', 'customer', 'supplier', 'category', 'product'])
|
||||||
|
valid_columns = set(['month', 'year', 'employee', 'shipper', 'category'])
|
||||||
|
valid_values = set(['sales', 'qty'])
|
||||||
|
|
||||||
|
if options["date"]["from"] == '' or options["date"]["to"] == '':
|
||||||
|
errors["date"] = "Please enter order date range."
|
||||||
|
|
||||||
|
if "category" not in options:
|
||||||
|
errors["category"] = "Please choose at least one product category."
|
||||||
|
|
||||||
|
if options["rows"] not in valid_rows:
|
||||||
|
errors["rows"] = "Please choose the data to display as rows."
|
||||||
|
|
||||||
|
if options["columns"] not in valid_columns:
|
||||||
|
options["columns"] = "none"
|
||||||
|
|
||||||
|
if options["rows"] == options["columns"]:
|
||||||
|
errors["columns"] = "Columns and Rows should be different."
|
||||||
|
|
||||||
|
if options["values"] not in valid_values:
|
||||||
|
errors["values"] = "Value selection invalid."
|
||||||
|
|
||||||
|
if len(errors) > 0:
|
||||||
|
return {'status': 'error', 'errors': errors}
|
||||||
|
|
||||||
|
# SELECT, GROUP BY, JOIN
|
||||||
|
parts = {
|
||||||
|
'none' : ("", "", ""),
|
||||||
|
'month' : ("CONCAT(YEAR(o.orderdate), '-', LPAD(MONTH(o.orderdate), 2, '0')) AS month", "month", ""),
|
||||||
|
'year' : ("YEAR(o.orderdate) AS year", "year", ""),
|
||||||
|
'employee': ("CONCAT(E.firstname, ' ', E.lastname) AS employeename",
|
||||||
|
"employeename",
|
||||||
|
"LEFT JOIN employees AS E ON O.employeeid = E.employeeid"),
|
||||||
|
'shipper' : ("SH.shippername",
|
||||||
|
"SH.shippername",
|
||||||
|
"LEFT JOIN shippers AS SH ON O.shipperid = SH.shipperid"),
|
||||||
|
'customer': ("C.customername",
|
||||||
|
"C.customername",
|
||||||
|
"LEFT JOIN customers AS C ON O.customerid = C.customerid"),
|
||||||
|
'supplier': ("S.suppliername",
|
||||||
|
"S.suppliername",
|
||||||
|
"LEFT JOIN suppliers AS S ON P.supplierid = S.supplierid"),
|
||||||
|
'category': ("CA.categoryname",
|
||||||
|
"CA.categoryname",
|
||||||
|
"LEFT JOIN categories AS CA ON P.categoryid = CA.categoryid"),
|
||||||
|
'product' : ("P.productname", "P.productname", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
values = {
|
||||||
|
'sales' : 'SUM(OD.quantity * P.price)',
|
||||||
|
'qty' : 'SUM(OD.quantity)'
|
||||||
|
}
|
||||||
|
|
||||||
|
query = """
|
||||||
|
SELECT
|
||||||
|
{rows} {columns} {values}
|
||||||
|
FROM orderdetails AS OD
|
||||||
|
LEFT JOIN orders AS O ON OD.orderid = O.orderid
|
||||||
|
LEFT JOIN products AS P ON OD.productid = P.productid
|
||||||
|
{joins}
|
||||||
|
WHERE P.categoryid IN ({categories})
|
||||||
|
AND O.orderdate BETWEEN %s AND %s
|
||||||
|
GROUP BY {grouprows}{groupcols}
|
||||||
|
""".format(
|
||||||
|
rows=f"{parts[options['rows']][0]},",
|
||||||
|
columns=f"{parts[options['columns']][0]}," if options["columns"] != 'none' else '',
|
||||||
|
values=f"{values[options['values']]}",
|
||||||
|
joins=f"{parts[options['rows']][2]} {parts[options['columns']][2]}",
|
||||||
|
categories=",".join(['%s'] * len(options["category"])),
|
||||||
|
grouprows=parts[options['rows']][1],
|
||||||
|
groupcols=', '+parts[options['columns']][1] if options["columns"] != 'none' else ''
|
||||||
|
)
|
||||||
|
|
||||||
|
await cursor.execute(query, [*options["category"].keys(), options["date"]["from"], options["date"]["to"]])
|
||||||
|
response = await cursor.fetchall()
|
||||||
|
|
||||||
|
if options["columns"] == 'none':
|
||||||
|
results = [ list(row) for row in response ]
|
||||||
|
value_lookup = {'sales': 'Order Value', 'qty': 'Item Quantity'}
|
||||||
|
rv = {'status': 'ok', 'rows': results, 'columns': [{'name': options['rows'].capitalize()}, {'name': value_lookup[options["values"]], 'format': '$'}]}
|
||||||
|
return rv
|
||||||
|
else:
|
||||||
|
results = []
|
||||||
|
intermediate = {}
|
||||||
|
cols = set()
|
||||||
|
for row in response:
|
||||||
|
if row[0] is None:
|
||||||
|
continue
|
||||||
|
cols.add(row[1])
|
||||||
|
if row[0] not in intermediate:
|
||||||
|
intermediate[row[0]] = {'Total': 0}
|
||||||
|
assert row[1] not in intermediate[row[0]]
|
||||||
|
intermediate[row[0]][row[1]] = row[2]
|
||||||
|
intermediate[row[0]]['Total'] += row[2]
|
||||||
|
cols = list(sorted(cols)) + ['Total']
|
||||||
|
for row in sorted(intermediate):
|
||||||
|
values = [row] + [ intermediate[row].get(col, '') for col in cols ]
|
||||||
|
results.append(values)
|
||||||
|
headings = [{'name': options['rows'].capitalize(), 'frozen': True}] + [ {'name': col, 'format': '$'} for col in cols ]
|
||||||
|
headings[-1]['frozen'] = True
|
||||||
|
return {'status': 'ok', 'rows': results, 'columns': headings, 'period': f'{options["date"]["from"]} - {options["date"]["to"]}'}
|
||||||
75
apps/reporter/reports/orders_by_employee.toml
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
title = "Orders by Employee"
|
||||||
|
description = "List of orders fulfilled by each employee"
|
||||||
|
group_by = 0
|
||||||
|
|
||||||
|
[[columns]]
|
||||||
|
name = "Employee"
|
||||||
|
[[columns]]
|
||||||
|
name = "Order #"
|
||||||
|
[[columns]]
|
||||||
|
name = "Order Date"
|
||||||
|
[[columns]]
|
||||||
|
name = "Customer"
|
||||||
|
[[columns]]
|
||||||
|
name = "Value"
|
||||||
|
footer = "sum"
|
||||||
|
format = "$"
|
||||||
|
|
||||||
|
[[options]]
|
||||||
|
name = "Order Date"
|
||||||
|
group = "date"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "date"
|
||||||
|
label = "From"
|
||||||
|
key = "from"
|
||||||
|
default = "1996-07-04"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "date"
|
||||||
|
label = "To"
|
||||||
|
key = "to"
|
||||||
|
default = "1997-02-12"
|
||||||
|
|
||||||
|
[[options]]
|
||||||
|
name = "Product Category"
|
||||||
|
group = "category"
|
||||||
|
helper = "checkbox"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "checkbox"
|
||||||
|
label = "Beverages"
|
||||||
|
key = "1"
|
||||||
|
default = "checked"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "checkbox"
|
||||||
|
label = "Condiments"
|
||||||
|
key = "2"
|
||||||
|
default = "checked"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "checkbox"
|
||||||
|
label = "Confections"
|
||||||
|
key = "3"
|
||||||
|
default = "checked"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "checkbox"
|
||||||
|
label = "Dairy Products"
|
||||||
|
key = "4"
|
||||||
|
default = "checked"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "checkbox"
|
||||||
|
label = "Grains/Cereals"
|
||||||
|
key = "5"
|
||||||
|
default = "checked"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "checkbox"
|
||||||
|
label = "Meat/Poultry"
|
||||||
|
key = "6"
|
||||||
|
default = "checked"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "checkbox"
|
||||||
|
label = "Produce"
|
||||||
|
key = "7"
|
||||||
|
default = "checked"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "checkbox"
|
||||||
|
label = "Seafood"
|
||||||
|
key = "8"
|
||||||
|
default = "checked"
|
||||||
88
apps/reporter/reports/orders_by_location.toml
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
title = "Orders by Customer Location"
|
||||||
|
description = "List of orders shipped to cities or countries."
|
||||||
|
group_by = 0
|
||||||
|
|
||||||
|
[[columns]]
|
||||||
|
name = "Location"
|
||||||
|
[[columns]]
|
||||||
|
name = "Customer"
|
||||||
|
[[columns]]
|
||||||
|
name = "Order #"
|
||||||
|
[[columns]]
|
||||||
|
name = "Order Date"
|
||||||
|
[[columns]]
|
||||||
|
name = "Value"
|
||||||
|
footer = "sum"
|
||||||
|
format = "$"
|
||||||
|
|
||||||
|
[[options]]
|
||||||
|
name = "Location"
|
||||||
|
group = "location"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "option"
|
||||||
|
label = "Country - City"
|
||||||
|
key = "city"
|
||||||
|
default = "checked"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "option"
|
||||||
|
label = "Country"
|
||||||
|
key = "country"
|
||||||
|
|
||||||
|
[[options]]
|
||||||
|
name = "Order Date"
|
||||||
|
group = "date"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "date"
|
||||||
|
label = "From"
|
||||||
|
key = "from"
|
||||||
|
default = "1996-07-04"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "date"
|
||||||
|
label = "To"
|
||||||
|
key = "to"
|
||||||
|
default = "1997-02-12"
|
||||||
|
|
||||||
|
[[options]]
|
||||||
|
name = "Product Category"
|
||||||
|
group = "category"
|
||||||
|
helper = "checkbox"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "checkbox"
|
||||||
|
label = "Beverages"
|
||||||
|
key = "1"
|
||||||
|
default = "checked"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "checkbox"
|
||||||
|
label = "Condiments"
|
||||||
|
key = "2"
|
||||||
|
default = "checked"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "checkbox"
|
||||||
|
label = "Confections"
|
||||||
|
key = "3"
|
||||||
|
default = "checked"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "checkbox"
|
||||||
|
label = "Dairy Products"
|
||||||
|
key = "4"
|
||||||
|
default = "checked"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "checkbox"
|
||||||
|
label = "Grains/Cereals"
|
||||||
|
key = "5"
|
||||||
|
default = "checked"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "checkbox"
|
||||||
|
label = "Meat/Poultry"
|
||||||
|
key = "6"
|
||||||
|
default = "checked"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "checkbox"
|
||||||
|
label = "Produce"
|
||||||
|
key = "7"
|
||||||
|
default = "checked"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "checkbox"
|
||||||
|
label = "Seafood"
|
||||||
|
key = "8"
|
||||||
|
default = "checked"
|
||||||
79
apps/reporter/reports/orders_by_product.toml
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
title = "Orders by Product"
|
||||||
|
description = "List of order lines for each product by customer."
|
||||||
|
group_by = 0
|
||||||
|
|
||||||
|
[[columns]]
|
||||||
|
name = "Product"
|
||||||
|
[[columns]]
|
||||||
|
name = "Customer"
|
||||||
|
[[columns]]
|
||||||
|
name = "Order #"
|
||||||
|
[[columns]]
|
||||||
|
name = "Order Date"
|
||||||
|
[[columns]]
|
||||||
|
name = "Qty"
|
||||||
|
footer = "sum"
|
||||||
|
format = "int"
|
||||||
|
[[columns]]
|
||||||
|
name = "Value"
|
||||||
|
footer = "sum"
|
||||||
|
format = "$"
|
||||||
|
|
||||||
|
[[options]]
|
||||||
|
name = "Order Date"
|
||||||
|
group = "date"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "date"
|
||||||
|
label = "From"
|
||||||
|
key = "from"
|
||||||
|
default = "1996-07-04"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "date"
|
||||||
|
label = "To"
|
||||||
|
key = "to"
|
||||||
|
default = "1997-02-12"
|
||||||
|
|
||||||
|
[[options]]
|
||||||
|
name = "Product Category"
|
||||||
|
group = "category"
|
||||||
|
helper = "checkbox"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "checkbox"
|
||||||
|
label = "Beverages"
|
||||||
|
key = "1"
|
||||||
|
default = "checked"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "checkbox"
|
||||||
|
label = "Condiments"
|
||||||
|
key = "2"
|
||||||
|
default = "checked"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "checkbox"
|
||||||
|
label = "Confections"
|
||||||
|
key = "3"
|
||||||
|
default = "checked"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "checkbox"
|
||||||
|
label = "Dairy Products"
|
||||||
|
key = "4"
|
||||||
|
default = "checked"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "checkbox"
|
||||||
|
label = "Grains/Cereals"
|
||||||
|
key = "5"
|
||||||
|
default = "checked"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "checkbox"
|
||||||
|
label = "Meat/Poultry"
|
||||||
|
key = "6"
|
||||||
|
default = "checked"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "checkbox"
|
||||||
|
label = "Produce"
|
||||||
|
key = "7"
|
||||||
|
default = "checked"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "checkbox"
|
||||||
|
label = "Seafood"
|
||||||
|
key = "8"
|
||||||
|
default = "checked"
|
||||||
81
apps/reporter/reports/orders_by_supplier.toml
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
title = "Orders by Supplier"
|
||||||
|
description = "List of order lines for each product from a supplier."
|
||||||
|
group_by = 0
|
||||||
|
|
||||||
|
[[columns]]
|
||||||
|
name = "Supplier"
|
||||||
|
[[columns]]
|
||||||
|
name = "Product"
|
||||||
|
[[columns]]
|
||||||
|
name = "Customer"
|
||||||
|
[[columns]]
|
||||||
|
name = "Order #"
|
||||||
|
[[columns]]
|
||||||
|
name = "Order Date"
|
||||||
|
[[columns]]
|
||||||
|
name = "Qty"
|
||||||
|
footer = "sum"
|
||||||
|
format = "int"
|
||||||
|
[[columns]]
|
||||||
|
name = "Value"
|
||||||
|
footer = "sum"
|
||||||
|
format = "$"
|
||||||
|
|
||||||
|
[[options]]
|
||||||
|
name = "Order Date"
|
||||||
|
group = "date"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "date"
|
||||||
|
label = "From"
|
||||||
|
key = "from"
|
||||||
|
default = "1996-07-04"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "date"
|
||||||
|
label = "To"
|
||||||
|
key = "to"
|
||||||
|
default = "1997-02-12"
|
||||||
|
|
||||||
|
[[options]]
|
||||||
|
name = "Product Category"
|
||||||
|
group = "category"
|
||||||
|
helper = "checkbox"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "checkbox"
|
||||||
|
label = "Beverages"
|
||||||
|
key = "1"
|
||||||
|
default = "checked"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "checkbox"
|
||||||
|
label = "Condiments"
|
||||||
|
key = "2"
|
||||||
|
default = "checked"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "checkbox"
|
||||||
|
label = "Confections"
|
||||||
|
key = "3"
|
||||||
|
default = "checked"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "checkbox"
|
||||||
|
label = "Dairy Products"
|
||||||
|
key = "4"
|
||||||
|
default = "checked"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "checkbox"
|
||||||
|
label = "Grains/Cereals"
|
||||||
|
key = "5"
|
||||||
|
default = "checked"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "checkbox"
|
||||||
|
label = "Meat/Poultry"
|
||||||
|
key = "6"
|
||||||
|
default = "checked"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "checkbox"
|
||||||
|
label = "Produce"
|
||||||
|
key = "7"
|
||||||
|
default = "checked"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "checkbox"
|
||||||
|
label = "Seafood"
|
||||||
|
key = "8"
|
||||||
|
default = "checked"
|
||||||
143
apps/reporter/reports/pivot_table.toml
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
title = "Pivot Table"
|
||||||
|
description = "A pivot table is a data summary using aggregate functions (like sum, average, count, etc) by one or more categories."
|
||||||
|
|
||||||
|
[[columns]]
|
||||||
|
#This space intentionally left blank
|
||||||
|
|
||||||
|
[[options]]
|
||||||
|
name = "Order Date"
|
||||||
|
group = "date"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "date"
|
||||||
|
label = "From"
|
||||||
|
key = "from"
|
||||||
|
default = "1996-07-04"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "date"
|
||||||
|
label = "To"
|
||||||
|
key = "to"
|
||||||
|
default = "1997-02-12"
|
||||||
|
|
||||||
|
[[options]]
|
||||||
|
name = "Rows"
|
||||||
|
group = "rows"
|
||||||
|
[[options.controls]]
|
||||||
|
label = "Month"
|
||||||
|
type = "option"
|
||||||
|
key = "month"
|
||||||
|
default = "checked"
|
||||||
|
[[options.controls]]
|
||||||
|
label = "Year"
|
||||||
|
type = "option"
|
||||||
|
key = "year"
|
||||||
|
[[options.controls]]
|
||||||
|
label = "Employee"
|
||||||
|
type = "option"
|
||||||
|
key = "employee"
|
||||||
|
[[options.controls]]
|
||||||
|
label = "Shipper"
|
||||||
|
type = "option"
|
||||||
|
key = "shipper"
|
||||||
|
[[options.controls]]
|
||||||
|
label = "Customer"
|
||||||
|
type = "option"
|
||||||
|
key = "customer"
|
||||||
|
[[options.controls]]
|
||||||
|
label = "Supplier"
|
||||||
|
type = "option"
|
||||||
|
key = "supplier"
|
||||||
|
[[options.controls]]
|
||||||
|
label = "Category"
|
||||||
|
type = "option"
|
||||||
|
key = "category"
|
||||||
|
[[options.controls]]
|
||||||
|
label = "Product"
|
||||||
|
type = "option"
|
||||||
|
key = "product"
|
||||||
|
|
||||||
|
[[options]]
|
||||||
|
name = "Columns"
|
||||||
|
group = "columns"
|
||||||
|
[[options.controls]]
|
||||||
|
label = "None"
|
||||||
|
type = "option"
|
||||||
|
key = "none"
|
||||||
|
default = "checked"
|
||||||
|
[[options.controls]]
|
||||||
|
label = "Month"
|
||||||
|
type = "option"
|
||||||
|
key = "month"
|
||||||
|
[[options.controls]]
|
||||||
|
label = "Year"
|
||||||
|
type = "option"
|
||||||
|
key = "year"
|
||||||
|
[[options.controls]]
|
||||||
|
label = "Employee"
|
||||||
|
type = "option"
|
||||||
|
key = "employee"
|
||||||
|
[[options.controls]]
|
||||||
|
label = "Shipper"
|
||||||
|
type = "option"
|
||||||
|
key = "shipper"
|
||||||
|
[[options.controls]]
|
||||||
|
label = "Category"
|
||||||
|
type = "option"
|
||||||
|
key = "category"
|
||||||
|
|
||||||
|
[[options]]
|
||||||
|
name = "Values"
|
||||||
|
group = "values"
|
||||||
|
[[options.controls]]
|
||||||
|
label = "Order Value"
|
||||||
|
type = "option"
|
||||||
|
key = "sales"
|
||||||
|
default = "checked"
|
||||||
|
[[options.controls]]
|
||||||
|
label = "Item Quantity"
|
||||||
|
type = "option"
|
||||||
|
key = "qty"
|
||||||
|
|
||||||
|
[[options]]
|
||||||
|
name = "Product Category"
|
||||||
|
group = "category"
|
||||||
|
helper = "checkbox"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "checkbox"
|
||||||
|
label = "Beverages"
|
||||||
|
key = "1"
|
||||||
|
default = "checked"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "checkbox"
|
||||||
|
label = "Condiments"
|
||||||
|
key = "2"
|
||||||
|
default = "checked"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "checkbox"
|
||||||
|
label = "Confections"
|
||||||
|
key = "3"
|
||||||
|
default = "checked"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "checkbox"
|
||||||
|
label = "Dairy Products"
|
||||||
|
key = "4"
|
||||||
|
default = "checked"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "checkbox"
|
||||||
|
label = "Grains/Cereals"
|
||||||
|
key = "5"
|
||||||
|
default = "checked"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "checkbox"
|
||||||
|
label = "Meat/Poultry"
|
||||||
|
key = "6"
|
||||||
|
default = "checked"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "checkbox"
|
||||||
|
label = "Produce"
|
||||||
|
key = "7"
|
||||||
|
default = "checked"
|
||||||
|
[[options.controls]]
|
||||||
|
type = "checkbox"
|
||||||
|
label = "Seafood"
|
||||||
|
key = "8"
|
||||||
|
default = "checked"
|
||||||
8
apps/reporter/static/css/autocomplete.min.css
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
/**
|
||||||
|
* Minified by jsDelivr using clean-css v5.2.4.
|
||||||
|
* Original file: /npm/@tarekraafat/autocomplete.js@10.2.7/dist/css/autoComplete.css
|
||||||
|
*
|
||||||
|
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
||||||
|
*/
|
||||||
|
.autoComplete_wrapper{display:inline-block;position:relative}.autoComplete_wrapper>input{height:3rem;width:370px;margin:0;padding:0 2rem 0 3.2rem;box-sizing:border-box;-moz-box-sizing:border-box;-webkit-box-sizing:border-box;font-size:1rem;text-overflow:ellipsis;color:rgba(255,122,122,.3);outline:0;border-radius:10rem;border:.05rem solid rgba(255,122,122,.5);background-image:url(images/search.svg);background-size:1.4rem;background-position:left 1.05rem top .8rem;background-repeat:no-repeat;background-origin:border-box;background-color:#fff;transition:all .4s ease;-webkit-transition:all -webkit-transform .4s ease}.autoComplete_wrapper>input::placeholder{color:rgba(255,122,122,.5);transition:all .3s ease;-webkit-transition:all -webkit-transform .3s ease}.autoComplete_wrapper>input:hover::placeholder{color:rgba(255,122,122,.6);transition:all .3s ease;-webkit-transition:all -webkit-transform .3s ease}.autoComplete_wrapper>input:focus::placeholder{padding:.1rem .6rem;font-size:.95rem;color:rgba(255,122,122,.4)}.autoComplete_wrapper>input:focus::selection{background-color:rgba(255,122,122,.15)}.autoComplete_wrapper>input::selection{background-color:rgba(255,122,122,.15)}.autoComplete_wrapper>input:hover{color:rgba(255,122,122,.8);transition:all .3s ease;-webkit-transition:all -webkit-transform .3s ease}.autoComplete_wrapper>input:focus{color:#ff7a7a;border:.06rem solid rgba(255,122,122,.8)}.autoComplete_wrapper>ul{position:absolute;max-height:226px;overflow-y:scroll;box-sizing:border-box;left:0;right:0;margin:.5rem 0 0 0;padding:0;z-index:1;list-style:none;border-radius:.6rem;background-color:#fff;border:1px solid rgba(33,33,33,.07);box-shadow:0 3px 6px rgba(149,157,165,.15);outline:0;transition:opacity .15s ease-in-out;-moz-transition:opacity .15s ease-in-out;-webkit-transition:opacity .15s ease-in-out}.autoComplete_wrapper>ul:empty,.autoComplete_wrapper>ul[hidden]{display:block;opacity:0;transform:scale(0)}.autoComplete_wrapper>ul>li{margin:.3rem;padding:.3rem .5rem;text-align:left;font-size:1rem;color:#212121;border-radius:.35rem;background-color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:all .2s ease}.autoComplete_wrapper>ul>li mark{background-color:transparent;color:#ff7a7a;font-weight:700}.autoComplete_wrapper>ul>li:hover{cursor:pointer;background-color:rgba(255,122,122,.15)}.autoComplete_wrapper>ul>li[aria-selected=true]{background-color:rgba(255,122,122,.15)}@media only screen and (max-width:600px){.autoComplete_wrapper>input{width:18rem}}
|
||||||
|
/*# sourceMappingURL=/sm/e81ce8f7addf065a61ccd811ac8dda3ac35badd83cf93c0a06ac1f8ee3879152.map */
|
||||||
17
apps/reporter/static/css/login.css
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.form-signin {
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cea {
|
||||||
|
color: #fff;
|
||||||
|
background-color: #32acea;
|
||||||
|
border-color: #32acea;
|
||||||
|
}
|
||||||
|
.btn-cea:hover {
|
||||||
|
color: #fff;
|
||||||
|
background-color: #229cda;
|
||||||
|
border-color: #229cda;
|
||||||
|
}
|
||||||
132
apps/reporter/static/css/style.css
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
.btn-excel {
|
||||||
|
--bs-btn-color: #fff;
|
||||||
|
--bs-btn-bg: #0b744d;
|
||||||
|
--bs-btn-border-color: #0b744d;
|
||||||
|
--bs-btn-hover-color: #fff;
|
||||||
|
--bs-btn-hover-bg: #0b6041;
|
||||||
|
--bs-btn-hover-border-color: #085b3b;
|
||||||
|
--bs-btn-focus-shadow-rgb: 49,132,253;
|
||||||
|
--bs-btn-active-color: #fff;
|
||||||
|
--bs-btn-active-bg: #085b3b;
|
||||||
|
--bs-btn-active-border-color: #0a563a;
|
||||||
|
--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
|
||||||
|
--bs-btn-disabled-color: #fff;
|
||||||
|
--bs-btn-disabled-bg: #0b744d;
|
||||||
|
--bs-btn-disabled-border-color: #0b744d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.min-height-0 {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
height: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-3-label {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 25%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
min-width: 250px;
|
||||||
|
}
|
||||||
|
#table-controls button {
|
||||||
|
min-width: 130px;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
.tabulator-tableholder {
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.tabulator-tableholder:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
.selectable {
|
||||||
|
user-select: auto !important;
|
||||||
|
cursor: text !important;
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Report autocomplete */
|
||||||
|
.searchresults {
|
||||||
|
position: absolute;
|
||||||
|
background-color: var(--bs-body-bg);
|
||||||
|
z-index: 1060;
|
||||||
|
}
|
||||||
|
.searchresults ul {
|
||||||
|
border: 1px solid black;
|
||||||
|
height: 200px;
|
||||||
|
min-width: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.5em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.searchresults ul li {
|
||||||
|
cursor: pointer;
|
||||||
|
list-style-type: none;
|
||||||
|
border-radius: 0.25em;
|
||||||
|
}
|
||||||
|
.searchresults ul li:hover {
|
||||||
|
background-color: var(--bs-primary-bg-subtle);
|
||||||
|
}
|
||||||
|
.searchresults ul li mark {
|
||||||
|
padding: 0;
|
||||||
|
background-color: transparent;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* MAPS */
|
||||||
|
#map {
|
||||||
|
position: absolute;
|
||||||
|
top: 56px;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-control-attribution {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.search-tip {
|
||||||
|
display: flex !important;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.tag {
|
||||||
|
flex-grow: 0;
|
||||||
|
padding: 0 2px;
|
||||||
|
min-width: 36px;
|
||||||
|
border-radius: 0.5em;
|
||||||
|
text-align: center;
|
||||||
|
color: white;
|
||||||
|
text-shadow: 0 0 3px #000000
|
||||||
|
}
|
||||||
|
.result {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
.t-red {
|
||||||
|
background-color: #cb2e51;
|
||||||
|
}
|
||||||
|
.t-whi {
|
||||||
|
background-color: #cb762e;
|
||||||
|
}
|
||||||
|
.t-sa2 {
|
||||||
|
background-color: #cbc42e;
|
||||||
|
}
|
||||||
|
.t-bea {
|
||||||
|
background-color: #34cb2e;
|
||||||
|
}
|
||||||
|
.t-hou {
|
||||||
|
background-color: #2e85cb;
|
||||||
|
}
|
||||||
|
.t-hes {
|
||||||
|
background-color: #742ecb;
|
||||||
|
}
|
||||||
|
.t-rdo {
|
||||||
|
background-color: #6f6f6f;
|
||||||
|
}
|
||||||
2
apps/reporter/static/css/tabulator_bootstrap5.min.css
vendored
Normal file
1
apps/reporter/static/js/autocomplete.min.js
vendored
Normal file
45
apps/reporter/static/js/exceljs.bare.min.js
vendored
Normal file
1
apps/reporter/static/js/exceljs.bare.min.js.map
Normal file
1
apps/reporter/static/js/luxon.min.js
vendored
Normal file
583
apps/reporter/static/js/script_reports.js
Normal file
|
|
@ -0,0 +1,583 @@
|
||||||
|
let table = null;
|
||||||
|
let period = null;
|
||||||
|
let columns = null;
|
||||||
|
let subcolumns = null;
|
||||||
|
let tblcolumns = null;
|
||||||
|
let rows = null;
|
||||||
|
let subrows = null;
|
||||||
|
let options = {};
|
||||||
|
// let mouse = "drag";
|
||||||
|
|
||||||
|
const form = document.getElementById('report_options');
|
||||||
|
const title_el = document.getElementById('report_title');
|
||||||
|
const title_contain = document.getElementById('title_container');
|
||||||
|
const progress = document.getElementById('report_progress');
|
||||||
|
const report_status = document.getElementById('report_status');
|
||||||
|
const dt = document.getElementById('datatable');
|
||||||
|
const searchbar = document.getElementById('filter');
|
||||||
|
//const tool_select = document.getElementById('tool_select');
|
||||||
|
const export_excel = document.getElementById('export_excel');
|
||||||
|
const btn_print = document.getElementById('btn_print');
|
||||||
|
const btn_link = document.getElementById('create_link');
|
||||||
|
const btn_expand = document.getElementById('btn_expand');
|
||||||
|
const btn_collapse = document.getElementById('btn_collapse');
|
||||||
|
|
||||||
|
let report_title = title_el.textContent;
|
||||||
|
let full_title = report_title;
|
||||||
|
let expandedNested = new Set();
|
||||||
|
let now = new Date();
|
||||||
|
let tblWidth = 0;
|
||||||
|
|
||||||
|
const sendOptions = async () => {
|
||||||
|
const formData = new FormData(form);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(window.location.href, {
|
||||||
|
method: "POST",
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
result = await response.json();
|
||||||
|
if (result.status == 'ok') {
|
||||||
|
//console.log(result);
|
||||||
|
now = new Date();
|
||||||
|
renderReport(result);
|
||||||
|
progress.style.width = "100%";
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
progress.style.width = "0%";
|
||||||
|
report_status.textContent = "Invalid options selected.";
|
||||||
|
for (error in result.errors) {
|
||||||
|
document.getElementById(`${error}:error`).textContent = result.errors[error];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
progress.style.width = "0%";
|
||||||
|
report_status.textContent = "Could not contact server.";
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const processColumns = (_columns) => {
|
||||||
|
let result = [];
|
||||||
|
_columns.forEach((item, index) => {
|
||||||
|
let column = {title: item.name, field: index.toString()};
|
||||||
|
if ('footer' in item) {
|
||||||
|
column.minWidth = 130;
|
||||||
|
if (item.footer === "sum") {
|
||||||
|
column.bottomCalc = "sum";
|
||||||
|
}
|
||||||
|
else if (item.footer === "margin") {
|
||||||
|
column.bottomCalc = (values, data, calcParams) => {
|
||||||
|
const sales = data.reduce((acc, row) => acc + row[item.margin[0]], 0);
|
||||||
|
if (sales == 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const costs = data.reduce((acc, row) => acc + row[item.margin[1]], 0);
|
||||||
|
|
||||||
|
return 1 - costs/sales;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else if (item.footer == "count") {
|
||||||
|
column.bottomCalc = (values, data, calcParams) => {
|
||||||
|
//console.log(values);
|
||||||
|
const counts = {};
|
||||||
|
for (const value of values) {
|
||||||
|
counts[value] = counts[value] ? counts[value] + 1 : 1;
|
||||||
|
}
|
||||||
|
return `${counts['Order'] ?? 0} order${counts['Order'] == 1 ? '' : 's'}, ${counts['Quote'] ?? 0} quote${counts['Quote'] == 1 ? '' : 's'}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ('format' in item) {
|
||||||
|
if (item.format === "int") {
|
||||||
|
column.formatter = intFormatter;
|
||||||
|
column.bottomCalcFormatter = intFormatter;
|
||||||
|
column.hozAlign = "right";
|
||||||
|
}
|
||||||
|
else if (item.format === "$") {
|
||||||
|
column.formatter = "money";
|
||||||
|
column.bottomCalcFormatter = "money";
|
||||||
|
column.hozAlign = "right";
|
||||||
|
}
|
||||||
|
else if (item.format === "%") {
|
||||||
|
column.formatter = percentFormatter;
|
||||||
|
column.bottomCalcFormatter = percentFormatter;
|
||||||
|
column.hozAlign = "right";
|
||||||
|
}
|
||||||
|
else if (item.format === "textarea") {
|
||||||
|
column.formatter = "textarea";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ('frozen' in item) {
|
||||||
|
column.frozen = true;
|
||||||
|
}
|
||||||
|
result.push(column);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderReport = (result) => {
|
||||||
|
report_title = result.title;
|
||||||
|
period = result.period;
|
||||||
|
columns = result.columns;
|
||||||
|
subcolumns = result.subcolumns;
|
||||||
|
tblcolumns = processColumns(result.columns);
|
||||||
|
rows = result.rows;
|
||||||
|
subrows = result.subrows;
|
||||||
|
options = result.options;
|
||||||
|
let subrowcount = 0;
|
||||||
|
|
||||||
|
if (period) {
|
||||||
|
full_title = `${report_title} for ${period}`;
|
||||||
|
title_el.textContent = full_title;
|
||||||
|
}
|
||||||
|
|
||||||
|
progress.style.width = "60%";
|
||||||
|
|
||||||
|
if (table !== null) { table.destroy(); }
|
||||||
|
tblWidth = title_contain.offsetWidth - 50;
|
||||||
|
|
||||||
|
let tableOptions = {
|
||||||
|
columns: tblcolumns,
|
||||||
|
data: rows,
|
||||||
|
importFormat: "array",
|
||||||
|
layout: "fitDataFill",
|
||||||
|
selectableRows: false,
|
||||||
|
movableColumns: true,
|
||||||
|
//height: 300,
|
||||||
|
printRowRange: "all",
|
||||||
|
columnDefaults: {resizable: "header"}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.group_by != null) {
|
||||||
|
if (typeof(options.group_by) == 'number') {
|
||||||
|
tableOptions.groupBy = options.group_by.toString();
|
||||||
|
}
|
||||||
|
else if (Array.isArray(options.group_by)) {
|
||||||
|
tableOptions.groupBy = Array.from(options.group_by.map(x => x.toString()));
|
||||||
|
}
|
||||||
|
// else {
|
||||||
|
// tableOptions.groupBy = '0';
|
||||||
|
// }
|
||||||
|
|
||||||
|
tableOptions.groupToggleElement = "header";
|
||||||
|
tableOptions.columnCalcs = "both";
|
||||||
|
tableOptions.groupClosedShowCalcs = true;
|
||||||
|
document.getElementById("group-controls").classList.remove('d-none');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
document.getElementById("group-controls").classList.add('d-none');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.subcolumns) {
|
||||||
|
for (let subrow in result.subrows) {
|
||||||
|
subrowcount += result.subrows[subrow].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
tableOptions.rowFormatter = (row) => {
|
||||||
|
let key = row.getCell("0").getValue();
|
||||||
|
if (key in result.subrows) {
|
||||||
|
let holderEl = document.createElement("div");
|
||||||
|
holderEl.classList.add('pb-2', 'ps-4');
|
||||||
|
holderEl.style.maxWidth = tblWidth + 'px';
|
||||||
|
|
||||||
|
let linkEl = document.createElement("a");
|
||||||
|
linkEl.href = '#';
|
||||||
|
let count = result.subrows[key].length;
|
||||||
|
let text = count == 1 ? "1 entry" : `${count} entries`;
|
||||||
|
|
||||||
|
holderEl.appendChild(linkEl);
|
||||||
|
|
||||||
|
if (expandedNested.has(key)) {
|
||||||
|
linkEl.innerHTML = '<i class="bi bi-caret-down me-1"></i>' + text;
|
||||||
|
linkEl.addEventListener('click', (event) => {
|
||||||
|
expandedNested.delete(key);
|
||||||
|
row.reformat();
|
||||||
|
});
|
||||||
|
|
||||||
|
let tableEl = document.createElement("div");
|
||||||
|
holderEl.appendChild(tableEl);
|
||||||
|
row.getElement().appendChild(holderEl);
|
||||||
|
|
||||||
|
let subTableOptions = {
|
||||||
|
columns: processColumns(result.subcolumns),
|
||||||
|
data: result.subrows[key],
|
||||||
|
importFormat: "array",
|
||||||
|
layout:"fitDataFill",
|
||||||
|
responsiveLayout:"collapse",
|
||||||
|
columnDefaults: {headerSort: false, resizable: false, responsive: 0},
|
||||||
|
selectableRows: false,
|
||||||
|
renderVertical:"basic"
|
||||||
|
}
|
||||||
|
subTableOptions.columns[4].responsive = 2;
|
||||||
|
|
||||||
|
let subTable = new Tabulator(tableEl, subTableOptions);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
linkEl.innerHTML = '<i class="bi bi-caret-right me-1"></i>' + text;
|
||||||
|
linkEl.addEventListener('click', (event) => {
|
||||||
|
expandedNested.add(key);
|
||||||
|
row.reformat();
|
||||||
|
});
|
||||||
|
|
||||||
|
row.getElement().appendChild(holderEl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tableOptions.rowFormatterPrint = false;
|
||||||
|
|
||||||
|
// The virtual DOM renderer is buggy when the row height is large.
|
||||||
|
tableOptions.renderVertical = 'basic';
|
||||||
|
}
|
||||||
|
|
||||||
|
report_status.textContent = `Showing ${rows.length}${subrowcount == 0 ? '' : '+'+subrowcount} record${rows.length == 1 ? '' : 's'}.`;
|
||||||
|
|
||||||
|
table = new Tabulator("#datatable", tableOptions);
|
||||||
|
table.on("dataLoaded", (data) => {
|
||||||
|
//pointerScroll(document.getElementsByClassName("tabulator-tableholder")[0]);
|
||||||
|
document.getElementById('table-controls').classList.remove('invisible');
|
||||||
|
});
|
||||||
|
|
||||||
|
searchbar.addEventListener("keyup", () => {
|
||||||
|
let filters = [];
|
||||||
|
|
||||||
|
table.getColumns().forEach((column) => {
|
||||||
|
filters.push({
|
||||||
|
field: column.getField(),
|
||||||
|
type: "like",
|
||||||
|
value: searchbar.value,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
table.setFilter([filters]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// const pointerScroll = (elem) => {
|
||||||
|
// const dragStart = (ev) => {
|
||||||
|
// if(mouse === "drag" && ev.button == 0 && ev.target.tagName != 'A' && !Array.from(document.getElementsByClassName('tabulator-group')).some((node) => {
|
||||||
|
// return node.contains(ev.target);
|
||||||
|
// })) {
|
||||||
|
// ev.preventDefault();
|
||||||
|
// elem.setPointerCapture(ev.pointerId);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// const dragEnd = (ev) => {
|
||||||
|
// if(mouse === "drag" && ev.button == 0) {
|
||||||
|
// elem.releasePointerCapture(ev.pointerId);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// const drag = (ev) => {
|
||||||
|
// ev.preventDefault();
|
||||||
|
// if(ev.buttons != 0 && elem.hasPointerCapture(ev.pointerId)) {
|
||||||
|
// elem.scrollLeft -= ev.movementX;
|
||||||
|
// elem.scrollTop -= ev.movementY;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// elem.addEventListener("pointerdown", dragStart);
|
||||||
|
// elem.addEventListener("pointerup", dragEnd);
|
||||||
|
// elem.addEventListener("pointermove", drag);
|
||||||
|
// };
|
||||||
|
|
||||||
|
const excelWidth = (width) => {
|
||||||
|
return Math.floor((width * 7 + 5) / 7 * 256) / 256;
|
||||||
|
}
|
||||||
|
|
||||||
|
const excelFixColumns = (sheet, icolumns, irows, mainsheet=true) => {
|
||||||
|
let rowskip = mainsheet ? 2 : 0;
|
||||||
|
let colskip = mainsheet ? 0 : 2;
|
||||||
|
|
||||||
|
for (let i = 0; i < icolumns.length+colskip; i++) {
|
||||||
|
const column = sheet.getColumn(i+1);
|
||||||
|
let maxLength = 10;
|
||||||
|
for (let j = 1+rowskip; j <= irows.length+1+rowskip; j++) {
|
||||||
|
if (column.values[j]) {
|
||||||
|
maxLength = Math.max(maxLength, column.values[j].toString().length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
column.width = excelWidth(maxLength + 2);
|
||||||
|
|
||||||
|
if (i >= colskip && 'format' in icolumns[i-colskip]) {
|
||||||
|
if (icolumns[i-colskip].format == "int") {
|
||||||
|
column.numFmt = "0";
|
||||||
|
}
|
||||||
|
else if (icolumns[i-colskip].format == "$") {
|
||||||
|
column.numFmt = "#,##0.00";
|
||||||
|
}
|
||||||
|
else if (icolumns[i-colskip].format == "%") {
|
||||||
|
column.numFmt = "0.00%";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const excelFormatter = async (list, _options, setFileContents) => {
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const sheet_name = report_title.replaceAll(/[\*\?\:\\\/\[\]]/g, ' ');
|
||||||
|
|
||||||
|
if (options.formatting == "parker") {
|
||||||
|
const todayDate = new Date();
|
||||||
|
const lastMonth = new Date(todayDate.getFullYear(), todayDate.getMonth() - 1, 1);
|
||||||
|
const reportMonth = String(lastMonth.getMonth()+1).padStart(2, '0');
|
||||||
|
const reportYear = String(lastMonth.getFullYear()).slice(2);
|
||||||
|
|
||||||
|
const sheet = workbook.addWorksheet("CEA");
|
||||||
|
sheet.columns = [
|
||||||
|
{ header: '*DFH', key: '0', width: excelWidth(4) },
|
||||||
|
{ header: '292512', key: '1', width: excelWidth(10) },
|
||||||
|
{ header: 'Custom Energized Air Ltd., Edmonton, AB', key: '2', width: excelWidth(3) },
|
||||||
|
{ key: '3', width: excelWidth(25) },
|
||||||
|
{ key: '4', width: excelWidth(18) },
|
||||||
|
{ key: '5', width: excelWidth(2) },
|
||||||
|
{ key: '6', width: excelWidth(5) },
|
||||||
|
{ key: '7', width: excelWidth(9) },
|
||||||
|
{ key: '8', width: excelWidth(4) },
|
||||||
|
{ header: `${reportMonth}${reportYear}`, key: '9', width: excelWidth(20) },
|
||||||
|
{ key: '10', width: excelWidth(6) },
|
||||||
|
{ key: '11', width: excelWidth(4) },
|
||||||
|
{ key: '12', width: excelWidth(10) },
|
||||||
|
]
|
||||||
|
|
||||||
|
for (let row of rows.toSorted( (a,b) => a[6].localeCompare(b[6]) )) {
|
||||||
|
sheet.addRow(['*DDD', row[0], null, row[1], row[2], row[3], null, row[8], null, row[6], null, row[9]]);
|
||||||
|
}
|
||||||
|
sheet.addRow(['*DFT', '292512', '0000000000']);
|
||||||
|
}
|
||||||
|
else if (options.formatting) {
|
||||||
|
let export_columns = [];
|
||||||
|
columns.forEach(column => {
|
||||||
|
let col_options = { name: column.name, filterButton: true }
|
||||||
|
if ('footer' in column) {
|
||||||
|
if (column.footer === "sum") {
|
||||||
|
col_options.totalsRowFunction = 'sum';
|
||||||
|
}
|
||||||
|
else if (column.footer === 'margin') {
|
||||||
|
col_options.totalsRowFunction = 'custom';
|
||||||
|
let sales = `SUBTOTAL(109,[${columns[column.margin[0]].name}])`;
|
||||||
|
let costs = `SUBTOTAL(109,[${columns[column.margin[1]].name}])`;
|
||||||
|
col_options.totalsRowFormula = `IFERROR(1 - ${costs} / ${sales}, "")`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export_columns.push(col_options);
|
||||||
|
});
|
||||||
|
if (!('footer' in columns[0])) {
|
||||||
|
export_columns[0].totalsRowLabel = 'Totals:';
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = create_link(true);
|
||||||
|
const sheet = workbook.addWorksheet(sheet_name, {
|
||||||
|
views: [ {state: 'frozen', xSplit: 0, ySplit:3} ]
|
||||||
|
});
|
||||||
|
sheet.getCell('A1').value = `${full_title} - Ran ${now.toLocaleString('sv')}`;
|
||||||
|
sheet.getCell('A2').value = { text: link, hyperlink: link };
|
||||||
|
sheet.addTable({
|
||||||
|
name: 'data',
|
||||||
|
ref: 'A3',
|
||||||
|
headerRow: true,
|
||||||
|
totalsRow: true,
|
||||||
|
style: { showRowStripes: true },
|
||||||
|
columns: export_columns,
|
||||||
|
rows: rows
|
||||||
|
});
|
||||||
|
|
||||||
|
excelFixColumns(sheet, columns, rows);
|
||||||
|
|
||||||
|
if (subcolumns !== null) {
|
||||||
|
let export_subcolumns = Array.from(subcolumns.map(column => {return {name: column.name, filterButton: true};}));
|
||||||
|
export_subcolumns.unshift({name: columns[1].name, filterButton: true});
|
||||||
|
export_subcolumns.unshift({name: columns[0].name, filterButton: true});
|
||||||
|
|
||||||
|
let descriptions = rows.reduce((result, row) => {
|
||||||
|
result[row[0]] = row[1];
|
||||||
|
return result;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const subsheet = workbook.addWorksheet('Detail', {
|
||||||
|
views: [ {state: 'frozen', xSplit: 0, ySplit:1} ]
|
||||||
|
});
|
||||||
|
let flatrows = [];
|
||||||
|
for (let key in subrows) {
|
||||||
|
for (let subrow of subrows[key]) {
|
||||||
|
flatrows.push([key, descriptions[key], ...subrow])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (flatrows.length == 0) {
|
||||||
|
flatrows.push(['', '', '', '', '', '', '', '', '', '', ''])
|
||||||
|
}
|
||||||
|
subsheet.addTable({
|
||||||
|
name: 'detail',
|
||||||
|
ref: 'A1',
|
||||||
|
headerRow: true,
|
||||||
|
style: { showRowStripes: true },
|
||||||
|
columns: export_subcolumns,
|
||||||
|
rows: flatrows
|
||||||
|
});
|
||||||
|
|
||||||
|
if (options.formatting != "performance") {
|
||||||
|
excelFixColumns(subsheet, subcolumns, flatrows, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const sheet = workbook.addWorksheet(sheet_name);
|
||||||
|
sheet.columns = tblcolumns.map(col => { return {header: col.title, key: col.field} });
|
||||||
|
for (let row of rows) {
|
||||||
|
sheet.addRow(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await workbook.xlsx.writeBuffer();
|
||||||
|
|
||||||
|
setFileContents(buffer, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||||
|
}
|
||||||
|
|
||||||
|
const intFormatter = (cell, formatterParams, onRendered) => {
|
||||||
|
if(typeof cell.getValue() === "undefined") {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
let num = parseInt(cell.getValue().toString(), 10);
|
||||||
|
return isNaN(num) ? '' : num;
|
||||||
|
}
|
||||||
|
const percentFormatter = (cell, formatterParams, onRendered) => {
|
||||||
|
if(typeof cell.getValue() === "undefined") {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
let num = parseFloat(cell.getValue().toString());
|
||||||
|
return isNaN(num) ? '' : (num*100).toFixed(2)+'%';
|
||||||
|
}
|
||||||
|
|
||||||
|
const create_link = (everything) => {
|
||||||
|
let base_url = window.location.href.split(/[?#]/)[0];
|
||||||
|
let params = [];
|
||||||
|
document.querySelectorAll("input[type=checkbox]").forEach( (input) => {
|
||||||
|
if (input.checked) {
|
||||||
|
params.push(input.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.querySelectorAll("input[type=radio]").forEach( (input) => {
|
||||||
|
if (input.checked) {
|
||||||
|
params.push(`${input.name}=${input.value}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.querySelectorAll("input[type=number]").forEach( (input) => {
|
||||||
|
params.push(`${input.name}=${input.value}`);
|
||||||
|
});
|
||||||
|
if (everything) {
|
||||||
|
document.querySelectorAll("input").forEach( (input) => {
|
||||||
|
if (['date', 'month'].includes(input.type)) {
|
||||||
|
params.push(`${input.name}=${input.value}`);
|
||||||
|
}
|
||||||
|
else if (input.type == 'text' && input.id != 'filter') {
|
||||||
|
params.push(`${input.name}=${encodeURIComponent(input.value)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push('run');
|
||||||
|
return `${base_url}?${params.join('&')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkall = (name) => {
|
||||||
|
const group_checkboxes = [...document.querySelectorAll("input[type=checkbox]")]
|
||||||
|
.filter( (input) => input.name.startsWith(name) );
|
||||||
|
const state = group_checkboxes.every( (input) => input.checked );
|
||||||
|
group_checkboxes.forEach( (input) => input.checked = !state );
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSuggestions = async (query) => {
|
||||||
|
try {
|
||||||
|
const method = document.querySelector(`input[name=${suggest.getAttribute('suggest')}]:checked`).value;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("options", method);
|
||||||
|
const response = await fetch('/suggest', {
|
||||||
|
method: "POST",
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener("submit", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
progress.style.width = "20%";
|
||||||
|
report_status.textContent = "Retrieving data...";
|
||||||
|
for (message_field of document.getElementsByClassName('error')) {
|
||||||
|
message_field.textContent = ""
|
||||||
|
}
|
||||||
|
sendOptions();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (document.getElementById('autorun').value == 'true') {
|
||||||
|
sendOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
btn_link.addEventListener("click", (event) => {
|
||||||
|
window.location = create_link(true);
|
||||||
|
});
|
||||||
|
// tool_select.addEventListener("click", (event) => {
|
||||||
|
// if (mouse == "drag") {
|
||||||
|
// mouse = "select";
|
||||||
|
// tool_select.textContent = "Mouse: Select";
|
||||||
|
// tool_select.classList.add("active");
|
||||||
|
// dt.childNodes[1].classList.add("selectable");
|
||||||
|
// }
|
||||||
|
// else if (mouse == "select") {
|
||||||
|
// mouse = "drag";
|
||||||
|
// tool_select.textContent = "Mouse: Drag";
|
||||||
|
// tool_select.classList.remove("active");
|
||||||
|
// dt.childNodes[1].classList.remove("selectable");
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
export_excel.addEventListener("click", (event) => {
|
||||||
|
table.download(excelFormatter, `${full_title} - Ran ${now.toLocaleString('sv').replace(':', '-')}.xlsx`);
|
||||||
|
});
|
||||||
|
btn_print.addEventListener("click", (event) => {
|
||||||
|
table.print();
|
||||||
|
});
|
||||||
|
btn_expand.addEventListener("click", (event) => {
|
||||||
|
table.setGroupStartOpen(true);
|
||||||
|
let original = table.options.groupBy;
|
||||||
|
table.setGroupBy(false);
|
||||||
|
table.setGroupBy(original);
|
||||||
|
});
|
||||||
|
btn_collapse.addEventListener("click", (event) => {
|
||||||
|
table.setGroupStartOpen(false);
|
||||||
|
let original = table.options.groupBy;
|
||||||
|
table.setGroupBy(false);
|
||||||
|
table.setGroupBy(original);
|
||||||
|
});
|
||||||
|
|
||||||
|
let suggest = document.querySelector('[suggest]');
|
||||||
|
if (suggest) {
|
||||||
|
suggest.addEventListener("selection", (event) => {
|
||||||
|
suggest.value = event.detail.selection.value.split(' - ')[0];
|
||||||
|
});
|
||||||
|
let config = {
|
||||||
|
selector: () => { return suggest },
|
||||||
|
data: {
|
||||||
|
src: getSuggestions,
|
||||||
|
cache: true
|
||||||
|
},
|
||||||
|
resultItem: { highlight: true },
|
||||||
|
resultsList: {
|
||||||
|
destination: '#searchresults',
|
||||||
|
position: 'afterbegin',
|
||||||
|
maxResults: 100,
|
||||||
|
//noResults: true,
|
||||||
|
tabSelect: true
|
||||||
|
},
|
||||||
|
wrapper: false
|
||||||
|
};
|
||||||
|
let autoCompleteJS = new autoComplete(config);
|
||||||
|
document.querySelectorAll(`input[name=${suggest.getAttribute('suggest')}]`).forEach( (input) => {
|
||||||
|
input.addEventListener("change", (radio) => {
|
||||||
|
getSuggestions('').then((data) => {
|
||||||
|
autoCompleteJS.data.store = data;
|
||||||
|
autoCompleteJS.start(suggest.value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
3
apps/reporter/static/js/tabulator.min.js
vendored
Normal file
1
apps/reporter/static/js/tabulator.min.js.map
Normal file
1
apps/reporter/subapps/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
from .admin import app as admin
|
||||||
74
apps/reporter/subapps/admin.py
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import asyncio
|
||||||
|
from aiohttp import web
|
||||||
|
import aiohttp_security
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import os
|
||||||
|
import psutil
|
||||||
|
import random
|
||||||
|
|
||||||
|
app = web.Application()
|
||||||
|
routes = web.RouteTableDef()
|
||||||
|
|
||||||
|
@routes.get('')
|
||||||
|
async def admin_get(request):
|
||||||
|
await aiohttp_security.check_permission(request, 'admin')
|
||||||
|
template = request.config_dict['templates']['admin.html']
|
||||||
|
parts = {
|
||||||
|
'title': request.config_dict['config']['app']['name'] + ' Admin',
|
||||||
|
'menu': request.config_dict['menu']
|
||||||
|
}
|
||||||
|
return web.Response(text=template.safe_substitute(parts), content_type="text/html")
|
||||||
|
|
||||||
|
@routes.post('/api')
|
||||||
|
async def api(request):
|
||||||
|
await aiohttp_security.check_permission(request, 'admin')
|
||||||
|
timestamp = datetime.now().strftime('%H:%M:%S')
|
||||||
|
postdata = await request.post()
|
||||||
|
command = postdata.get('command').split(' ')
|
||||||
|
if command[0] == "status":
|
||||||
|
try:
|
||||||
|
count_tables = "SELECT COUNT(*) FROM CompanyC.INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'BASE TABLE'"
|
||||||
|
await request.config_dict['cur'].execute(count_tables)
|
||||||
|
result = await request.config_dict['cur'].fetchone()
|
||||||
|
tables = result[0]
|
||||||
|
db_status = 'OK'
|
||||||
|
except Exception as e:
|
||||||
|
tables = 0
|
||||||
|
db_status = str(e).rstrip('.')
|
||||||
|
|
||||||
|
core_count = psutil.cpu_count(logical=False)
|
||||||
|
thread_count = psutil.cpu_count()
|
||||||
|
cpu_load = psutil.cpu_percent()
|
||||||
|
ram_stats = psutil.virtual_memory()
|
||||||
|
ram_total = ram_stats[0]
|
||||||
|
ram_used = ram_stats[3]
|
||||||
|
magic_8_ball = [
|
||||||
|
"It is certain", "It is decidedly so", "Without a doubt", "Yes definitely", "You may rely on it",
|
||||||
|
"As I see it, yes", "Most likely", "Outlook good", "Yes", "Signs point to yes",
|
||||||
|
"Reply hazy, try again", "Ask again later", "Better not tell you now", "Cannot predict now", "Concentrate and ask again",
|
||||||
|
"Don't count on it", "My reply is no", "My sources say no", "Outlook not so good", "Very doubtful"
|
||||||
|
]
|
||||||
|
|
||||||
|
data = f"""\
|
||||||
|
Web server status: {request.config_dict['stats']['status']}, started {request.config_dict['start']:%Y-%m-%d %H:%M:%S}.
|
||||||
|
Database at {request.config_dict['config']['database']['host']}: {db_status}, access to {tables:,} tables.
|
||||||
|
Load on {core_count} cores, {thread_count} threads: {cpu_load}%. Using {ram_used/1024**3:.1f} GiB of {ram_total/1024**3:.1f} GiB memory.
|
||||||
|
Your magic 8-ball prediction: {random.choice(magic_8_ball)}."""
|
||||||
|
return web.json_response({'status': 'ok', 'data': data, 'timestamp': timestamp})
|
||||||
|
|
||||||
|
elif command[0] == "shutdown":
|
||||||
|
request.config_dict['stats']['status'] = 'Shutting down'
|
||||||
|
delay = 5 if len(command) == 1 else int(command[1])
|
||||||
|
asyncio.create_task(shutdown(request.config_dict['runner'], request.config_dict['waiter'], delay))
|
||||||
|
return web.json_response({'status': 'ok', 'data': f'Shutting down in {delay} seconds...', 'timestamp': timestamp})
|
||||||
|
|
||||||
|
else:
|
||||||
|
return web.json_response({'status': 'error', 'data': f'Unrecognized command "{command[0]}"', 'timestamp': timestamp})
|
||||||
|
|
||||||
|
async def shutdown(runner, waiter, delay=0):
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
await runner.cleanup()
|
||||||
|
waiter.set()
|
||||||
|
|
||||||
|
app.add_routes(routes)
|
||||||
158
apps/reporter/templates/admin.html
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${title}</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="/static/bootstrap/bootstrap.min.css">
|
||||||
|
<link rel="stylesheet" type="text/css" href="/static/bootstrap/bootstrap-icons.min.css">
|
||||||
|
<link rel="stylesheet" type="text/css" href="./static/css/style.css">
|
||||||
|
<link rel="shortcut icon" type="image/png" href="./static/favicon.png">
|
||||||
|
<link rel="shortcut icon" sizes="192x192" href="./static/favicon.png">
|
||||||
|
<link rel="apple-touch-icon" href="./static/favicon.png">
|
||||||
|
<style type="text/css">
|
||||||
|
#output {
|
||||||
|
height: 20em;
|
||||||
|
resize: vertical;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
#command {
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
.status-ok {
|
||||||
|
color: var(--bs-success-text-emphasis);
|
||||||
|
}
|
||||||
|
.status-error {
|
||||||
|
color: var(--bs-danger-text-emphasis);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="wrapper" class="d-flex flex-column vh-100">
|
||||||
|
${menu}
|
||||||
|
|
||||||
|
<div class="container flex-grow-1 min-height-0 z-0">
|
||||||
|
<div class="row h-100">
|
||||||
|
<div class="p-3 mx-auto">
|
||||||
|
<select class="form-select mb-3" id="commandlist">
|
||||||
|
<option value="" disabled selected>Commands</option>
|
||||||
|
<option value="status">Status</option>
|
||||||
|
<option value="shutdown">Shut down</option>
|
||||||
|
</select>
|
||||||
|
<div id="output" class="w-100 border overflow-y-auto p-2 mb-3"></div>
|
||||||
|
<form method="post" action="/reporter/admin/api" id="commandform" class="mb-3" autocomplete="off">
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control" id="command" name="command">
|
||||||
|
<button type="submit" class="btn btn-primary"><i class="bi bi-send"></i></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/bootstrap/bootstrap.bundle.min.js"></script>
|
||||||
|
<script type="text/javascript">
|
||||||
|
const el = (id) => document.getElementById(id);
|
||||||
|
let theme = "light";
|
||||||
|
|
||||||
|
const form = el('commandform');
|
||||||
|
const output = el('output');
|
||||||
|
const commandEl = el('command');
|
||||||
|
const commandList = el('commandlist');
|
||||||
|
const html = document.documentElement;
|
||||||
|
let history = [];
|
||||||
|
let cursor = null;
|
||||||
|
let buffer = "";
|
||||||
|
|
||||||
|
const sendCommand = async () => {
|
||||||
|
const command = commandEl.value;
|
||||||
|
if (command == '') { return; }
|
||||||
|
if (command != history[0]) {
|
||||||
|
history.unshift(command);
|
||||||
|
}
|
||||||
|
output.innerHTML += `<pre class='mb-0'>> ${command}</pre>`;
|
||||||
|
const formData = new FormData(form);
|
||||||
|
commandEl.value = '';
|
||||||
|
cursor = null;
|
||||||
|
buffer = "";
|
||||||
|
|
||||||
|
const words = command.split(' ');
|
||||||
|
|
||||||
|
if (words[0] == "history") {
|
||||||
|
output.innerHTML += `<pre class="status-ok">${history.toReversed().join('\n')}</pre>`;
|
||||||
|
}
|
||||||
|
else if (words[0] == "theme") {
|
||||||
|
const themes = ["dark", "light"];
|
||||||
|
if (words.length == 1) {
|
||||||
|
output.innerHTML += `<pre class="status-ok">Using ${theme} theme.</pre>`;
|
||||||
|
}
|
||||||
|
else if (themes.includes(words[1])) {
|
||||||
|
html.setAttribute("data-bs-theme", words[1]);
|
||||||
|
theme = words[1];
|
||||||
|
output.innerHTML += `<pre class="status-ok">Theme set to ${words[1]}</pre>`;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
output.innerHTML += `<pre class="status-error">Theme ${words[1]} unsupported. Available: ${themes.join(", ")}</pre>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
try {
|
||||||
|
const response = await fetch(form.action, {
|
||||||
|
method: "POST",
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
result = await response.json();
|
||||||
|
output.innerHTML += `<pre class="status-${result.status}">${result.data}\n[${result.timestamp}]</pre>`;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output.scrollTop = output.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
commandList.addEventListener("change", (event) => {
|
||||||
|
commandEl.value = commandList.value;
|
||||||
|
sendCommand();
|
||||||
|
commandList.options[0].selected = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
form.addEventListener("submit", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
sendCommand();
|
||||||
|
});
|
||||||
|
|
||||||
|
commandEl.addEventListener("keydown", (event) => {
|
||||||
|
if (event.keyCode == 38) { //up
|
||||||
|
event.preventDefault();
|
||||||
|
if (cursor == null && history.length > 0) {
|
||||||
|
cursor = 0;
|
||||||
|
buffer = commandEl.value;
|
||||||
|
commandEl.value = history[0];
|
||||||
|
}
|
||||||
|
else if (history.length > cursor+1) {
|
||||||
|
cursor++;
|
||||||
|
commandEl.value = history[cursor];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (event.keyCode == 40) { //down
|
||||||
|
event.preventDefault();
|
||||||
|
if (cursor == 0) {
|
||||||
|
cursor = null;
|
||||||
|
commandEl.value = buffer;
|
||||||
|
buffer = "";
|
||||||
|
}
|
||||||
|
else if (cursor > 0) {
|
||||||
|
cursor--;
|
||||||
|
commandEl.value = history[cursor];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
commandEl.focus();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
79
apps/reporter/templates/home.html
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${title}</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="/static/bootstrap/bootstrap.min.css">
|
||||||
|
<link rel="stylesheet" type="text/css" href="/static/bootstrap/bootstrap-icons.min.css">
|
||||||
|
<link rel="stylesheet" type="text/css" href="./static/css/style.css">
|
||||||
|
<link rel="shortcut icon" type="image/png" href="./static/favicon.png">
|
||||||
|
<link rel="shortcut icon" sizes="192x192" href="./static/favicon.png">
|
||||||
|
<link rel="apple-touch-icon" href="./static/favicon.png">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="wrapper" class="d-flex flex-column vh-100">
|
||||||
|
${menu}
|
||||||
|
|
||||||
|
<div class="container-fluid flex-grow-1 min-height-0 z-0">
|
||||||
|
<div class="row h-100">
|
||||||
|
<div class="col-md-4 col-xl-3 mh-100 overflow-y-auto">
|
||||||
|
<div class="bg-body-tertiary p-3 mh-100">
|
||||||
|
<div class="row"><div class="col-12"><span class="fs-2 fw-medium">About Reporter</span></div></div>
|
||||||
|
<div class="row"><div class="col-6">Version:</div><div class="col-6">${version}</div></div>
|
||||||
|
<div class="row"><div class="col-6">Reports available:</div><div class="col-6">${reports}</div></div>
|
||||||
|
<div class="row"><div class="col-6">Uptime:</div><div class="col-6">${uptime}</div></div>
|
||||||
|
<div class="row"><div class="col-6">Reports ran since startup:</div><div class="col-6">${reports_ran}</div></div>
|
||||||
|
<div class="row"><div class="col-12"><span class="fs-2 fw-medium">About You</span></div></div>
|
||||||
|
<div class="row"><div class="col-6">Account type:</div><div class="col-6">${usertype}</div></div>
|
||||||
|
<div class="row"><div class="col-6"></div><div class="col-6">${role_specific}</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-8 col-xl-9 mh-100 overflow-y-auto">
|
||||||
|
<main class="p-3 mh-100">
|
||||||
|
<div class="d-flex justify-content-between align-items-center gap-3 mb-3">
|
||||||
|
<span class="fs-1 fw-medium">Demo Database</span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div>This demo is populated with the <a href="https://en.wikiversity.org/wiki/Database_Examples/Northwind">Northwind Traders sample data</a> distributed with Microsoft Access. The order dates fall between July 4, 1996 and February 12, 1997.</div>
|
||||||
|
<img src="https://upload.wikimedia.org/wikiversity/en/thumb/a/ac/Northwind_E-R_Diagram.png/640px-Northwind_E-R_Diagram.png" class="img-fluid">
|
||||||
|
<div>Try running these sample reports:</div>
|
||||||
|
<ul class="mb-0">
|
||||||
|
<li><a href="/reporter/report/orders_by_employee">Orders by employee</a></li>
|
||||||
|
<li><a href="/reporter/report/orders_by_location">Orders by customer location</a></li>
|
||||||
|
<li><a href="/reporter/report/orders_by_product">Orders by product</a></li>
|
||||||
|
<li><a href="/reporter/report/orders_by_supplier">Orders by supplier</a></li>
|
||||||
|
<li><a href="/reporter/report/pivot_table">Pivot table</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between align-items-center gap-3 mb-3">
|
||||||
|
<span class="fs-1 fw-medium">What's new?</span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12"><span class="badge text-bg-primary">February 1, 2025</span> <strong>Reporter v1.3 released.</strong>
|
||||||
|
<ul class="mb-0">
|
||||||
|
<li>Table library switched from DataTables.net to Tabulator. Columns can now be resized and reordered. Header and footer rows stay on the screen when scrolling. Groups can now be collapsed. Added controls to expand/collapse all groups.</li>
|
||||||
|
<li>Excel library switched from SheetJS to ExcelJS. Export files now support striped rows and include totals row. The header row is now frozen by default.</li>
|
||||||
|
<li>Columns can now be frozen.</li>
|
||||||
|
<li>Layout is more mobile-friendly. Options and Data sections are now independently scrollable. Buttons stay within the Options section when scrolling.</li>
|
||||||
|
<li>Added Print button.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<!--div class="col-12"><span class="badge text-bg-primary">July 10, 2024</span> Started publishing this change log.</div-->
|
||||||
|
</div>
|
||||||
|
<table id="datatable" class="table table-striped"></table>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/bootstrap/bootstrap.bundle.min.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
9
apps/reporter/templates/navbar-menu.html
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" id="${id}" role="button"
|
||||||
|
data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
${name}
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu ${align}" aria-labelledby="${id}">
|
||||||
|
${items}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
17
apps/reporter/templates/navbar.html
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<nav class="navbar navbar-expand-lg sticky-top bg-dark" data-bs-theme="dark">
|
||||||
|
<div class="container">
|
||||||
|
<a class="navbar-brand" href="/reporter">${title}</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"
|
||||||
|
aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
|
<ul class="navbar-nav me-auto">
|
||||||
|
${leftmenu}
|
||||||
|
</ul>
|
||||||
|
<ul class="navbar-nav">
|
||||||
|
${rightmenu}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
3
apps/reporter/templates/options/checkbox-helper.html
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
<div class="btn-group btn-group-sm pe-2" role="group" aria-label="Check/uncheck all">
|
||||||
|
<button type="button" class="btn btn-primary py-0 px-1" onclick="checkall('${group}')"><i class="bi bi-check-all"></i></button>
|
||||||
|
</div>
|
||||||
4
apps/reporter/templates/options/checkbox.html
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="${group}:${key}" name="${group}:${key}" ${checked}>
|
||||||
|
<label class="form-check-label" for="${group}:${key}">${label}</label>
|
||||||
|
</div>
|
||||||
4
apps/reporter/templates/options/date.html
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<div class="input-group mb-2">
|
||||||
|
<span class="input-group-text col-3-label">${label}</span>
|
||||||
|
<input type="${type}" class="form-control col-9" id="${group}:${key}" name="${group}:${key}" $value>
|
||||||
|
</div>
|
||||||
8
apps/reporter/templates/options/fieldset.html
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<fieldset class="my-2 px-3" id="${group}">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
${helper}
|
||||||
|
<legend class="mb-0">${name}</legend>
|
||||||
|
</div>
|
||||||
|
<div id="${group}:error" class="text-danger error"></div>
|
||||||
|
${controls}
|
||||||
|
</fieldset>
|
||||||
4
apps/reporter/templates/options/number.html
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<div class="input-group mb-2">
|
||||||
|
<span class="input-group-text col-3-label">${label}</span>
|
||||||
|
<input type="${type}" class="form-control col-9" id="${group}:${key}" name="${group}:${key}" $min $max $value>
|
||||||
|
</div>
|
||||||
4
apps/reporter/templates/options/option.html
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" id="${group}:${key}" name="${group}" value="${key}" ${checked}>
|
||||||
|
<label class="form-check-label" for="${group}:${key}">${label}</label>
|
||||||
|
</div>
|
||||||
5
apps/reporter/templates/options/search.html
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text col-3-label">${label}</span>
|
||||||
|
<input type="$type" class="form-control col-9" id="${group}:${key}" name="${group}:${key}" suggest="$suggest" autocomplete="off" $value>
|
||||||
|
</div>
|
||||||
|
<div class="searchresults" id="searchresults"></div>
|
||||||
98
apps/reporter/templates/report.html
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${title}</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="/static/bootstrap/bootstrap.min.css">
|
||||||
|
<link rel="stylesheet" type="text/css" href="/static/bootstrap/bootstrap-icons.min.css">
|
||||||
|
<link rel="stylesheet" type="text/css" href="/reporter/static/css/style.css">
|
||||||
|
<link rel="stylesheet" type="text/css" href="/reporter/static/css/tabulator_bootstrap5.min.css">
|
||||||
|
<link rel="stylesheet" type="text/css" href="/reporter/static/css/autocomplete.min.css">
|
||||||
|
<link rel="shortcut icon" type="image/png" href="/reporter/static/favicon.png">
|
||||||
|
<link rel="shortcut icon" sizes="192x192" href="/reporter/static/favicon.png">
|
||||||
|
<link rel="apple-touch-icon" href="/reporter/static/favicon.png">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="wrapper" class="d-flex flex-column vh-100">
|
||||||
|
${menu}
|
||||||
|
|
||||||
|
<div class="container-fluid flex-grow-1 min-height-0 z-0">
|
||||||
|
<div class="row h-100">
|
||||||
|
<div class="col-md-4 col-xl-3 mh-100">
|
||||||
|
<div class="bg-body-tertiary mh-100 overflow-y-auto">
|
||||||
|
<form method="post" id="report_options">
|
||||||
|
<div class="sticky-top px-3 pt-3 bg-body-tertiary">
|
||||||
|
<div class="d-flex justify-content-between align-items-center gap-3 mb-2">
|
||||||
|
<span class="fs-2 fw-medium">Options</span>
|
||||||
|
<button type="submit" class="btn btn-primary fs-4 text-nowrap"><i class="bi bi-play"></i> Run</button>
|
||||||
|
<input type="hidden" id="autorun" value="${autorun}">
|
||||||
|
</div>
|
||||||
|
<div class="progress bg-dark d-flex mb-2 position-relative" role="progressbar" aria-label="Report progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
|
||||||
|
<div id="report_status" class="position-absolute w-100 text-center align-self-center text-light">Ready</div>
|
||||||
|
<div id="report_progress" class="progress-bar" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="mb-0">${report_description}</p>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
</div>
|
||||||
|
${options}
|
||||||
|
<!--
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="slider" class="form-label">Range Slider</label>
|
||||||
|
<input type="range" class="form-range" id="slider">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="dropdown" class="form-label">Dropdown Selector</label>
|
||||||
|
<select class="form-select" id="dropdown">
|
||||||
|
<option selected>Select an option</option>
|
||||||
|
<option value="option1">Option 1</option>
|
||||||
|
<option value="option2">Option 2</option>
|
||||||
|
<option value="option3">Option 3</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
-->
|
||||||
|
<div class="sticky-bottom px-3 pb-3 bg-body-tertiary">
|
||||||
|
<hr>
|
||||||
|
<button type="button" id="create_link" class="btn btn-dark w-100"><i class="bi bi-star-fill"></i> Direct link to report with these options</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-8 col-xl-9 h-100 d-flex flex-column">
|
||||||
|
<div class="p-3" id="title_container">
|
||||||
|
<span class="fs-1 fw-medium" id="report_title">${report_title}</span>
|
||||||
|
</div>
|
||||||
|
<div id="table-controls" class="btn-toolbar justify-content-between invisible">
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||||
|
<input type="text" class="form-control" id="filter" placeholder="Search...">
|
||||||
|
</div>
|
||||||
|
<div class="d-none mb-3" id="group-controls">
|
||||||
|
<button type="button" class="btn btn-secondary" id="btn_expand"><i class="bi bi-arrows-expand"></i> Expand all</button>
|
||||||
|
<button type="button" class="btn btn-secondary" id="btn_collapse"><i class="bi bi-arrows-collapse"></i> Collapse all</button>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group mb-3">
|
||||||
|
<!--button type="button" class="btn btn-secondary" id="tool_select">Mouse: Drag</button-->
|
||||||
|
<button type="button" class="btn btn-secondary" id="btn_print"><i class="bi bi-printer"></i> Print</button>
|
||||||
|
<button type="button" class="btn btn-excel" id="export_excel"><i class="bi bi-file-earmark-spreadsheet"></i> To Excel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="datatable" class="flex-grow-1"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="/static/bootstrap/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="/reporter/static/js/luxon.min.js"></script>
|
||||||
|
<script src="/reporter/static/js/exceljs.bare.min.js"></script>
|
||||||
|
<script src="/reporter/static/js/tabulator.min.js"></script>
|
||||||
|
<script src="/reporter/static/js/autocomplete.min.js"></script>
|
||||||
|
<script src="/reporter/static/js/script_reports.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
7
config/config.toml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
[web]
|
||||||
|
host = "0.0.0.0"
|
||||||
|
port = 4000
|
||||||
|
|
||||||
|
[database]
|
||||||
|
host = "127.0.0.1"
|
||||||
|
port = 3306
|
||||||
39
files.py
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
from pathlib import Path
|
||||||
|
import tomllib
|
||||||
|
import string
|
||||||
|
|
||||||
|
def merge(a: dict, b: dict, path=[]):
|
||||||
|
for key in b:
|
||||||
|
if key in a:
|
||||||
|
if isinstance(a[key], dict) and isinstance(b[key], dict):
|
||||||
|
merge(a[key], b[key], path + [str(key)])
|
||||||
|
elif a[key] != b[key]:
|
||||||
|
raise Exception('Conflict at ' + '.'.join(path + [str(key)]))
|
||||||
|
else:
|
||||||
|
a[key] = b[key]
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
def __init__(self, config_path='config', parent_config={}):
|
||||||
|
self.config = {}
|
||||||
|
|
||||||
|
for file_path in Path(config_path).glob('**/*.toml'):
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
merge(self.config, tomllib.load(f))
|
||||||
|
|
||||||
|
merge(self.config, parent_config)
|
||||||
|
|
||||||
|
def __getitem__(self, item):
|
||||||
|
return self.config[item]
|
||||||
|
|
||||||
|
class Templates:
|
||||||
|
def __init__(self, template_path='templates'):
|
||||||
|
self.templates = {}
|
||||||
|
|
||||||
|
path = Path(template_path)
|
||||||
|
for file_path in path.glob('**/*.html'):
|
||||||
|
with open(file_path, "r", encoding='utf-8') as f:
|
||||||
|
relative_path = file_path.relative_to(path).as_posix()
|
||||||
|
self.templates[relative_path] = string.Template(f.read())
|
||||||
|
|
||||||
|
def __getitem__(self, item):
|
||||||
|
return self.templates[item]
|
||||||
100
main.py
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
import asyncio
|
||||||
|
from aiohttp import web
|
||||||
|
import aiohttp_session
|
||||||
|
import aiohttp_security
|
||||||
|
|
||||||
|
import aiomysql
|
||||||
|
from pymysql.err import OperationalError
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import simplejson
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
import urllib.parse
|
||||||
|
import psutil
|
||||||
|
|
||||||
|
# Local
|
||||||
|
import files
|
||||||
|
import apps
|
||||||
|
import security
|
||||||
|
|
||||||
|
async def mysql_engine(app):
|
||||||
|
print("Connecting to database... ", end='')
|
||||||
|
try:
|
||||||
|
pool = await aiomysql.create_pool(autocommit=True, **app['config']['database'])
|
||||||
|
app['con'] = await pool.acquire()
|
||||||
|
app['cur'] = await app['con'].cursor() #aiomysql.DictCursor
|
||||||
|
except OperationalError as e:
|
||||||
|
print(e)
|
||||||
|
print("Done.")
|
||||||
|
|
||||||
|
yield # Keep the cleanup context open until shutdown
|
||||||
|
|
||||||
|
print("Disconnecting from database... ", end='')
|
||||||
|
pool.close()
|
||||||
|
await pool.wait_closed()
|
||||||
|
print("Done.")
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
# Start tracking CPU usage
|
||||||
|
_ = psutil.cpu_percent(percpu=True)
|
||||||
|
|
||||||
|
root = web.Application()
|
||||||
|
root['config'] = files.Config()
|
||||||
|
root['templates'] = files.Templates()
|
||||||
|
routes = web.RouteTableDef()
|
||||||
|
@routes.get('/')
|
||||||
|
async def home(request):
|
||||||
|
return web.Response(text=request.config_dict['templates']['home.html'].template, content_type="text/html")
|
||||||
|
|
||||||
|
aiohttp_session.setup(root, security.get_cookie_storage())
|
||||||
|
aiohttp_security.setup(root, security.LocalSessionIdentityPolicy(), security.SimplePasswordAuthPolicy())
|
||||||
|
root.add_routes(routes)
|
||||||
|
|
||||||
|
# Should be served by reverse proxy in production
|
||||||
|
root.router.add_static('/static/', path='./static', name='static')
|
||||||
|
|
||||||
|
account = web.Application()
|
||||||
|
account['prefix'] = '/log/'
|
||||||
|
account['templates'] = files.Templates('apps/account/templates')
|
||||||
|
apps.account.init_app(account)
|
||||||
|
root.router.add_static('/log/static/', path='./apps/account/static', name='acc_static')
|
||||||
|
root.add_subapp('/log', account)
|
||||||
|
|
||||||
|
calendar = web.Application()
|
||||||
|
calendar.middlewares.append(security.redirect_to_login)
|
||||||
|
calendar['prefix'] = '/calendar/'
|
||||||
|
calendar['templates'] = files.Templates('apps/calendar/templates')
|
||||||
|
calendar['config'] = files.Config('apps/calendar/config', root['config'].config)
|
||||||
|
calendar.cleanup_ctx.append(mysql_engine)
|
||||||
|
apps.calendar.init_app(calendar)
|
||||||
|
root.router.add_static('/calendar/static/', path='./apps/calendar/static', name='cal_static')
|
||||||
|
root.add_subapp('/calendar', calendar)
|
||||||
|
|
||||||
|
reporter = web.Application()
|
||||||
|
reporter.middlewares.append(security.redirect_to_login)
|
||||||
|
reporter['prefix'] = '/reporter/'
|
||||||
|
reporter['templates'] = files.Templates('apps/reporter/templates')
|
||||||
|
reporter['config'] = files.Config('apps/reporter/config', root['config'].config)
|
||||||
|
reporter.cleanup_ctx.append(mysql_engine)
|
||||||
|
apps.reporter.init_app(reporter)
|
||||||
|
root.router.add_static('/reporter/static/', path='./apps/reporter/static', name='rep_static')
|
||||||
|
root.add_subapp('/reporter', reporter)
|
||||||
|
|
||||||
|
waiter = asyncio.Event()
|
||||||
|
runner = web.AppRunner(root)
|
||||||
|
# Hang on to these references so we can initiate a graceful shutdown from inside a coroutine
|
||||||
|
root['waiter'] = waiter
|
||||||
|
root['runner'] = runner
|
||||||
|
|
||||||
|
await runner.setup()
|
||||||
|
web_config = root['config']['web']
|
||||||
|
site = web.TCPSite(runner, **web_config)
|
||||||
|
await site.start()
|
||||||
|
print(f"Listening on {web_config['host']}:{web_config['port']}.")
|
||||||
|
|
||||||
|
# Wait for the Event to be .set() by the graceful shutdown procedure
|
||||||
|
await waiter.wait()
|
||||||
|
print("Goodbye!")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
7
requirements.txt
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
python-dateutil
|
||||||
|
aiomysql
|
||||||
|
aiohttp
|
||||||
|
aiohttp_session[secure]
|
||||||
|
aiohttp_security[session]
|
||||||
|
simplejson
|
||||||
|
psutil
|
||||||
77
security.py
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
from aiohttp import web
|
||||||
|
import aiohttp_session
|
||||||
|
import aiohttp_security
|
||||||
|
|
||||||
|
import base64
|
||||||
|
#from cryptography import fernet
|
||||||
|
from aiohttp_session.cookie_storage import EncryptedCookieStorage
|
||||||
|
|
||||||
|
account_types = {
|
||||||
|
'LOCAL': 'User (on local network)',
|
||||||
|
'USER': 'User (entered password)',
|
||||||
|
'ADMIN': 'Administrator'
|
||||||
|
}
|
||||||
|
|
||||||
|
@web.middleware
|
||||||
|
async def redirect_to_login(request, handler):
|
||||||
|
try:
|
||||||
|
return await handler(request)
|
||||||
|
except web.HTTPException as ex:
|
||||||
|
if ex.status in (401, 403):
|
||||||
|
raise web.HTTPFound(f'/log/in?status={ex.status}&url={request.url}')
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
# except Exception:
|
||||||
|
# return await handle_500(request)
|
||||||
|
|
||||||
|
def try_password(password):
|
||||||
|
passwords = {
|
||||||
|
'userpass': 'USER',
|
||||||
|
'adminpass': 'ADMIN'
|
||||||
|
}
|
||||||
|
return passwords.get(password)
|
||||||
|
|
||||||
|
def get_cookie_storage():
|
||||||
|
# chosen by fair dice roll ( fernet.Fernet.generate_key() )
|
||||||
|
# guaranteed to be random.
|
||||||
|
fernet_key = b'_TxCY776Q1GtN6dFSvAuhqSW6O9gEI1MZL8hYoK92DA='
|
||||||
|
secret_key = base64.urlsafe_b64decode(fernet_key)
|
||||||
|
return EncryptedCookieStorage(secret_key, cookie_name="WEB_APP_DEMOS")
|
||||||
|
|
||||||
|
class LocalSessionIdentityPolicy(aiohttp_security.abc.AbstractIdentityPolicy):
|
||||||
|
def __init__(self, session_key = 'auth'):
|
||||||
|
self._session_key = session_key
|
||||||
|
|
||||||
|
async def identify(self, request):
|
||||||
|
session = await aiohttp_session.get_session(request)
|
||||||
|
key = session.get(self._session_key)
|
||||||
|
if key:
|
||||||
|
return key
|
||||||
|
|
||||||
|
real_ip = request.headers.get('X-Real-IP')
|
||||||
|
if real_ip is not None and real_ip != '10.69.0.1' and (real_ip == '127.0.0.1' or real_ip.startswith('10.')):
|
||||||
|
return 'LOCAL'
|
||||||
|
|
||||||
|
async def remember(self, request, response, identity, **kwargs):
|
||||||
|
session = await aiohttp_session.get_session(request)
|
||||||
|
session[self._session_key] = identity
|
||||||
|
|
||||||
|
async def forget(self, request, response):
|
||||||
|
session = await aiohttp_session.get_session(request)
|
||||||
|
session.pop(self._session_key, None)
|
||||||
|
|
||||||
|
class SimplePasswordAuthPolicy(aiohttp_security.abc.AbstractAuthorizationPolicy):
|
||||||
|
async def authorized_userid(self, identity):
|
||||||
|
if identity in account_types:
|
||||||
|
return identity
|
||||||
|
|
||||||
|
async def permits(self, identity, permission, context=None):
|
||||||
|
if identity == 'LOCAL':
|
||||||
|
return permission in ('user',)
|
||||||
|
elif identity == 'USER':
|
||||||
|
return permission in ('user',)
|
||||||
|
elif identity == 'ADMIN':
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
5
static/bootstrap/bootstrap-icons.min.css
vendored
Normal file
7
static/bootstrap/bootstrap.bundle.min.js
vendored
Normal file
1
static/bootstrap/bootstrap.bundle.min.js.map
Normal file
6
static/bootstrap/bootstrap.min.css
vendored
Normal file
1
static/bootstrap/bootstrap.min.css.map
Normal file
BIN
static/bootstrap/fonts/bootstrap-icons.woff
Normal file
BIN
static/bootstrap/fonts/bootstrap-icons.woff2
Normal file
BIN
static/calendar.png
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
static/reporter.png
Normal file
|
After Width: | Height: | Size: 165 KiB |
BIN
static/selfie.jpg
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
static/vassili-resume.pdf
Normal file
161
templates/home.html
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Vassili Minaev</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="/static/bootstrap/bootstrap.min.css">
|
||||||
|
<style type="text/css">
|
||||||
|
#card {
|
||||||
|
width: 600px;
|
||||||
|
height: 300px;
|
||||||
|
box-shadow:
|
||||||
|
rgba(0, 0, 0, 0.25) 0px 54px 55px,
|
||||||
|
rgba(0, 0, 0, 0.12) 0px -12px 30px,
|
||||||
|
rgba(0, 0, 0, 0.12) 0px 4px 6px,
|
||||||
|
rgba(0, 0, 0, 0.17) 0px 12px 13px,
|
||||||
|
rgba(0, 0, 0, 0.09) 0px -3px 5px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<main class="container-fluid py-4">
|
||||||
|
<section class="card mx-auto mb-5" id="card">
|
||||||
|
<div class="row g-0 h-100">
|
||||||
|
<div class="col-4">
|
||||||
|
<img src="/static/selfie.jpg" class="img-fluid rounded-start" alt="Vassili's profile photo">
|
||||||
|
</div>
|
||||||
|
<div class="col-8 h-100 d-flex flex-column justify-content-between">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-center">Vassili Minaev</h3>
|
||||||
|
<p class="card-text">I make software, hardware, and in between<br>
|
||||||
|
Based in Edmonton, AB<br>
|
||||||
|
Now looking for a technical role at your company
|
||||||
|
</p>
|
||||||
|
<div class="text-center">
|
||||||
|
<!--<a class="btn btn-success" href="#hire" role="button">Hire me</a>-->
|
||||||
|
<a class="btn btn-success btn-lg" role="button" href="/static/vassili-resume.pdf" download="vassili-resume.pdf">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-file-earmark-pdf" viewBox="0 0 16 16">
|
||||||
|
<path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2M9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5z"/>
|
||||||
|
<path d="M4.603 14.087a.8.8 0 0 1-.438-.42c-.195-.388-.13-.776.08-1.102.198-.307.526-.568.897-.787a7.7 7.7 0 0 1 1.482-.645 20 20 0 0 0 1.062-2.227 7.3 7.3 0 0 1-.43-1.295c-.086-.4-.119-.796-.046-1.136.075-.354.274-.672.65-.823.192-.077.4-.12.602-.077a.7.7 0 0 1 .477.365c.088.164.12.356.127.538.007.188-.012.396-.047.614-.084.51-.27 1.134-.52 1.794a11 11 0 0 0 .98 1.686 5.8 5.8 0 0 1 1.334.05c.364.066.734.195.96.465.12.144.193.32.2.518.007.192-.047.382-.138.563a1.04 1.04 0 0 1-.354.416.86.86 0 0 1-.51.138c-.331-.014-.654-.196-.933-.417a5.7 5.7 0 0 1-.911-.95 11.7 11.7 0 0 0-1.997.406 11.3 11.3 0 0 1-1.02 1.51c-.292.35-.609.656-.927.787a.8.8 0 0 1-.58.029m1.379-1.901q-.25.115-.459.238c-.328.194-.541.383-.647.547-.094.145-.096.25-.04.361q.016.032.026.044l.035-.012c.137-.056.355-.235.635-.572a8 8 0 0 0 .45-.606m1.64-1.33a13 13 0 0 1 1.01-.193 12 12 0 0 1-.51-.858 21 21 0 0 1-.5 1.05zm2.446.45q.226.245.435.41c.24.19.407.253.498.256a.1.1 0 0 0 .07-.015.3.3 0 0 0 .094-.125.44.44 0 0 0 .059-.2.1.1 0 0 0-.026-.063c-.052-.062-.2-.152-.518-.209a4 4 0 0 0-.612-.053zM8.078 7.8a7 7 0 0 0 .2-.828q.046-.282.038-.465a.6.6 0 0 0-.032-.198.5.5 0 0 0-.145.04c-.087.035-.158.106-.196.283-.04.192-.03.469.046.822q.036.167.09.346z"/>
|
||||||
|
</svg>
|
||||||
|
Download résumé
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<div class="d-flex justify-content-around">
|
||||||
|
<div>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-envelope-at" viewBox="0 0 16 16">
|
||||||
|
<path d="M2 2a2 2 0 0 0-2 2v8.01A2 2 0 0 0 2 14h5.5a.5.5 0 0 0 0-1H2a1 1 0 0 1-.966-.741l5.64-3.471L8 9.583l7-4.2V8.5a.5.5 0 0 0 1 0V4a2 2 0 0 0-2-2zm3.708 6.208L1 11.105V5.383zM1 4.217V4a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v.217l-7 4.2z"/>
|
||||||
|
<path d="M14.247 14.269c1.01 0 1.587-.857 1.587-2.025v-.21C15.834 10.43 14.64 9 12.52 9h-.035C10.42 9 9 10.36 9 12.432v.214C9 14.82 10.438 16 12.358 16h.044c.594 0 1.018-.074 1.237-.175v-.73c-.245.11-.673.18-1.18.18h-.044c-1.334 0-2.571-.788-2.571-2.655v-.157c0-1.657 1.058-2.724 2.64-2.724h.04c1.535 0 2.484 1.05 2.484 2.326v.118c0 .975-.324 1.39-.639 1.39-.232 0-.41-.148-.41-.42v-2.19h-.906v.569h-.03c-.084-.298-.368-.63-.954-.63-.778 0-1.259.555-1.259 1.4v.528c0 .892.49 1.434 1.26 1.434.471 0 .896-.227 1.014-.643h.043c.118.42.617.648 1.12.648m-2.453-1.588v-.227c0-.546.227-.791.573-.791.297 0 .572.192.572.708v.367c0 .573-.253.744-.564.744-.354 0-.581-.215-.581-.8Z"/>
|
||||||
|
</svg>
|
||||||
|
<a href="/" id="email"></a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-globe" viewBox="0 0 16 16">
|
||||||
|
<path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8m7.5-6.923c-.67.204-1.335.82-1.887 1.855A8 8 0 0 0 5.145 4H7.5zM4.09 4a9.3 9.3 0 0 1 .64-1.539 7 7 0 0 1 .597-.933A7.03 7.03 0 0 0 2.255 4zm-.582 3.5c.03-.877.138-1.718.312-2.5H1.674a7 7 0 0 0-.656 2.5zM4.847 5a12.5 12.5 0 0 0-.338 2.5H7.5V5zM8.5 5v2.5h2.99a12.5 12.5 0 0 0-.337-2.5zM4.51 8.5a12.5 12.5 0 0 0 .337 2.5H7.5V8.5zm3.99 0V11h2.653c.187-.765.306-1.608.338-2.5zM5.145 12q.208.58.468 1.068c.552 1.035 1.218 1.65 1.887 1.855V12zm.182 2.472a7 7 0 0 1-.597-.933A9.3 9.3 0 0 1 4.09 12H2.255a7 7 0 0 0 3.072 2.472M3.82 11a13.7 13.7 0 0 1-.312-2.5h-2.49c.062.89.291 1.733.656 2.5zm6.853 3.472A7 7 0 0 0 13.745 12H11.91a9.3 9.3 0 0 1-.64 1.539 7 7 0 0 1-.597.933M8.5 12v2.923c.67-.204 1.335-.82 1.887-1.855q.26-.487.468-1.068zm3.68-1h2.146c.365-.767.594-1.61.656-2.5h-2.49a13.7 13.7 0 0 1-.312 2.5m2.802-3.5a7 7 0 0 0-.656-2.5H12.18c.174.782.282 1.623.312 2.5zM11.27 2.461c.247.464.462.98.64 1.539h1.835a7 7 0 0 0-3.072-2.472c.218.284.418.598.597.933M10.855 4a8 8 0 0 0-.468-1.068C9.835 1.897 9.17 1.282 8.5 1.077V4z"/>
|
||||||
|
</svg>
|
||||||
|
<a href="https://vassi.li" data-bs-toggle="tooltip" data-bs-title="You're already here">vassi.li</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-phone" viewBox="0 0 16 16">
|
||||||
|
<path d="M11 1a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1zM5 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2z"/>
|
||||||
|
<path d="M8 14a1 1 0 1 0 0-2 1 1 0 0 0 0 2"/>
|
||||||
|
</svg>
|
||||||
|
<a href="tel:+17807076594">780-707-6594</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section id="projects" class="mb-5">
|
||||||
|
<div class="album py-5 bg-body-tertiary">
|
||||||
|
<div class="container">
|
||||||
|
|
||||||
|
<h2 class="text-center">Recent projects and demos</h2>
|
||||||
|
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 g-3">
|
||||||
|
<div class="col">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<a class="text-decoration-none text-reset" href="/calendar">
|
||||||
|
<img class="img-fluid card-img-top" src="/static/calendar.png" height="225">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Event Calendar</h5>
|
||||||
|
<p class="card-text">This simple calendar can be used to borrow a resource, check out for lunch, or remember a birthday. Click on an event to edit or delete it.</p>
|
||||||
|
<!--
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div class="btn-group">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary">View</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary">Edit</button>
|
||||||
|
</div>
|
||||||
|
<small class="text-body-secondary">9 mins</small>
|
||||||
|
</div>
|
||||||
|
-->
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<a class="text-decoration-none text-reset" href="/reporter">
|
||||||
|
<img class="img-fluid card-img-top" src="/static/reporter.png" height="225">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Data Reporting Tool</h5>
|
||||||
|
<p class="card-text">Run reports from a database (like your ERP system), summarize the data, then export to Excel.</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!--
|
||||||
|
<div class="col">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<svg class="bd-placeholder-img card-img-top" width="100%" height="225" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Placeholder: Thumbnail" preserveAspectRatio="xMidYMid slice" focusable="false"><title>Placeholder</title><rect width="100%" height="100%" fill="#55595c"></rect><text x="50%" y="50%" fill="#eceeef" dy=".3em">Thumbnail</text></svg>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="card-text">This is a wider card with supporting text below as a natural lead-in to additional content. This content is a little bit longer.</p>
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div class="btn-group">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary">View</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary">Edit</button>
|
||||||
|
</div>
|
||||||
|
<small class="text-body-secondary">9 mins</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
-->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section id="hire">
|
||||||
|
<div class="p-5 mb-4 bg-body-tertiary rounded-3">
|
||||||
|
<div class="container-fluid py-5">
|
||||||
|
<h1 class="display-5 fw-bold">Hire me</h1>
|
||||||
|
<!--<p class="col-md-8 fs-4"></p>-->
|
||||||
|
<a class="btn btn-success btn-lg" role="button" href="/static/vassili-resume.pdf" download="vassili-resume.pdf">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-file-earmark-pdf" viewBox="0 0 16 16">
|
||||||
|
<path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2M9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5z"/>
|
||||||
|
<path d="M4.603 14.087a.8.8 0 0 1-.438-.42c-.195-.388-.13-.776.08-1.102.198-.307.526-.568.897-.787a7.7 7.7 0 0 1 1.482-.645 20 20 0 0 0 1.062-2.227 7.3 7.3 0 0 1-.43-1.295c-.086-.4-.119-.796-.046-1.136.075-.354.274-.672.65-.823.192-.077.4-.12.602-.077a.7.7 0 0 1 .477.365c.088.164.12.356.127.538.007.188-.012.396-.047.614-.084.51-.27 1.134-.52 1.794a11 11 0 0 0 .98 1.686 5.8 5.8 0 0 1 1.334.05c.364.066.734.195.96.465.12.144.193.32.2.518.007.192-.047.382-.138.563a1.04 1.04 0 0 1-.354.416.86.86 0 0 1-.51.138c-.331-.014-.654-.196-.933-.417a5.7 5.7 0 0 1-.911-.95 11.7 11.7 0 0 0-1.997.406 11.3 11.3 0 0 1-1.02 1.51c-.292.35-.609.656-.927.787a.8.8 0 0 1-.58.029m1.379-1.901q-.25.115-.459.238c-.328.194-.541.383-.647.547-.094.145-.096.25-.04.361q.016.032.026.044l.035-.012c.137-.056.355-.235.635-.572a8 8 0 0 0 .45-.606m1.64-1.33a13 13 0 0 1 1.01-.193 12 12 0 0 1-.51-.858 21 21 0 0 1-.5 1.05zm2.446.45q.226.245.435.41c.24.19.407.253.498.256a.1.1 0 0 0 .07-.015.3.3 0 0 0 .094-.125.44.44 0 0 0 .059-.2.1.1 0 0 0-.026-.063c-.052-.062-.2-.152-.518-.209a4 4 0 0 0-.612-.053zM8.078 7.8a7 7 0 0 0 .2-.828q.046-.282.038-.465a.6.6 0 0 0-.032-.198.5.5 0 0 0-.145.04c-.087.035-.158.106-.196.283-.04.192-.03.469.046.822q.036.167.09.346z"/>
|
||||||
|
</svg>
|
||||||
|
Download résumé
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="static/bs/bootstrap.bundle.min.js"></script>
|
||||||
|
<script type="text/javascript">
|
||||||
|
window.onload = () => {
|
||||||
|
const email = document.getElementById('email');
|
||||||
|
email.href = "mailto:" + "v" + "@" + "ssi" + "." + "li";
|
||||||
|
email.textContent = "v" + "@" + "ssi" + "." + "li";
|
||||||
|
|
||||||
|
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]')
|
||||||
|
const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||