Initial commit

This commit is contained in:
Vassili Minaev 2025-03-17 16:11:01 -06:00
commit 708ee610f1
80 changed files with 3858 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
**/__pycache__/
config/secret.toml
apps/*/config/secret.toml

3
README.md Normal file
View 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
View 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
View file

48
apps/account/app.py Normal file
View 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)

View file

@ -0,0 +1,6 @@
html, body {
height: 100%;
}
.form-signin {
max-width: 500px;
}

View 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>

View file

118
apps/calendar/app.py Normal file
View 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)

View file

@ -0,0 +1,2 @@
[app]
name = "Event Calendar"

View 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

View 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;
}

View 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);
}*/

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

View 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();

View 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());
}
};

View 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)}}}));

View 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)}}}));

View 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

File diff suppressed because one or more lines are too long

View 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)}}}));

View 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 || '&nbsp;';
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>&nbsp;</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();
});
}

View 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);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

View 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>

View file

330
apps/reporter/app.py Normal file
View 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)

View 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"]

View file

@ -0,0 +1,4 @@
[database]
db = "reporter"
user = "user"
password = "pass"

282
apps/reporter/queries.py Normal file
View 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"]}'}

View 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"

View 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"

View 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"

View 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"

View 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"

View 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 */

View 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;
}

View 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;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
apps/reporter/static/js/luxon.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View 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);
});
});
});
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
from .admin import app as admin

View 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)

View 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'>&gt; ${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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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
View 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
View 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
View file

@ -0,0 +1,7 @@
python-dateutil
aiomysql
aiohttp
aiohttp_session[secure]
aiohttp_security[session]
simplejson
psutil

77
security.py Normal file
View 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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

6
static/bootstrap/bootstrap.min.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

BIN
static/calendar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

BIN
static/reporter.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

BIN
static/selfie.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

BIN
static/vassili-resume.pdf Normal file

Binary file not shown.

161
templates/home.html Normal file
View 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>