├── .gitignore
├── LICENSE.md
├── README.md
├── bookapp
├── client
├── dev-server.js
├── package-lock.json
├── package.json
├── requirements.txt
└── src
│ ├── app.py
│ ├── common
│ ├── __init__.py
│ ├── jsutils.py
│ ├── pymui.py
│ ├── pyreact.py
│ └── urlutils.py
│ ├── index.html
│ ├── main
│ ├── __init__.py
│ ├── aboutModal.py
│ ├── appData.py
│ ├── appTheme.py
│ └── loginModal.py
│ ├── static
│ ├── app_logo.jpg
│ └── favicon.ico
│ ├── version.py
│ └── views
│ ├── __init__.py
│ ├── bookEdit
│ ├── __init__.py
│ ├── bookEditForm.py
│ ├── bookEditLookups.py
│ └── bookEditView.py
│ ├── bookList
│ ├── __init__.py
│ ├── bookListFilter.py
│ ├── bookListTable.py
│ └── bookListView.py
│ ├── landingPage
│ ├── __init__.py
│ ├── landingPageMenu.py
│ └── landingPageView.py
│ └── lookupTable
│ ├── __init__.py
│ ├── lookupList.py
│ └── lookupView.py
└── server
├── admin_routes.py
├── appserver.py
├── db_routes.py
├── dbutils.py
├── requirements.txt
└── testdata.py
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea/
2 |
3 | *.pyc
4 | *.bak
5 | *.log
6 |
7 | database/
8 | venv/
9 | __target__/
10 | dist/
11 | .cache/
12 | node_modules/
13 | __pycache__/
14 |
15 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | Copyright (c) 2020 John Sheehan
2 |
3 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
4 |
5 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
6 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## React to Python
2 | ### Part III Project Code
3 |
4 | This repository contains source code files for React to Python Part 3
5 |
6 | The [main branch](https://github.com/rtp-book/project) of the project code has the finished project. Other branches have versions of the code that reflect the progress in each chapter of the book as follows:
7 |
8 | - [Step 1](https://github.com/rtp-book/project/tree/step01) - Environment Setup
9 | - [Step 2](https://github.com/rtp-book/project/tree/step02) - Landing Page
10 | - [Step 3](https://github.com/rtp-book/project/tree/step03) - Modal View
11 | - [Step 4](https://github.com/rtp-book/project/tree/step04) - REST Service
12 | - [Step 5](https://github.com/rtp-book/project/tree/step05) - Books
13 | - [Step 6](https://github.com/rtp-book/project/tree/step06) - Menus
14 | - [Step 7](https://github.com/rtp-book/project/tree/step07) - User Login
15 | - [Step 8](https://github.com/rtp-book/project/tree/step08) - Lookups
16 | - [Step 9](https://github.com/rtp-book/project/tree/step09) - User Context
17 | - [Step 10](https://github.com/rtp-book/project/tree/step10) - Editing Lookups
18 | - [Step 11](https://github.com/rtp-book/project/tree/step11) - Filtering Data
19 | - [Step 12](https://github.com/rtp-book/project/tree/step12) - Editing Books
20 | - [Step 13](https://github.com/rtp-book/project/tree/step13) - SPA Redirect
21 | - [Step 14](https://github.com/rtp-book/project/tree/step14) - Deploying the Application
22 |
23 | **requirements.txt** and **package.json** files are included as part of the source code for installing Python and JavaScript dependecies in your development environment.
24 |
25 | Source code for Part 1 and Part 2 of the book is in the [Chapter Code Repository](https://github.com/rtp-book/code)
26 |
27 | For information on the *React to Python* book itself, visit [https://pyreact.com](https://pyreact.com)
28 |
--------------------------------------------------------------------------------
/bookapp:
--------------------------------------------------------------------------------
1 | server {
2 | listen 80;
3 | root /home/data/bookapp/client/dist/prod/;
4 | index index.html;
5 |
6 | location /api {
7 | proxy_pass http://localhost:8000;
8 | include proxy_params;
9 | }
10 |
11 | location / {
12 | try_files $uri /index.html;
13 | }
14 |
15 | error_log /var/log/nginx/api-error.log;
16 | access_log /var/log/nginx/api-access.log;
17 | }
18 |
19 |
--------------------------------------------------------------------------------
/client/dev-server.js:
--------------------------------------------------------------------------------
1 | const Bundler = require('parcel-bundler');
2 | const express = require('express');
3 | const { createProxyMiddleware } = require('http-proxy-middleware');
4 |
5 |
6 | const app = express();
7 |
8 | const apiProxy = createProxyMiddleware('/api', {
9 | target: 'http://localhost:8000'
10 | });
11 | app.use(apiProxy);
12 |
13 | // parcel options
14 | const options = {minify:false, cache: false, outDir: 'dist/dev', logLevel: 4};
15 |
16 | const bundler = new Bundler('src/index.html', options);
17 | app.use(bundler.middleware());
18 |
19 | bundler.on('buildEnd', () => {
20 | console.log('Parcel proxy server has started at: http://localhost:8080');
21 | });
22 |
23 | app.listen(8080);
24 |
25 |
26 |
--------------------------------------------------------------------------------
/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "bookapp",
3 | "version": "1.1.0",
4 | "description": "React to Python",
5 | "main": "index.js",
6 | "scripts": {
7 | "start": "NODE_ENV=development parcel --log-level 4 src/index.html --no-cache --out-dir dist/dev",
8 | "build": "NODE_ENV=production parcel --log-level 4 build src/index.html --no-source-maps --out-dir dist/prod --no-hmr --public-url ./",
9 | "dev": "node dev-server.js",
10 | "version": "echo \"version = '$npm_package_version'\" > ./src/version.py;git add ./src/version.py",
11 | "test": "echo \"Error: no test specified\" && exit 1"
12 | },
13 | "author": "John Sheehan",
14 | "license": "ISC",
15 | "devDependencies": {
16 | "express": "^4.17.1",
17 | "http-proxy-middleware": "^1.0.6",
18 | "parcel-bundler": "^1.12.4",
19 | "parcel-plugin-bundle-visualiser": "^1.2.0",
20 | "parcel-plugin-transcrypt": "^1.0.20"
21 | },
22 | "dependencies": {
23 | "@babel/polyfill": "^7.12.1",
24 | "@material-ui/core": "4.11.0",
25 | "@material-ui/icons": "^4.9.1",
26 | "deepcopy": "^2.1.0",
27 | "notistack": "^1.0.1",
28 | "react": "^16.14.0",
29 | "react-dom": "^16.14.0",
30 | "react-ga": "^3.2.1",
31 | "react-modal": "^3.11.2"
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/client/requirements.txt:
--------------------------------------------------------------------------------
1 | Transcrypt==3.7.16
2 |
3 |
--------------------------------------------------------------------------------
/client/src/app.py:
--------------------------------------------------------------------------------
1 | from common.pyreact import render, createElement as el, ReactGA
2 | from common.pyreact import useState, useEffect
3 | from common.pymui import ThemeProvider, SnackbarProvider
4 | from common.jsutils import setTitle, console
5 | from common.urlutils import fetch, spaRedirect
6 | from main import UserCtx
7 | from main.appTheme import theme
8 | from main.appData import gaid
9 | from views.bookList.bookListView import BookList
10 | from views.landingPage.landingPageView import LandingPage
11 |
12 |
13 | ReactGA.initialize(gaid, {'titleCase': False, 'debug': False,
14 | 'gaOptions': {'siteSpeedSampleRate': 100}}
15 | )
16 |
17 |
18 | def App(props):
19 | title = props['title']
20 | pathname = props['pathname']
21 |
22 | user, setUser = useState("")
23 |
24 | setTitle(title)
25 |
26 | router = {
27 | '/': LandingPage,
28 | '/books': BookList,
29 | }
30 |
31 | route_is_valid = pathname in router
32 | isLoggedIn = len(user) > 0
33 |
34 | def login(username):
35 | setUser(username)
36 |
37 | def logout():
38 | setUser("")
39 | fetch('/api/logout', lambda: spaRedirect('/'))
40 |
41 | def validateSession():
42 | def validated():
43 | def _setuser(data):
44 | login(data['user'])
45 |
46 | if not isLoggedIn:
47 | fetch('/api/whoami', _setuser,
48 | onError=console.error,
49 | redirect=False
50 | )
51 |
52 | def notValidated(error):
53 | if len(user) > 0:
54 | setUser("")
55 |
56 | if route_is_valid:
57 | fetch('/api/ping', validated, onError=notValidated, redirect=False)
58 |
59 | user_ctx = {'user': user,
60 | 'login': login,
61 | 'logout': logout,
62 | 'isLoggedIn': isLoggedIn
63 | }
64 |
65 | useEffect(validateSession, [])
66 | useEffect(lambda: ReactGA.pageview(pathname), [pathname])
67 |
68 | if route_is_valid:
69 | return el(ThemeProvider, {'theme': theme},
70 | el(SnackbarProvider, {'maxSnack': 3},
71 | el(UserCtx.Provider, {'value': user_ctx},
72 | el(router[pathname], props)
73 | )
74 | )
75 | )
76 | else:
77 | console.error(f"ERROR - Bad pathname for route: {props['pathname']}")
78 | return el('div', None,
79 | el('h1', None, "Page Not Found"),
80 | el('p', None, f"Bad pathname: {props['pathname']}"),
81 | el('div', None, el('a', {'href': "/"}, "Back to Home"))
82 | )
83 |
84 |
85 | render(App, {'title': "Books"}, 'root')
86 |
87 |
--------------------------------------------------------------------------------
/client/src/common/__init__.py:
--------------------------------------------------------------------------------
1 | # __pragma__ ('skip')
2 |
3 | """
4 | These JavaScript builtin function and object stubs are just to
5 | quiet the Python linter and are ignored by transcrypt as long
6 | as they are imported inside of pragma skip/noskip lines.
7 | """
8 |
9 | def require(lib):
10 | return lib
11 |
12 | def __new__(obj):
13 | return obj
14 |
15 | class JSON:
16 | stringify = None
17 |
18 | class document:
19 | title = None
20 | getElementById = None
21 | addEventListener = None
22 |
23 | class window:
24 | class console:
25 | log = None
26 | error = None
27 | warn = None
28 |
29 | alert = None
30 | confirm = None
31 | fetch = None
32 | history = None
33 | location = None
34 | addEventListener = None
35 | dispatchEvent = None
36 | PopStateEvent = None
37 | URLSearchParams = None
38 | encodeURIComponent = None
39 |
40 | # __pragma__ ('noskip')
41 |
42 |
--------------------------------------------------------------------------------
/client/src/common/jsutils.py:
--------------------------------------------------------------------------------
1 | # __pragma__ ('skip')
2 | from common import require, document, window
3 | # __pragma__ ('noskip')
4 |
5 |
6 | console = window.console
7 | alert = window.alert
8 | confirm = window.confirm
9 |
10 | deepcopy = require('deepcopy')
11 |
12 |
13 | def setTitle(title):
14 | document.title = title
15 |
16 |
--------------------------------------------------------------------------------
/client/src/common/pymui.py:
--------------------------------------------------------------------------------
1 | # __pragma__ ('skip')
2 | from common import require
3 | # __pragma__ ('noskip')
4 |
5 |
6 | # Icons
7 | MenuIcon = require('@material-ui/icons/Menu')['default']
8 | CloseIcon = require('@material-ui/icons/Close')['default']
9 | AddIcon = require('@material-ui/icons/AddCircle')['default']
10 |
11 | # Basic components
12 | Button = require('@material-ui/core/Button')['default']
13 | ButtonGroup = require('@material-ui/core/ButtonGroup')['default']
14 | IconButton = require('@material-ui/core/IconButton')['default']
15 | InputLabel = require('@material-ui/core/InputLabel')['default']
16 | OutlinedInput = require('@material-ui/core/OutlinedInput')['default']
17 | TextField = require('@material-ui/core/TextField')['default']
18 | Select = require('@material-ui/core/Select')['default']
19 | Box = require('@material-ui/core/Box')['default']
20 | Toolbar = require('@material-ui/core/Toolbar')['default']
21 | AppBar = require('@material-ui/core/AppBar')['default']
22 | Typography = require('@material-ui/core/Typography')['default']
23 | Divider = require('@material-ui/core/Divider')['default']
24 | Container = require('@material-ui/core/Container')['default']
25 | Input = require('@material-ui/core/Input')['default']
26 | Tooltip = require('@material-ui/core/Tooltip')['default']
27 | Menu = require('@material-ui/core/Menu')['default']
28 | MenuItem = require('@material-ui/core/MenuItem')['default']
29 | Paper = require('@material-ui/core/Paper')['default']
30 | CircularProgress = require('@material-ui/core/CircularProgress')['default']
31 | Link = require('@material-ui/core/Link')['default']
32 | Radio = require('@material-ui/core/Radio')['default']
33 | RadioGroup = require('@material-ui/core/RadioGroup')['default']
34 | FormControl = require('@material-ui/core/FormControl')['default']
35 | FormLabel = require('@material-ui/core/FormLabel')['default']
36 | FormControlLabel = require('@material-ui/core/FormControlLabel')['default']
37 |
38 | # Tables
39 | TableContainer = require('@material-ui/core/TableContainer')['default']
40 | Table = require('@material-ui/core/Table')['default']
41 | TableHead = require('@material-ui/core/TableHead')['default']
42 | TableBody = require('@material-ui/core/TableBody')['default']
43 | TableFooter = require('@material-ui/core/TableFooter')['default']
44 | TableRow = require('@material-ui/core/TableRow')['default']
45 | TableCell = require('@material-ui/core/TableCell')['default']
46 |
47 | # Theming
48 | ThemeProvider = require('@material-ui/styles/ThemeProvider')['default']
49 | createMuiTheme = require('@material-ui/core/styles/createMuiTheme')['default']
50 | useTheme = require('@material-ui/styles/useTheme')['default']
51 | styled = require('@material-ui/styles/styled')['default']
52 | makeStyles = require('@material-ui/styles/makeStyles')['default']
53 | colors = require('@material-ui/core/colors')
54 |
55 |
56 |
57 | # notistack
58 | notistack = require('notistack')
59 |
60 | SnackbarProvider = notistack.SnackbarProvider
61 | useSnackbar = notistack.useSnackbar
62 |
63 |
--------------------------------------------------------------------------------
/client/src/common/pyreact.py:
--------------------------------------------------------------------------------
1 | # __pragma__ ('skip')
2 | from common import require, document, window, __new__
3 | # __pragma__ ('noskip')
4 |
5 |
6 | # Load React and ReactDOM JavaScript libraries into local namespace
7 | React = require('react')
8 | ReactDOM = require('react-dom')
9 | ReactGA = require('react-ga')
10 |
11 | Modal = require('react-modal')
12 |
13 | # Map React javaScript objects to Python identifiers
14 | createElement = React.createElement
15 | useState = React.useState
16 | useEffect = React.useEffect
17 | createContext = React.createContext
18 | useContext = React.useContext
19 |
20 | Fragment = React.Fragment
21 |
22 |
23 | def render(root_component, props, container):
24 | def main():
25 | querystring = window.location.search
26 | params = __new__(window.URLSearchParams(querystring)).entries()
27 | new_props = {'pathname': window.location.pathname,
28 | 'params': {p[0]: p[1] for p in params if p}}
29 | new_props.update(props)
30 | ReactDOM.render(
31 | React.createElement(root_component, new_props),
32 | document.getElementById(container)
33 | )
34 |
35 | document.addEventListener('DOMContentLoaded', main)
36 | window.addEventListener('popstate', main)
37 |
38 |
--------------------------------------------------------------------------------
/client/src/common/urlutils.py:
--------------------------------------------------------------------------------
1 | import time
2 | from common.pyreact import ReactGA, createElement as el
3 | from common.pymui import Link as MuiLink
4 | from common.jsutils import console
5 |
6 | # __pragma__ ('skip')
7 | from common import require, window, JSON, __new__
8 | # __pragma__ ('noskip')
9 |
10 |
11 | polyfill = require("@babel/polyfill") # required by async/await
12 |
13 |
14 | # __pragma__ ('kwargs')
15 | async def fetch(url, callback=None, **kwargs):
16 | ReactGA.event({'category': 'api', 'action': 'request', 'label': url})
17 | t_start = time.time()
18 | on_error = kwargs.pop('onError', None)
19 | redirect = kwargs.pop('redirect', True)
20 | method = kwargs.pop('method', 'GET')
21 | try:
22 | if method == 'POST' or method == 'DELETE':
23 | data = kwargs.pop('data', None)
24 | # headers needs to be a plain JS object
25 | headers = {'Content-Type': 'application/json;'} # __:jsiter
26 | response = await window.fetch(url, {'method': method,
27 | 'headers': headers,
28 | 'body': JSON.stringify(data)
29 | }
30 | )
31 | else:
32 | kw_params = kwargs.pop('params', {})
33 | params = buildParams(kw_params)
34 | response = await window.fetch(f"{url}{params}")
35 |
36 | if response.status == 401:
37 | console.error("401 - Session Expired")
38 | if redirect:
39 | redirToLoginPage()
40 | raise Exception("Unauthorized")
41 | elif response.status != 200:
42 | console.error('Fetch error - Status Code: ' + response.status)
43 | if on_error:
44 | on_error()
45 | else:
46 | json_data = await response.json()
47 | t_elapsed = time.time() - t_start
48 | ReactGA.timing({'category': 'API',
49 | 'variable': 'fetch',
50 | 'value': int(t_elapsed * 1000),
51 | 'label': url}
52 | )
53 |
54 | error = dict(json_data).get('error', None)
55 | if error:
56 | raise Exception(error)
57 | else:
58 | result = dict(json_data).get('success', None)
59 | if callback:
60 | callback(result)
61 | except object as e:
62 | console.error(str(e))
63 | if on_error:
64 | on_error()
65 |
66 | # __pragma__ ('nokwargs')
67 |
68 |
69 | def buildParams(param_dict: dict):
70 | param_list = [f"&{key}={window.encodeURIComponent(val)}"
71 | for key, val in param_dict.items() if val]
72 | params = ''.join(param_list)
73 | return f"?{params[1:]}" if len(params) > 0 else ''
74 |
75 |
76 | def spaRedirect(url):
77 | window.history.pushState(None, '', url)
78 | window.dispatchEvent(__new__(window.PopStateEvent('popstate')))
79 |
80 |
81 | def redirToLoginPage():
82 | # Check if redir is already in params
83 | params = __new__(window.URLSearchParams(window.location.search)).entries()
84 | param_dict = {p[0]: p[1] for p in params if p}
85 | redir = param_dict.get('redir', None)
86 |
87 | if redir:
88 | hrefNew = f"/?login=show&redir={window.encodeURIComponent(redir)}"
89 | else:
90 | hrefCurrent = window.location.href
91 | if hrefCurrent:
92 | encoded_href = window.encodeURIComponent(hrefCurrent)
93 | hrefNew = f"/?login=show&redir={encoded_href}"
94 | else:
95 | hrefNew = '/?login=show'
96 |
97 | window.location.href = hrefNew
98 |
99 |
100 | def Link(props):
101 | """Internal SPA link with browser history"""
102 |
103 | def onClick(event):
104 | event.preventDefault()
105 | spaRedirect(props['to'])
106 |
107 | def onClickAlt(event):
108 | event.preventDefault()
109 | props['onClick']()
110 |
111 | if props['onClick']:
112 | return el(MuiLink, {'href': props['to'],
113 | 'onClick': onClickAlt}, props['children'])
114 | else:
115 | return el(MuiLink, {'href': props['to'],
116 | 'onClick': onClick}, props['children'])
117 |
118 |
--------------------------------------------------------------------------------
/client/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
8 |
11 |
15 |
18 | Python React App
19 |
20 |
21 |
22 | Loading...
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/client/src/main/__init__.py:
--------------------------------------------------------------------------------
1 | from common.pyreact import createContext
2 |
3 | UserCtx = createContext()
4 |
5 |
--------------------------------------------------------------------------------
/client/src/main/aboutModal.py:
--------------------------------------------------------------------------------
1 | from common.pyreact import createElement as el, Modal
2 | from common.pymui import Paper, Typography, IconButton, CloseIcon, Divider
3 | from main.appData import applogo, appname
4 | from main.appTheme import Flexbox, FlexboxCenter, modalStyles
5 | from version import version
6 |
7 |
8 | def About(props):
9 | modalState = props['modalState']
10 | onClose = props['onClose']
11 |
12 | return el(Modal, {'isOpen': modalState,
13 | 'onRequestClose': onClose,
14 | 'style': modalStyles,
15 | 'ariaHideApp': False,
16 | },
17 | el(Flexbox, {'justifyContent': 'space-between',
18 | 'alignItems': 'center'
19 | },
20 | el(Typography, {'variant': 'h6', 'color': 'primary'}, "About"),
21 | el(IconButton, {'edge': 'end',
22 | 'color': 'primary',
23 | 'onClick': onClose
24 | }, el(CloseIcon, None)
25 | ),
26 | ),
27 | el(Paper, {'style': {'padding': '1rem'}},
28 | el(FlexboxCenter, {'maxWidth': '400px'},
29 | el('img', {'src': applogo, 'width': '80%'})
30 | ),
31 | ),
32 | el(Paper, {'style': {'padding': '0.5rem', 'marginTop': '1rem'}},
33 | el(FlexboxCenter, None,
34 | el(Typography, {'variant': 'h5'}, appname)
35 | ),
36 | el(Divider, {'style': {'marginTop': '0.5rem',
37 | 'marginBottom': '0.5rem'}
38 | }
39 | ),
40 | el(FlexboxCenter, None,
41 | el(Typography, {'variant': 'h5'}, f"Version: {version}")
42 | ),
43 | )
44 | )
45 |
46 |
--------------------------------------------------------------------------------
/client/src/main/appData.py:
--------------------------------------------------------------------------------
1 | # __pragma__ ('skip')
2 | from common import require
3 | # __pragma__ ('noskip')
4 |
5 |
6 | applogo = require("../static/app_logo.jpg")
7 | appname = "Library Management System"
8 |
9 | gaid = 'UA-100000000-1'
10 |
11 |
--------------------------------------------------------------------------------
/client/src/main/appTheme.py:
--------------------------------------------------------------------------------
1 | from common.pyreact import createElement
2 | from common.pymui import createMuiTheme, colors
3 | from common.pymui import Box, styled, TextField
4 |
5 | theme = createMuiTheme({
6 | 'overrides': {
7 | 'MuiDivider': {
8 | 'root': {
9 | 'margin': '0.8rem',
10 | }
11 | },
12 | 'MuiTextField': {
13 | 'root': {
14 | 'marginRight': '0.5rem',
15 | }
16 | },
17 | },
18 | 'palette': {
19 | 'primary': colors['teal'],
20 | 'secondary': colors['pink'],
21 | 'altPrimary': {
22 | 'main': colors['cyan'][700],
23 | 'contrastText': colors['common']['white'],
24 | },
25 | 'altSecondary': {
26 | 'main': colors['cyan'][400],
27 | 'contrastText': colors['common']['white'],
28 | },
29 | 'warning': colors['yellow'],
30 | 'error': colors['red'],
31 | },
32 | 'props': {
33 | 'MuiButton': {
34 | 'variant': 'contained',
35 | 'color': 'primary',
36 | 'style': {'minWidth': '6rem', 'margin': '0.3rem'},
37 | },
38 | 'MuiTextField': {
39 | 'variant': 'outlined',
40 | 'type': 'text',
41 | 'fullWidth': True,
42 | 'InputLabelProps': {'shrink': True},
43 | 'InputProps': {'margin': 'dense'},
44 | 'margin': 'dense',
45 | },
46 | 'MuiPaper': {
47 | 'elevation': 2
48 | },
49 | 'MuiTable': {
50 | 'stickyHeader': True,
51 | 'size': 'small'
52 | },
53 | 'MuiTableCell': {
54 | 'size': 'small'
55 | },
56 | },
57 | })
58 |
59 |
60 | def ROTextField(props):
61 | new_props = {'type': 'text', 'fullWidth': True, 'disabled': True}
62 | new_props.update(props)
63 | return createElement(TextField, new_props)
64 |
65 |
66 | Flexbox = styled(Box)({
67 | 'display': 'flex',
68 | })
69 |
70 | FlexboxCenter = styled(Box)({
71 | 'display': 'flex',
72 | 'alignItems': 'center',
73 | 'justifyContent': 'center'
74 | })
75 |
76 | modalStyles = {
77 | 'overlay': {'zIndex': 1000},
78 | 'content': {
79 | 'top': '35%',
80 | 'left': '50%',
81 | 'right': 'auto',
82 | 'bottom': 'auto',
83 | 'marginRight': '-50%',
84 | 'transform': 'translate(-50%, -50%)'
85 | }
86 | }
87 |
88 |
89 |
--------------------------------------------------------------------------------
/client/src/main/loginModal.py:
--------------------------------------------------------------------------------
1 | from common.pyreact import createElement as el, Modal
2 | from common.pymui import Box, Paper, TextField, Button, Typography
3 | from common.pymui import IconButton, CloseIcon
4 | from main.appData import appname
5 | from main.appTheme import Flexbox, FlexboxCenter, modalStyles
6 |
7 |
8 | def Login(props):
9 | onClose = props['onClose']
10 | onLogin = props['onLogin']
11 | username = props['username']
12 | password = props['password']
13 | setUsername = props['setUsername']
14 | setPassword = props['setPassword']
15 | modalState = props['modalState']
16 |
17 | def login(event):
18 | event.preventDefault()
19 | onLogin()
20 |
21 | def handleUsernameChange(event):
22 | target = event['target']
23 | setUsername(target['value'])
24 |
25 | def handlePasswordChange(event):
26 | target = event['target']
27 | setPassword(target['value'])
28 |
29 | return el(Modal, {'isOpen': modalState,
30 | 'onRequestClose': onClose,
31 | 'style': modalStyles,
32 | 'ariaHideApp': False,
33 | },
34 | el(FlexboxCenter, {'maxWidth': '300px'},
35 | el(Box, None,
36 | el(Flexbox, {'justifyContent': 'space-between',
37 | 'alignItems': 'center'},
38 | el(Typography, {'variant': 'h6',
39 | 'width': '40%',
40 | 'color': 'primary'}, appname),
41 | el(IconButton, {'edge': 'end',
42 | 'color': 'primary',
43 | 'onClick': onClose}, el(CloseIcon, None))
44 | ),
45 | el(Paper, {'elevation': 2, 'style': {'padding': '1rem'}},
46 | el('form', {'onSubmit': login},
47 | el(TextField, {'label': 'Login Name',
48 | 'variant': 'outlined',
49 | 'fullWidth': True,
50 | 'value': username,
51 | 'onChange': handleUsernameChange,
52 | 'autoFocus': True
53 | }
54 | ),
55 | el(TextField, {'label': 'Password',
56 | 'variant': 'outlined',
57 | 'fullWidth': True,
58 | 'type': 'password',
59 | 'value': password,
60 | 'onChange': handlePasswordChange
61 | }
62 | ),
63 | el(Button, {'type': 'submit',
64 | 'fullWidth': True,
65 | 'style': {'minWidth': '10rem',
66 | 'marginRight': '1rem',
67 | 'marginTop': '1rem'},
68 | }, "Login"
69 | ),
70 | )
71 | )
72 | )
73 | )
74 | )
75 |
76 |
--------------------------------------------------------------------------------
/client/src/static/app_logo.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rtp-book/project/6eb6c759868f0511083785b9a72d24f6f78c486f/client/src/static/app_logo.jpg
--------------------------------------------------------------------------------
/client/src/static/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rtp-book/project/6eb6c759868f0511083785b9a72d24f6f78c486f/client/src/static/favicon.ico
--------------------------------------------------------------------------------
/client/src/version.py:
--------------------------------------------------------------------------------
1 | version = '1.1.0'
2 |
--------------------------------------------------------------------------------
/client/src/views/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rtp-book/project/6eb6c759868f0511083785b9a72d24f6f78c486f/client/src/views/__init__.py
--------------------------------------------------------------------------------
/client/src/views/bookEdit/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rtp-book/project/6eb6c759868f0511083785b9a72d24f6f78c486f/client/src/views/bookEdit/__init__.py
--------------------------------------------------------------------------------
/client/src/views/bookEdit/bookEditForm.py:
--------------------------------------------------------------------------------
1 | from common.pyreact import createElement as el, useContext
2 | from common.pymui import TextField, RadioGroup, FormControlLabel, Radio, Button
3 | from common.pymui import Paper, Divider, Typography
4 | from main import UserCtx
5 | from main.appTheme import Flexbox
6 | from views.bookEdit.bookEditLookups import CategoriesList, PublishersList
7 | from views.bookEdit.bookEditLookups import FormatsList, ConditionsList
8 |
9 |
10 | def BookEditForm(props):
11 | book = props['book']
12 | handleInputChange = props['handleInputChange']
13 | categories = props['categories']
14 | publishers = props['publishers']
15 | formats = props['formats']
16 | conditions = props['conditions']
17 | isDirty = props['isDirty']
18 | saveBook = props['saveBook']
19 | deleteBook = props['deleteBook']
20 |
21 | ctx = useContext(UserCtx)
22 | isLoggedIn = ctx['isLoggedIn']
23 |
24 | read_only = not isLoggedIn
25 |
26 | return el(Paper, {'style': {'padding': '0.5rem', 'marginTop': '0.8rem'}},
27 | el(Flexbox, None,
28 | el(Flexbox, {'style': {'width': '40%',
29 | 'flexDirection': 'column'}
30 | },
31 | el(TextField, {'label': "Title",
32 | 'name': 'Title',
33 | 'value': book.Title,
34 | 'onChange': handleInputChange,
35 | 'required': True,
36 | 'autoFocus': True,
37 | 'disabled': read_only
38 | }
39 | ),
40 | el(TextField, {'label': "Author",
41 | 'name': 'Author',
42 | 'value': book.Author or "",
43 | 'onChange': handleInputChange,
44 | 'disabled': read_only
45 | }
46 | ),
47 | el(TextField, {'select': True,
48 | 'label': "Publisher",
49 | 'name': 'Publisher',
50 | 'value': book.Publisher or "",
51 | 'onChange': handleInputChange,
52 | 'SelectProps': {'native': True},
53 | 'disabled': read_only
54 | },
55 | el('option', {'value': ''}),
56 | el(PublishersList, {'publishers': publishers}),
57 | ),
58 | ),
59 | el(Divider, {'orientation': 'vertical', 'flexItem': True}),
60 | el(Flexbox, {'flexWrap': 'wrap',
61 | 'style': {'width': '60%',
62 | 'flexDirection': 'column'}
63 | },
64 | el(Flexbox, {'flexWrap': 'wrap'},
65 | el(Flexbox, {'style': {'width': '70%'},
66 | 'flexDirection': 'column'},
67 | el(Flexbox, None,
68 | el(TextField, {'select': True,
69 | 'label': "Category",
70 | 'name': 'Category',
71 | 'value': book.Category or "",
72 | 'onChange': handleInputChange,
73 | 'SelectProps': {'native': True},
74 | 'disabled': read_only
75 | },
76 | el('option', {'value': ''}),
77 | el(CategoriesList, {'categories': categories}),
78 | ),
79 |
80 | ),
81 | el(Flexbox, None,
82 | el(TextField, {'label': "Edition",
83 | 'name': 'Edition',
84 | 'value': book.Edition or "",
85 | 'onChange': handleInputChange,
86 | 'disabled': read_only
87 | }
88 | ),
89 | el(TextField, {'label': "DatePublished",
90 | 'name': 'DatePublished',
91 | 'value': book.DatePublished or "",
92 | 'onChange': handleInputChange,
93 | 'disabled': read_only
94 | }
95 | ),
96 | ),
97 | ),
98 | el(Flexbox, {'style': {'width': '30%'}},
99 | el(RadioGroup, {'name': 'IsFiction',
100 | 'style': {'margin': '0.7rem'},
101 | 'value': book.IsFiction,
102 | 'onChange': handleInputChange,
103 | },
104 | el(FormControlLabel,
105 | {'control': el(Radio, {'color': 'primary',
106 | 'size': 'small'}
107 | ),
108 | 'value': 1,
109 | 'label': 'Fiction',
110 | 'disabled': read_only}
111 | ),
112 | el(FormControlLabel,
113 | {'control': el(Radio, {'color': 'primary',
114 | 'size': 'small'}
115 | ),
116 | 'value': 0,
117 | 'label': 'Non-Fiction',
118 | 'disabled': read_only}
119 | ),
120 | ),
121 | ),
122 | ),
123 | el(Flexbox, {'flexWrap': 'wrap'},
124 | el(TextField, {'select': True,
125 | 'label': "Format",
126 | 'name': 'Format',
127 | 'style': {'width': '45%'},
128 | 'value': book.Format or "",
129 | 'onChange': handleInputChange,
130 | 'SelectProps': {'native': True},
131 | 'disabled': read_only
132 | },
133 | el('option', {'value': ''}),
134 | el(FormatsList, {'formats': formats}),
135 | ),
136 | el(TextField, {'label': "ISBN",
137 | 'name': 'ISBN',
138 | 'style': {'width': '36%'},
139 | 'value': book.ISBN or "",
140 | 'onChange': handleInputChange,
141 | 'disabled': read_only
142 | }
143 | ),
144 | el(TextField, {'label': "Pages",
145 | 'name': 'Pages',
146 | 'style': {'width': '15%',
147 | 'marginRight': 0},
148 | 'value': book.Pages or "",
149 | 'onChange': handleInputChange,
150 | 'disabled': read_only
151 | }
152 | ),
153 | ),
154 | ),
155 | ),
156 | el(Divider, None),
157 | el(Flexbox, None,
158 | el(Flexbox, {'style': {'width': '30%',
159 | 'flexDirection': 'column'}
160 | },
161 | el(TextField, {'label': "DateAcquired",
162 | 'name': 'DateAcquired',
163 | 'type': 'date',
164 | 'style': {'marginBottom': '0.7rem'},
165 | 'value': book.DateAcquired or "",
166 | 'onChange': handleInputChange,
167 | 'disabled': read_only
168 | }
169 | ),
170 | el(TextField, {'select': True,
171 | 'label': "Condition",
172 | 'name': 'Condition',
173 | 'value': book.Condition or "",
174 | 'onChange': handleInputChange,
175 | 'SelectProps': {'native': True},
176 | 'disabled': read_only
177 | },
178 | el('option', {'value': ''}),
179 | el(ConditionsList, {'conditions': conditions}),
180 | ),
181 | el(TextField, {'label': "Location",
182 | 'name': 'Location',
183 | 'style': {'marginTop': '0.7rem'},
184 | 'value': book.Location or "",
185 | 'onChange': handleInputChange,
186 | 'disabled': read_only
187 | }
188 | ),
189 | ),
190 | el(Divider, {'orientation': 'vertical', 'flexItem': True}),
191 | el(Flexbox, {'style': {'width': '70%',
192 | 'flexDirection': 'column'}
193 | },
194 | el(Flexbox, None,
195 | el(TextField, {'label': "Notes",
196 | 'name': 'Notes',
197 | 'multiline': True,
198 | 'rows': 4,
199 | 'rowsMax': 4,
200 | 'style': {'marginRight': 0},
201 | 'value': book.Notes or "",
202 | 'onChange': handleInputChange,
203 | 'disabled': read_only
204 | }
205 | ),
206 | ),
207 | el(Flexbox, {'justifyContent': 'flex-end',
208 | 'style': {'marginTop': '0.7rem'}
209 | },
210 | el(Flexbox, {'justifyContent': 'center',
211 | 'alignItems': 'center',
212 | 'width': '100%'},
213 | el(Typography, {'color': 'secondary'},
214 | "A book title is required!"
215 | ) if len(book.Title or "") == 0 else None
216 | ),
217 | el(Button, {'type': 'button',
218 | 'color': 'secondary',
219 | 'style': {'minWidth': '8rem'},
220 | 'disabled': not isLoggedIn or
221 | book.ID == "NEW",
222 | 'onClick': deleteBook
223 | }, "Delete"
224 | ),
225 | el(Button, {'type': 'button',
226 | 'color': 'primary',
227 | 'style': {'minWidth': '8rem',
228 | 'marginLeft': '1rem'},
229 | 'disabled': not (isLoggedIn and isDirty() and
230 | len(book.Title or "") > 0),
231 | 'onClick': saveBook
232 | }, "Save"
233 | ),
234 | ),
235 | ),
236 | ),
237 | )
238 |
239 |
--------------------------------------------------------------------------------
/client/src/views/bookEdit/bookEditLookups.py:
--------------------------------------------------------------------------------
1 | from common.pyreact import createElement as el
2 |
3 |
4 | def CategoriesList(props):
5 | categories = props['categories']
6 |
7 | def categoryToRow(author):
8 | category_id = author['ID']
9 | category_name = author['Category']
10 |
11 | return el('option', {'key': category_id,
12 | 'value': category_name}, category_name)
13 |
14 | return [categoryToRow(category) for category in categories]
15 |
16 |
17 | def PublishersList(props):
18 | publishers = props['publishers']
19 |
20 | def publisherToRow(publisher):
21 | publisher_id = publisher['ID']
22 | publisher_name = publisher['Publisher']
23 |
24 | return el('option', {'key': publisher_id,
25 | 'value': publisher_name}, publisher_name)
26 |
27 | return [publisherToRow(publisher) for publisher in publishers]
28 |
29 |
30 | def ConditionsList(props):
31 | conditions = props['conditions']
32 |
33 | def conditionToRow(condition):
34 | condition_id = condition['ID']
35 | condition_code = condition['Code']
36 | condition_name = condition['Condition']
37 |
38 | return el('option', {'key': condition_id,
39 | 'value': condition_code}, condition_name)
40 |
41 | return [conditionToRow(condition) for condition in conditions]
42 |
43 |
44 | def FormatsList(props):
45 | formats = props['formats']
46 |
47 | def formatToRow(publisher):
48 | format_id = publisher['ID']
49 | format_name = publisher['Format']
50 |
51 | return el('option', {'key': format_id,
52 | 'value': format_name}, format_name)
53 |
54 | return [formatToRow(format_) for format_ in formats]
55 |
56 |
--------------------------------------------------------------------------------
/client/src/views/bookEdit/bookEditView.py:
--------------------------------------------------------------------------------
1 | from common.jsutils import confirm
2 | from common.pyreact import useState, useEffect, createElement as el, Modal
3 | from common.pymui import Typography, AppBar, Toolbar, Box, useSnackbar
4 | from common.pymui import IconButton, CloseIcon
5 | from common.urlutils import fetch, spaRedirect
6 | from main.appTheme import modalStyles
7 | from views.bookEdit.bookEditForm import BookEditForm
8 |
9 |
10 | book_template = dict(
11 | ID=None,
12 | Title="",
13 | Author=None,
14 | Publisher=None,
15 | IsFiction=0,
16 | Category=None,
17 | Edition=None,
18 | DatePublished=None,
19 | ISBN=None,
20 | Pages=None,
21 | DateAcquired=None,
22 | Condition=None,
23 | Format=None,
24 | Location=None,
25 | Notes=None
26 | )
27 |
28 |
29 | def BookEdit(props):
30 | bookId = props['bookId']
31 | categories = props['categories']
32 | publishers = props['publishers']
33 | formats = props['formats']
34 | conditions = props['conditions']
35 | getBooks = props['getBooks']
36 |
37 | book, setBook = useState(book_template)
38 | bookInitial, setBookInitial = useState(book_template)
39 | modalState = bool(bookId)
40 |
41 | snack = useSnackbar()
42 |
43 | def handleInputChange(event):
44 | event.preventDefault()
45 | target = event['target']
46 | value = target['value']
47 | key = target['name']
48 |
49 | if key == "IsFiction": # RadioGroup sends str instead of int
50 | value = int(value)
51 |
52 | tmp_book = dict(book)
53 | tmp_book.update({key: value})
54 | setBook(tmp_book)
55 |
56 | def isDirty():
57 | changed = [key for key, val in book.items() if val != bookInitial[key]]
58 | return len(changed) > 0
59 |
60 | def saveBook():
61 | tmp_book = dict(book)
62 | if tmp_book['ID'] == "NEW":
63 | tmp_book.pop('ID')
64 |
65 | fetch(f"/api/book", on_update_success,
66 | method='POST', data=tmp_book, onError=on_update_error)
67 |
68 | def deleteBook():
69 | if confirm(f"Are you sure you want to delete {book.Title}?"):
70 | fetch(f"/api/book", on_update_success,
71 | method='DELETE', data=book, onError=on_update_error)
72 |
73 | def on_update_success():
74 | getBooks()
75 | snack.enqueueSnackbar("Book was updated!", {'variant': 'success'})
76 | spaRedirect('/books')
77 |
78 | def on_update_error():
79 | snack.enqueueSnackbar("Error updating data!", {'variant': 'error'})
80 |
81 | def on_fetch_error():
82 | snack.enqueueSnackbar("Error retrieving data!", {'variant': 'error'})
83 |
84 | def getBook():
85 | def _getBook(data):
86 | if data:
87 | tmp_book = dict(book)
88 | tmp_book.update(**data)
89 | setBookInitial(tmp_book)
90 | else:
91 | setBookInitial(book_template)
92 |
93 | if bookId == "NEW":
94 | new_book = dict(book_template)
95 | new_book.update(ID=bookId)
96 | setBookInitial(new_book)
97 | elif bookId:
98 | fetch(f"/api/book", _getBook,
99 | params={'id': bookId},
100 | onError=on_fetch_error
101 | )
102 |
103 | def update_book():
104 | tmp_book = dict(bookInitial)
105 | setBook(tmp_book)
106 |
107 | useEffect(getBook, [bookId])
108 | useEffect(update_book, [bookInitial])
109 |
110 | return el(Modal, {'isOpen': modalState,
111 | 'style': modalStyles,
112 | 'ariaHideApp': False,
113 | },
114 | el(AppBar, {'position': 'static',
115 | 'style': {'marginBottom': '0.5rem'}
116 | },
117 | el(Toolbar, {'variant': 'dense'},
118 | el(Box, {'width': '100%'},
119 | el(Typography, {'variant': 'h6'}, book.Title)
120 | ),
121 | el(IconButton, {'edge': 'end',
122 | 'color': 'inherit',
123 | 'onClick': lambda: spaRedirect('/books')
124 | }, el(CloseIcon, None)
125 | ),
126 | ),
127 | ),
128 | el(BookEditForm, {'book': book,
129 | 'handleInputChange': handleInputChange,
130 | 'categories': categories,
131 | 'publishers': publishers,
132 | 'formats': formats,
133 | 'conditions': conditions,
134 | 'isDirty': isDirty,
135 | 'saveBook': saveBook,
136 | 'deleteBook': deleteBook
137 | }),
138 | )
139 |
140 |
--------------------------------------------------------------------------------
/client/src/views/bookList/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rtp-book/project/6eb6c759868f0511083785b9a72d24f6f78c486f/client/src/views/bookList/__init__.py
--------------------------------------------------------------------------------
/client/src/views/bookList/bookListFilter.py:
--------------------------------------------------------------------------------
1 | from common.pyreact import useState, createElement as el
2 | from common.pymui import TextField, Button, Paper
3 | from main.appTheme import Flexbox
4 | from views.bookEdit.bookEditLookups import CategoriesList
5 |
6 |
7 |
8 | def BooksFilterVu(props):
9 | categories = props['categories']
10 | setFilterParams = props['setFilterParams']
11 |
12 | Title, setTitle = useState("")
13 | Author, setAuthor = useState("")
14 | IsFiction, setIsFiction = useState("")
15 | Category, setCategory = useState("")
16 | ISBN, setISBN = useState("")
17 |
18 | def setState(field, value):
19 | switch = dict(Title=setTitle,
20 | Author=setAuthor,
21 | IsFiction=setIsFiction,
22 | Category=setCategory,
23 | ISBN=setISBN
24 | )
25 | switch[field](value)
26 |
27 | def handleInputChange(event):
28 | event.preventDefault()
29 | target = event['target']
30 | value = target['value']
31 | key = target['name']
32 | setState(key, value)
33 |
34 | def handleFilter():
35 | params = dict(Title=Title,
36 | Author=Author,
37 | IsFiction=IsFiction,
38 | Category=Category,
39 | ISBN=ISBN
40 | )
41 | filters = {key: val for key, val in params.items() if len(val) > 0}
42 | setFilterParams(filters)
43 |
44 | filter_width = '17%'
45 |
46 | return el(Paper, None,
47 | el(Flexbox, {'flexWrap': 'wrap', 'style': {'margin': '0.5rem'}},
48 | el(TextField, {'label': "Title",
49 | 'name': 'Title',
50 | 'style': {'width': filter_width},
51 | 'value': Title,
52 | 'onChange': handleInputChange,
53 | }
54 | ),
55 | el(TextField, {'label': "Author",
56 | 'name': 'Author',
57 | 'style': {'width': filter_width},
58 | 'value': Author,
59 | 'onChange': handleInputChange,
60 | }
61 | ),
62 | el(TextField, {'label': "Genre",
63 | 'name': 'IsFiction',
64 | 'style': {'width': filter_width},
65 | 'value': IsFiction,
66 | 'onChange': handleInputChange,
67 | 'select': True,
68 | 'SelectProps': {'native': True},
69 | },
70 | el('option', {'value': ''}, ""),
71 | el('option', {'value': '1'}, "Fiction"),
72 | el('option', {'value': '0'}, "Non-Fiction"),
73 | ),
74 | el(TextField, {'label': "Category",
75 | 'name': 'Category',
76 | 'style': {'width': filter_width},
77 | 'value': Category,
78 | 'onChange': handleInputChange,
79 | 'select': True,
80 | 'SelectProps': {'native': True},
81 | },
82 | el('option', {'value': ''}),
83 | el(CategoriesList, {'categories': categories}),
84 | ),
85 | el(TextField, {'label': "ISBN",
86 | 'name': 'ISBN',
87 | 'style': {'width': filter_width},
88 | 'value': ISBN,
89 | 'onChange': handleInputChange,
90 | }
91 | ),
92 | el(Button, {'type': 'button',
93 | 'color': 'primary',
94 | 'size': 'small',
95 | 'style': {'minWidth': '7rem', 'margin': '0.5rem'},
96 | 'onClick': handleFilter
97 | }, "Filter"
98 | ),
99 | )
100 | )
101 |
102 |
--------------------------------------------------------------------------------
/client/src/views/bookList/bookListTable.py:
--------------------------------------------------------------------------------
1 | from common.pyreact import createElement as el
2 | from common.urlutils import buildParams, spaRedirect
3 | from common.pymui import Box, Link, Tooltip
4 | from common.pymui import TableContainer, Table
5 | from common.pymui import TableHead, TableBody, TableRow, TableCell
6 |
7 |
8 | def BookRowVu(props):
9 | book = props['book']
10 |
11 | book_id = book['ID']
12 | title = book['Title']
13 | author = book['Author']
14 | book_type = "Fiction" if book['IsFiction'] else "Non-Fiction"
15 | category = book['Category']
16 | book_fmt = book['Format']
17 | location = book['Location']
18 |
19 | def handleEdit():
20 | params = buildParams({'id': book_id})
21 | spaRedirect(f'/books{params}')
22 |
23 | return el(TableRow, {'onClick': handleEdit},
24 | el(TableCell, None,
25 | el(Tooltip, {'title': title if title else ''},
26 | el(Box, {'width': '10rem',
27 | 'textOverflow': 'ellipsis',
28 | 'overflow': 'hidden',
29 | 'whiteSpace': 'nowrap'}, title),
30 | )
31 | ),
32 | el(TableCell, None,
33 | el(Box, {'width': '6rem', 'whiteSpace': 'nowrap'}, author)),
34 | el(TableCell, None,
35 | el(Box, {'width': '5rem'}, book_type)),
36 | el(TableCell, None,
37 | el(Box, {'width': '8rem'}, category)),
38 | el(TableCell, None,
39 | el(Box, {'width': '6rem'}, book_fmt)),
40 | el(TableCell, None,
41 | el(Box, {'width': '5rem'}, location)),
42 | )
43 |
44 |
45 | def BooksTable(props):
46 | books = props['books']
47 | setSortKey = props['setSortKey']
48 |
49 | def bookToRow(book):
50 | return el(BookRowVu, {'key': book['ID'], 'book': book})
51 |
52 | def BookRows():
53 | if len(books) > 0:
54 | return [bookToRow(book) for book in books if book]
55 | else:
56 | return el(TableRow, {'key': '0'})
57 |
58 | def HeaderSort(props_):
59 | field = props_['field']
60 |
61 | def handleSort(event):
62 | event.preventDefault()
63 | setSortKey(field)
64 |
65 | return el(TableCell, None,
66 | el(Link, {'href': '#', 'onClick': handleSort}, field)
67 | )
68 |
69 | return el(TableContainer, {'style': {'maxHeight': '30rem'}},
70 | el(Table, None,
71 | el(TableHead, None,
72 | el(TableRow, None,
73 | el(HeaderSort, {'field': 'Title'}),
74 | el(HeaderSort, {'field': 'Author'}),
75 | el(HeaderSort, {'field': 'Genre'}),
76 | el(HeaderSort, {'field': 'Category'}),
77 | el(HeaderSort, {'field': 'Format'}),
78 | el(HeaderSort, {'field': 'Location'}),
79 | ),
80 | ),
81 | el(TableBody, None,
82 | el(BookRows, None)
83 | )
84 | )
85 | )
86 |
87 |
--------------------------------------------------------------------------------
/client/src/views/bookList/bookListView.py:
--------------------------------------------------------------------------------
1 | from common.pyreact import useState, useEffect, createElement as el, useContext
2 | from common.pymui import Typography, AppBar, Toolbar, Tooltip, useSnackbar
3 | from common.pymui import Container, Box, Paper, CircularProgress
4 | from common.pymui import IconButton, CloseIcon, AddIcon
5 | from common.urlutils import fetch, spaRedirect, buildParams
6 | from main import UserCtx
7 | from views.bookEdit.bookEditView import BookEdit
8 | from views.bookList.bookListFilter import BooksFilterVu
9 | from views.bookList.bookListTable import BooksTable
10 |
11 |
12 | def BookList(props):
13 | params = props['params']
14 |
15 | book_id = params['id']
16 |
17 | books, setBooks = useState([])
18 | sortKey, setSortKey = useState('Title')
19 | showProgress, setShowProgress = useState(False)
20 | filterParams, setFilterParams = useState({})
21 | bookModal, setBookModal = useState(None)
22 |
23 | categories, setCategories = useState([])
24 | publishers, setPublishers = useState([])
25 | formats, setFormats = useState([])
26 | conditions, setConditions = useState([])
27 |
28 | ctx = useContext(UserCtx)
29 | isLoggedIn = ctx['isLoggedIn']
30 |
31 | snack = useSnackbar()
32 |
33 | def handleAdd():
34 | new_params = buildParams({'id': "NEW"})
35 | spaRedirect(f'/books{new_params}')
36 |
37 | def setEdit():
38 | if book_id:
39 | setBookModal(book_id)
40 | else:
41 | setBookModal(None)
42 |
43 | def sortBooks():
44 | book_list = [dict(tmp_book) for tmp_book in books]
45 | if len(book_list) > 0:
46 | setBooks(sorted(book_list, key=lambda k: k[sortKey] or ""))
47 |
48 | def on_fetch_error():
49 | snack.enqueueSnackbar("Error retrieving data!",
50 | {'variant': 'error'}
51 | )
52 | setShowProgress(False)
53 |
54 | def getBooks():
55 | isPending = True
56 |
57 | def _getBooks(data):
58 | book_list = data if data else []
59 | if isPending:
60 | if len(book_list) > 0:
61 | setBooks(sorted(book_list, key=lambda k: k[sortKey]))
62 | else:
63 | setBooks([])
64 | setShowProgress(False)
65 |
66 | def abort():
67 | nonlocal isPending
68 | isPending = False
69 |
70 | setShowProgress(True)
71 | fetch("/api/books", _getBooks,
72 | params=filterParams,
73 | onError=on_fetch_error
74 | )
75 | return abort
76 |
77 | def getLookup(table_name, setState):
78 | isPending = True
79 |
80 | def _getLookup(data):
81 | if isPending:
82 | if data:
83 | setState(data)
84 | else:
85 | setState([])
86 |
87 | def abort():
88 | nonlocal isPending
89 | isPending = False
90 |
91 | fetch(f"/api/lookup/{table_name}", _getLookup)
92 | return abort
93 |
94 | def getLookups():
95 | getLookup('Categories', setCategories)
96 | getLookup('Publishers', setPublishers)
97 | getLookup('Formats', setFormats)
98 | getLookup('Conditions', setConditions)
99 |
100 | useEffect(getBooks, [filterParams])
101 | useEffect(sortBooks, [sortKey])
102 | useEffect(setEdit, [book_id])
103 | useEffect(getLookups, [])
104 |
105 | return el(Container, None,
106 | el(AppBar, {'position': 'static',
107 | 'style': {'marginBottom': '0.5rem'}
108 | },
109 | el(Toolbar, {'variant': 'dense'},
110 | el(Tooltip, {'title': 'Add new book'},
111 | el(IconButton, {'edge': 'start',
112 | 'color': 'inherit',
113 | 'padding': 'none',
114 | 'onClick': handleAdd
115 | }, el(AddIcon, None)
116 | )
117 | ) if isLoggedIn else None,
118 | el(Box, {'width': '100%'},
119 | el(Typography, {'variant': 'h6'}, "Books")
120 | ),
121 | el(IconButton, {'edge': 'end',
122 | 'color': 'inherit',
123 | 'onClick': lambda: spaRedirect('/')
124 | }, el(CloseIcon, None)
125 | ),
126 | ),
127 | ),
128 | el(BooksFilterVu, {'categories': categories,
129 | 'setFilterParams': setFilterParams}
130 | ),
131 | el(Paper, {'style': {'padding': '0.5rem', 'marginTop': '0.8rem'}},
132 | el(BooksTable, {'books': books, 'setSortKey': setSortKey})
133 | ),
134 | el(BookEdit, {'bookId': bookModal,
135 | 'categories': categories,
136 | 'publishers': publishers,
137 | 'formats': formats,
138 | 'conditions': conditions,
139 | 'getBooks': getBooks
140 | }),
141 | el(CircularProgress,
142 | {'style': {'position': 'absolute',
143 | 'top': '30%',
144 | 'left': '50%',
145 | 'marginLeft': -12}
146 | }) if showProgress else None
147 | )
148 |
149 |
--------------------------------------------------------------------------------
/client/src/views/landingPage/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rtp-book/project/6eb6c759868f0511083785b9a72d24f6f78c486f/client/src/views/landingPage/__init__.py
--------------------------------------------------------------------------------
/client/src/views/landingPage/landingPageMenu.py:
--------------------------------------------------------------------------------
1 | from common.pyreact import useState, createElement as el, Fragment, useContext
2 | from common.pymui import Menu, MenuItem
3 | from main import UserCtx
4 | from views.lookupTable.lookupView import lookup_tables
5 |
6 |
7 | def LandingPageMenu(props):
8 | mainMenu = props['mainMenu']
9 | mainMenuClose = props['mainMenuClose']
10 | setLookupModal = props['setLookupModal']
11 | aboutModalOpen = props['aboutModalOpen']
12 |
13 | ctx = useContext(UserCtx)
14 | logout = ctx['logout']
15 | isLoggedIn = ctx['isLoggedIn']
16 |
17 | lookupMenu, setLookupMenu = useState(None)
18 |
19 | def lookupMenuOpen(event):
20 | setLookupMenu(event['currentTarget'])
21 |
22 | def lookupMenuClose():
23 | setLookupMenu(None)
24 | mainMenuClose()
25 |
26 | def handleLookup(event):
27 | value = event['currentTarget']['textContent']
28 | lookupMenuClose()
29 | setLookupModal(value)
30 |
31 | def handleAbout():
32 | mainMenuClose()
33 | aboutModalOpen()
34 |
35 | def handleLogout():
36 | mainMenuClose()
37 | logout()
38 |
39 | return el(Fragment, None,
40 | el(Menu, {'id': 'main-menu',
41 | 'anchorEl': mainMenu,
42 | 'keepMounted': True,
43 | 'open': bool(mainMenu),
44 | 'onClose': mainMenuClose,
45 | },
46 | el(MenuItem, {'onClick': lookupMenuOpen,
47 | 'disabled': not isLoggedIn}, "Lookup Tables"),
48 | el(MenuItem, {'onClick': handleAbout}, "About"),
49 | el(MenuItem, {'onClick': handleLogout,
50 | 'disabled': not isLoggedIn}, "Logout"),
51 | ),
52 | el(Menu, {'id': 'lookup-menu',
53 | 'anchorEl': lookupMenu,
54 | 'keepMounted': True,
55 | 'open': bool(lookupMenu),
56 | 'onClose': lookupMenuClose,
57 | 'transformOrigin': {'vertical': 'top',
58 | 'horizontal': 'center'},
59 | },
60 | [el(MenuItem, {'key': table['name'],
61 | 'onClick': handleLookup
62 | }, table['name']) for table in lookup_tables
63 | ],
64 | )
65 | )
66 |
67 |
68 |
--------------------------------------------------------------------------------
/client/src/views/landingPage/landingPageView.py:
--------------------------------------------------------------------------------
1 | from common.pyreact import useState, createElement as el, useEffect, useContext
2 | from common.pymui import Container, Paper, Typography, useSnackbar
3 | from common.pymui import IconButton, MenuIcon
4 | from common.urlutils import fetch, Link, buildParams, spaRedirect
5 | from main import UserCtx
6 | from main.appTheme import Flexbox, FlexboxCenter
7 | from main.aboutModal import About
8 | from main.appData import appname
9 | from main.loginModal import Login
10 | from views.landingPage.landingPageMenu import LandingPageMenu
11 | from views.lookupTable.lookupView import LookupTable
12 |
13 |
14 | def LandingPage(props):
15 | params = dict(props['params'])
16 | pathname = props['pathname']
17 |
18 | show_login = params.get('login', 'hide') == 'show'
19 |
20 | uCtx = useContext(UserCtx)
21 | isLoggedIn = uCtx['isLoggedIn']
22 | login = uCtx['login']
23 |
24 | mainMenu, setMainMenu = useState(None)
25 | aboutShow, setAboutShow = useState(False)
26 | lookupModal, setLookupModal = useState(None)
27 | loginModal, setLoginModal = useState(False)
28 | username, setUsername = useState("")
29 | password, setPassword = useState("")
30 |
31 | snack = useSnackbar()
32 |
33 | def doLogin():
34 | def _login():
35 | login(username)
36 | snack.enqueueSnackbar("Login succeeded!", {'variant': 'success'})
37 | spaRedirect(redir)
38 |
39 | def _loginFailed():
40 | setLoginModal(True)
41 | snack.enqueueSnackbar("Login failed, please try again",
42 | {'variant': 'error'}
43 | )
44 |
45 | redir = params.get('redir', f"{pathname}{buildParams(params)}")
46 | fetch("/api/login", _login,
47 | data={'username': username, 'password': password},
48 | method='POST',
49 | onError=_loginFailed
50 | )
51 |
52 | setLoginModal(False)
53 |
54 | def clearUser():
55 | if loginModal:
56 | setUsername("")
57 | setPassword("")
58 |
59 | def mainMenuOpen(event):
60 | setMainMenu(event['currentTarget'])
61 |
62 | def mainMenuClose():
63 | setMainMenu(None)
64 |
65 | def aboutModalOpen():
66 | setAboutShow(True)
67 |
68 | useEffect(lambda: setLoginModal(show_login), [show_login])
69 | useEffect(clearUser, [loginModal])
70 |
71 | return el(Container, {'maxWidth': 'md'},
72 | el(Paper, {'style': {'padding': '1rem'}},
73 | el(Flexbox, {'alignItems': 'center'},
74 | el(IconButton, {'edge': 'start',
75 | 'color': 'inherit',
76 | 'onClick': mainMenuOpen
77 | }, el(MenuIcon, None)
78 | ),
79 | el(Typography, {'variant': 'h5'}, appname)
80 | )
81 | ),
82 | el(LandingPageMenu, {'mainMenu': mainMenu,
83 | 'mainMenuClose': mainMenuClose,
84 | 'setLookupModal':
85 | lambda tbl: setLookupModal(tbl),
86 | 'aboutModalOpen': aboutModalOpen}
87 | ),
88 | el(Paper, {'style': {'padding': '0.5rem',
89 | 'marginTop': '1rem'}
90 | },
91 | el(FlexboxCenter, None,
92 | el(Typography, {'variant': 'h5'},
93 | el(Link, {'to': '/books'}, "Books")
94 | ),
95 | ),
96 | el(FlexboxCenter, None,
97 | el(Typography, {'variant': 'h5'},
98 | el(Link, {'to': '#',
99 | 'onClick': lambda: setLoginModal(True)
100 | }, "Login")
101 | ) if not isLoggedIn else None
102 | ),
103 | ),
104 | el(Login, {'onClose': lambda: setLoginModal(False),
105 | 'onLogin': doLogin,
106 | 'password': password,
107 | 'username': username,
108 | 'setUsername': lambda usr: setUsername(usr),
109 | 'setPassword': lambda pwd: setPassword(pwd),
110 | 'modalState': loginModal,
111 | }
112 | ),
113 | el(About, {'onClose': lambda: setAboutShow(False),
114 | 'modalState': aboutShow}
115 | ),
116 | el(LookupTable, {'table': lookupModal,
117 | 'onClose': lambda: setLookupModal(None)}
118 | ) if lookupModal else None
119 | )
120 |
121 |
--------------------------------------------------------------------------------
/client/src/views/lookupTable/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rtp-book/project/6eb6c759868f0511083785b9a72d24f6f78c486f/client/src/views/lookupTable/__init__.py
--------------------------------------------------------------------------------
/client/src/views/lookupTable/lookupList.py:
--------------------------------------------------------------------------------
1 | from common.pyreact import useState, createElement as el, Fragment
2 | from common.pymui import Box, AddIcon, IconButton
3 | from common.pymui import TableContainer, Table
4 | from common.pymui import TableHead, TableBody, TableRow, TableCell
5 |
6 |
7 | def ItemEditCell(props):
8 | field = props['field']
9 | setEditValues = props['setEditValues']
10 | editValues = props['editValues']
11 | checkSaveItem = props['checkSaveItem']
12 |
13 | field_value = editValues[field]
14 |
15 | def handleChange(event):
16 | event.preventDefault()
17 | target = event['target']
18 | value = target['value']
19 | key = target['id']
20 |
21 | new_editValues = dict(editValues)
22 | new_editValues.update({key: value})
23 | setEditValues(new_editValues)
24 |
25 | def handleKeyPress(event):
26 | key = event['key']
27 | if key == 'Enter':
28 | checkSaveItem()
29 |
30 | return el(TableCell, None,
31 | el('input', {'id': field,
32 | 'onKeyPress': handleKeyPress,
33 | 'onChange': handleChange,
34 | 'value': field_value,
35 | 'style': {'width': '10rem', 'margin': '-4px'}}
36 | )
37 | )
38 |
39 |
40 | def ItemCell(props):
41 | value = props['value']
42 |
43 | return el(TableCell, None,
44 | el(Box, {'width': '10rem', 'whiteSpace': 'nowrap'}, value)
45 | )
46 |
47 |
48 | def ItemRowVu(props):
49 | item = props['item']
50 | fields = props['fields']
51 | selected = props['selected']
52 | setSelected = props['setSelected']
53 | editValues = props['editValues']
54 | setEditValues = props['setEditValues']
55 | checkSaveItem = props['checkSaveItem']
56 |
57 | def handleClick():
58 | if selected:
59 | checkSaveItem()
60 |
61 | def handleDoubleClick():
62 | setEditValues(dict(item))
63 | setSelected(item['ID'])
64 |
65 | if item['ID'] == selected:
66 | return el(TableRow, None,
67 | [el(ItemEditCell, {'key': field,
68 | 'field': field,
69 | 'setEditValues': setEditValues,
70 | 'editValues': editValues,
71 | 'checkSaveItem': checkSaveItem,
72 | }) for field in fields]
73 | )
74 | else:
75 | return el(TableRow, {'onClick': handleClick,
76 | 'onDoubleClick': handleDoubleClick},
77 | [el(ItemCell, {'key': field,
78 | 'value': item[field],
79 | }) for field in fields]
80 | )
81 |
82 |
83 | def ItemRows(props):
84 | items = props['items']
85 | fields = props['fields']
86 | setItems = props['setItems']
87 | saveItem = props['saveItem']
88 |
89 | selected, setSelected = useState(None)
90 | editValues, setEditValues = useState({})
91 |
92 | def checkSaveItem():
93 | old_item = next((item for item in items if item['ID'] == selected), {})
94 | new_item = dict(editValues)
95 | if new_item['ID'] == "NEW":
96 | new_item.pop("ID")
97 | # Transcrypt differs from CPython on object equality so check each value
98 | if len(new_item) != len(old_item) or \
99 | len([key for key, val in new_item.items()
100 | if val != old_item[key]]) > 0:
101 | saveItem(new_item)
102 | setEditValues({})
103 | setSelected(None)
104 |
105 | def handleAdd():
106 | new_items = [dict(item) for item in items]
107 | new_item = {field: "" for field in fields}
108 | new_item['ID'] = "NEW"
109 | new_items.append(new_item)
110 | setItems(new_items)
111 | setEditValues(new_item)
112 | setSelected("NEW")
113 |
114 | def itemToRow(item):
115 | return el(ItemRowVu, {'key': item['ID'],
116 | 'item': item,
117 | 'fields': fields,
118 | 'selected': selected,
119 | 'setSelected': setSelected,
120 | 'editValues': editValues,
121 | 'setEditValues': setEditValues,
122 | 'checkSaveItem': checkSaveItem,
123 | }
124 | )
125 |
126 | def AddItem():
127 | if selected == "NEW":
128 | return None
129 | else:
130 | return el(TableRow, {'key': 'ADD'},
131 | el(TableCell, {'variant': 'footer',
132 | 'align': 'center',
133 | 'colSpan': len(fields)},
134 | el(IconButton, {'edge': 'end',
135 | 'color': 'primary',
136 | 'size': 'small',
137 | 'padding': 'none',
138 | 'onClick': handleAdd
139 | }, el(AddIcon, None)
140 | )
141 | )
142 | )
143 |
144 | if len(items) > 0:
145 | return el(Fragment, None,
146 | [itemToRow(item) for item in items if item],
147 | el(AddItem, None)
148 | )
149 | else:
150 | return el(AddItem, None)
151 |
152 |
153 | def ItemsList(props):
154 | items = props['items']
155 | fields = props['fields']
156 | saveItem = props['saveItem']
157 | setItems = props['setItems']
158 |
159 | def HeaderCols():
160 | return el(TableRow, None,
161 | [el(TableCell, {'key': field, 'fields': fields}, field)
162 | for field in fields]
163 | )
164 |
165 | return el(TableContainer, {'style': {'maxHeight': '10.5rem'}},
166 | el(Table, {'size': 'small', 'stickyHeader': True},
167 | el(TableHead, None,
168 | el(HeaderCols, None),
169 | ),
170 | el(TableBody, None,
171 | el(ItemRows, {'items': items,
172 | 'fields': fields,
173 | 'setItems': setItems,
174 | 'saveItem': saveItem}),
175 | )
176 | )
177 | )
178 |
179 |
--------------------------------------------------------------------------------
/client/src/views/lookupTable/lookupView.py:
--------------------------------------------------------------------------------
1 | from common.pyreact import Modal, useState, useEffect, createElement as el
2 | from common.pymui import Typography, AppBar, Toolbar, Box, Paper
3 | from common.pymui import IconButton, CloseIcon, useSnackbar
4 | from common.urlutils import fetch
5 | from main.appTheme import modalStyles
6 | from views.lookupTable.lookupList import ItemsList
7 |
8 | lookup_tables = [
9 | {'name': 'Categories', 'fields': ['Category'], 'sort': 'Category'},
10 | {'name': 'Publishers', 'fields': ['Publisher'], 'sort': 'Publisher'},
11 | {'name': 'Conditions', 'fields': ['Code', 'Condition'], 'sort': 'ID}'},
12 | {'name': 'Formats', 'fields': ['Format'], 'sort': 'Format'}
13 | ]
14 |
15 |
16 | def LookupTable(props):
17 | onClose = props['onClose']
18 | table_name = props['table']
19 |
20 | table_info = next(
21 | (table for table in lookup_tables if table['name'] == table_name),
22 | {})
23 | table_fields = table_info['fields']
24 | table_sort = table_info['sort']
25 | modalState = bool(table_name)
26 |
27 | items, setItems = useState([])
28 |
29 | snack = useSnackbar()
30 |
31 | def on_update_error():
32 | snack.enqueueSnackbar("Error updating lookup table!",
33 | {'variant': 'error'})
34 | getItems()
35 |
36 | def on_update_success():
37 | snack.enqueueSnackbar("Lookup table updated!", {'variant': 'success'})
38 | getItems()
39 |
40 | def saveItem(item):
41 | # If all non-ID values are empty, then delete record
42 | if len(''.join([val for key, val in item.items() if key != 'ID'])) == 0:
43 | if not item.get('ID', None):
44 | getItems() # Probably an unmodified record so just refresh list
45 | else:
46 | fetch(f"/api/lookup/{table_name}", on_update_success,
47 | method='DELETE', data=item, onError=on_update_error)
48 | else:
49 | fetch(f"/api/lookup/{table_name}", on_update_success,
50 | method='POST', data=item, onError=on_update_error)
51 |
52 | def getItems():
53 | def _getItems(data):
54 | item_list = data if data else []
55 | if len(item_list) > 0:
56 | item_list.sort(key=lambda item: item[table_sort])
57 | setItems(item_list)
58 | else:
59 | setItems([])
60 |
61 | if table_name:
62 | fetch(f"/api/lookup/{table_name}", _getItems)
63 | else:
64 | setItems([])
65 |
66 | useEffect(getItems, [table_name])
67 |
68 | return el(Modal, {'isOpen': modalState,
69 | 'style': modalStyles,
70 | 'ariaHideApp': False,
71 | },
72 | el(AppBar, {'position': 'static',
73 | 'style': {'marginBottom': '0.5rem'}
74 | },
75 | el(Toolbar, {'variant': 'dense'},
76 | el(Box, {'width': '100%'},
77 | el(Typography, {'variant': 'h6'}, f"Table: {table_name}")
78 | ),
79 | el(IconButton, {'edge': 'end',
80 | 'color': 'inherit',
81 | 'onClick': onClose
82 | }, el(CloseIcon, None)
83 | ),
84 | ),
85 | ),
86 | el(Paper, {'style': {'padding': '0.5rem', 'marginTop': '0.8rem'}},
87 | el(ItemsList, {'items': items,
88 | 'fields': table_fields,
89 | 'saveItem': saveItem,
90 | 'setItems': setItems})
91 | )
92 | )
93 |
94 |
--------------------------------------------------------------------------------
/server/admin_routes.py:
--------------------------------------------------------------------------------
1 | from flask import jsonify, request, Response, session, Blueprint
2 | from werkzeug.security import check_password_hash, generate_password_hash
3 | import flask_login
4 | import logging
5 |
6 | log = logging.getLogger(__name__)
7 |
8 | admin_api = Blueprint('admin_api', __name__, url_prefix='/api')
9 |
10 |
11 | class User(flask_login.UserMixin):
12 | def __init__(self, userid):
13 | self.id = userid
14 |
15 |
16 | @admin_api.route('/login', methods=['POST'])
17 | def login():
18 | record = request.get_json()
19 | pwd = record.pop('password', "")
20 | username = record.pop('username', "")
21 | username = username.lower()
22 |
23 | if validateLogin(username, pwd):
24 | flask_login.login_user(User(username))
25 | session.permanent = True
26 | return jsonify({"OK": 200})
27 | else:
28 | log.warning(f"Failed login attempt for user '{username}'")
29 | flask_login.logout_user()
30 | return Response("UNAUTHORIZED", 401)
31 |
32 |
33 | def validateLogin(user, pwd):
34 | # TODO: Use db.Users table for user validation
35 | SECRET_PASSWORD = generate_password_hash('123')
36 | return user == 'admin' and check_password_hash(SECRET_PASSWORD, pwd)
37 |
38 |
39 | @admin_api.route('/logout', methods=['GET'])
40 | @flask_login.login_required
41 | def logout():
42 | flask_login.logout_user()
43 | return jsonify({"OK": 200})
44 |
45 |
46 | @admin_api.route('/whoami', methods=['GET'])
47 | @flask_login.login_required
48 | def getUser():
49 | user = ''
50 | if flask_login.current_user.is_authenticated:
51 | user = flask_login.current_user.get_id()
52 | return jsonify({'success': {'user': user}})
53 |
54 |
55 | @admin_api.route('/ping', methods=['GET'])
56 | @flask_login.login_required
57 | def keepAlive():
58 | return jsonify({"OK": 200})
59 |
60 |
--------------------------------------------------------------------------------
/server/appserver.py:
--------------------------------------------------------------------------------
1 | from flask import Flask, jsonify, request, Response, session
2 | import flask_login
3 | import logging
4 | import os
5 | from datetime import timedelta
6 |
7 | import dbutils as db
8 | from admin_routes import admin_api, User
9 | from db_routes import db_api
10 |
11 |
12 | fmt = "[%(asctime)s]|%(levelname)s|[%(module)s]:%(funcName)s()|%(message)s"
13 | logging.basicConfig(format=fmt)
14 | log = logging.getLogger()
15 | log.setLevel(logging.INFO)
16 |
17 | SERVE_SPA = True
18 | SPA_DIR = '../client/dist/prod'
19 | SESSION_TIMEOUT = 60
20 |
21 | app = Flask(__name__, static_folder=SPA_DIR, static_url_path='/')
22 | app.register_blueprint(admin_api)
23 | app.register_blueprint(db_api)
24 |
25 | login_manager = flask_login.LoginManager()
26 | login_manager.init_app(app)
27 |
28 | app.config.update(
29 | SECRET_KEY=os.urandom(16),
30 | SESSION_COOKIE_HTTPONLY=True,
31 | SESSION_COOKIE_SAMESITE='Lax',
32 | )
33 |
34 | app.permanent_session_lifetime = timedelta(minutes=SESSION_TIMEOUT)
35 |
36 | # Create the database if it doesn't exist
37 | db.connect()
38 |
39 |
40 | @login_manager.user_loader
41 | def load_user(user_id):
42 | return User(user_id)
43 |
44 |
45 | @login_manager.unauthorized_handler
46 | def unauthorized_callback():
47 | log.warning(f"UNAUTHORIZED [{request.method}] {request.full_path}")
48 | return Response("UNAUTHORIZED", 401)
49 |
50 |
51 | @app.errorhandler(404)
52 | def request_not_found(err):
53 | if SERVE_SPA:
54 | return app.send_static_file('index.html')
55 | else:
56 | return jsonify({'error': str(err)})
57 |
58 |
59 | @app.before_request
60 | def request_log():
61 | log.info(f"[{request.method}] {request.full_path}")
62 |
63 |
64 | @app.before_request
65 | def refresh_session():
66 | session.modified = True
67 |
68 |
69 | @app.after_request
70 | def apply_headers(response):
71 | response.headers['Access-Control-Allow-Origin'] = '*'
72 | if request.blueprint == 'db_api':
73 | response.headers['Cache-Control'] = 'no-store'
74 | return response
75 |
76 |
77 | @app.route('/api/', methods=['GET'])
78 | def index():
79 | return Response("OK", 200)
80 |
81 |
82 | if __name__ == "__main__":
83 | app.run(debug=True, port=8000)
84 |
85 |
--------------------------------------------------------------------------------
/server/db_routes.py:
--------------------------------------------------------------------------------
1 | from flask import jsonify, request, Blueprint
2 | import flask_login
3 | import logging
4 |
5 | import dbutils as db
6 |
7 | log = logging.getLogger(__name__)
8 |
9 | db_api = Blueprint('db_api', __name__, url_prefix='/api')
10 |
11 |
12 | LOOKUPS = ['Categories', 'Publishers', 'Conditions', 'Formats']
13 |
14 |
15 | @db_api.route('/lookup/', methods=['GET'])
16 | def get_lookup(name):
17 |
18 | if name in LOOKUPS:
19 | result, data = db.select(f"SELECT * FROM {name}")
20 | return jsonify({result: data})
21 |
22 | return jsonify(None)
23 |
24 |
25 | @db_api.route('/lookup/', methods=['POST'])
26 | @flask_login.login_required
27 | def update_lookup(name):
28 | if name in LOOKUPS:
29 | record = request.get_json()
30 | record_id = record.pop('ID', None)
31 | fields = record.keys()
32 | values = tuple([record[field] for field in fields])
33 |
34 | if record_id:
35 | fields = ','.join([f"{field}=?" for field in fields])
36 | sql = f"UPDATE {name} SET {fields} WHERE ID=?"
37 | values = values + (record_id,)
38 | else:
39 | fields = ','.join(record.keys())
40 | params = ','.join(['?']*len(record.keys()))
41 | sql = f"INSERT INTO {name} ({fields}) VALUES ({params})"
42 |
43 | result, data = db.execute(sql, values)
44 |
45 | return jsonify({result: data})
46 |
47 | return jsonify(None)
48 |
49 |
50 | @db_api.route('/lookup/', methods=['DELETE'])
51 | @flask_login.login_required
52 | def delete_lookup(name):
53 | if name in LOOKUPS:
54 | record = request.get_json()
55 | record_id = record.pop('ID', None)
56 |
57 | if record_id:
58 | sql = f"DELETE FROM {name} WHERE ID=?"
59 | values = (record_id,)
60 | result, data = db.execute(sql, values)
61 | return jsonify({result: data})
62 |
63 | return jsonify(None)
64 |
65 |
66 | @db_api.route('/book', methods=['GET'])
67 | def get_book():
68 | book_id = request.args.get('id', "")
69 | if len(book_id) > 0:
70 | result, data = db.select(f"SELECT * FROM Books WHERE ID=?", (book_id,))
71 | return jsonify({result: data[0]} if len(data) > 0 else None)
72 |
73 | return jsonify(None)
74 |
75 |
76 | @db_api.route('/book', methods=['POST'])
77 | @flask_login.login_required
78 | def update_book():
79 | record = request.get_json()
80 | if record:
81 | record_id = record.pop('ID', None)
82 | fields = record.keys()
83 | values = tuple([record[field] if len(f"{record[field]}") > 0 else None
84 | for field in fields]
85 | )
86 |
87 | if record_id:
88 | fields = ','.join([f"{field}=?" for field in fields])
89 | sql = f"UPDATE Books SET {fields} WHERE ID=?"
90 | values = values + (record_id,)
91 | else:
92 | fields = ','.join(record.keys())
93 | params = ','.join(['?']*len(record.keys()))
94 | sql = f"INSERT INTO Books ({fields}) VALUES ({params})"
95 |
96 | result, data = db.execute(sql, values)
97 | return jsonify({result: data})
98 |
99 | return jsonify(None)
100 |
101 |
102 | @db_api.route('/book', methods=['DELETE'])
103 | @flask_login.login_required
104 | def delete_book():
105 | record = request.get_json()
106 | if record:
107 | record_id = record.pop('ID', None)
108 |
109 | if record_id:
110 | sql = f"DELETE FROM Books WHERE ID=?"
111 | values = (record_id,)
112 | result, data = db.execute(sql, values)
113 | return jsonify({result: data})
114 |
115 | return jsonify(None)
116 |
117 |
118 | @db_api.route('/books', methods=['GET'])
119 | def get_books():
120 | params = dict(request.args)
121 | fields = params.keys()
122 |
123 | if 'IsFiction' in fields:
124 | params['IsFiction'] = int(params['IsFiction'])
125 | if 'Title' in fields:
126 | params['Title'] = f"%{params['Title']}%"
127 | if 'Author' in fields:
128 | params['Author'] = f"%{params['Author']}%"
129 |
130 | values = tuple([params[field] if len(f"{params[field]}") > 0 else None
131 | for field in fields]
132 | )
133 |
134 | def get_operator(field):
135 | return ' LIKE ' if field in ['Title', 'Author'] else '='
136 |
137 | wc = ' AND '.join([f"{field}{get_operator(field)}?" for field in fields])
138 | if len(wc) > 0:
139 | wc = f" WHERE {wc}"
140 | result, data = db.select(f"SELECT * FROM Books{wc}", values)
141 | return jsonify({result: data} if len(data) > 0 else None)
142 |
143 |
--------------------------------------------------------------------------------
/server/dbutils.py:
--------------------------------------------------------------------------------
1 | import sqlite3
2 | import os
3 | import logging
4 |
5 |
6 | DB_LOC = './database'
7 | DB_NAME = 'books.db'
8 | DB_FILE = os.path.join(DB_LOC, DB_NAME)
9 |
10 | if __name__ == "__main__":
11 | fmt = "[%(asctime)s]|%(levelname)s|[%(module)s]:%(funcName)s()|%(message)s"
12 | logging.basicConfig(format=fmt, level=logging.DEBUG)
13 | log = logging.getLogger(__name__)
14 |
15 |
16 | def create_db(autopopulate):
17 | if not os.path.exists(DB_LOC):
18 | os.mkdir(DB_LOC)
19 |
20 | with sqlite3.connect(DB_FILE) as conn:
21 | cur = conn.cursor()
22 |
23 | # sqlite foreign key support is off by default
24 | cur.execute("PRAGMA foreign_keys = ON")
25 | conn.commit()
26 |
27 | # Create tables
28 | cur.execute("CREATE TABLE Categories ("
29 | "ID INTEGER PRIMARY KEY NOT NULL, "
30 | "Category TEXT UNIQUE)")
31 | cur.execute("CREATE TABLE Publishers ("
32 | "ID INTEGER PRIMARY KEY NOT NULL, "
33 | "Publisher TEXT UNIQUE)")
34 | cur.execute("CREATE TABLE Conditions ("
35 | "ID INTEGER PRIMARY KEY NOT NULL, "
36 | "Code TEXT UNIQUE, "
37 | "Condition TEXT)")
38 | cur.execute("CREATE TABLE Formats ("
39 | "ID INTEGER PRIMARY KEY NOT NULL, "
40 | "Format TEXT UNIQUE)")
41 | cur.execute("CREATE TABLE Users ("
42 | "ID INTEGER PRIMARY KEY NOT NULL, "
43 | "Username TEXT UNIQUE, "
44 | "Password TEXT)")
45 | conn.commit()
46 |
47 | cur.execute("CREATE TABLE Books ("
48 | "ID INTEGER PRIMARY KEY NOT NULL,"
49 | "Title TEXT NOT NULL,"
50 | "Author TEXT,"
51 | "Publisher TEXT "
52 | "REFERENCES Publishers(Publisher) "
53 | "ON UPDATE CASCADE ON DELETE RESTRICT,"
54 | "IsFiction BOOLEAN DEFAULT 0,"
55 | "Category TEXT "
56 | "REFERENCES Categories(Category) "
57 | "ON UPDATE CASCADE ON DELETE RESTRICT,"
58 | "Edition TEXT,"
59 | "DatePublished TEXT,"
60 | "ISBN TEXT,"
61 | "Pages INTEGER,"
62 | "DateAcquired DATE,"
63 | "Condition TEXT "
64 | "REFERENCES Conditions(Code) "
65 | "ON UPDATE CASCADE ON DELETE RESTRICT,"
66 | "Format TEXT "
67 | "REFERENCES Formats(Format) "
68 | "ON UPDATE CASCADE ON DELETE RESTRICT,"
69 | "Location TEXT,"
70 | "Notes TEXT"
71 | ")"
72 | )
73 | conn.commit()
74 | cur.close()
75 |
76 | if autopopulate:
77 | from testdata import populate_db
78 | populate_db(conn)
79 |
80 |
81 | def connect(autopopulate=False):
82 | if not os.path.exists(DB_FILE):
83 | log.warning("Creating new DB")
84 | create_db(autopopulate)
85 |
86 |
87 | def execute(stmt, params=()):
88 | try:
89 | with sqlite3.connect(DB_FILE) as conn:
90 | conn.execute("PRAGMA foreign_keys = ON")
91 | curs = conn.cursor()
92 | curs.execute(stmt, params)
93 | rowcount = curs.rowcount
94 | curs.close()
95 | conn.commit()
96 | return 'success', rowcount
97 | except Exception as e:
98 | log.error(e)
99 | return 'error', str(e)
100 |
101 |
102 | def select(stmt, params=()):
103 | try:
104 | with sqlite3.connect(DB_FILE) as conn:
105 | curs = conn.cursor()
106 | curs.execute(stmt, params)
107 | desc = curs.description
108 | cols = [fld[0] for fld in desc]
109 | rowset = curs.fetchall()
110 | rows = [dict(zip(cols, row)) for row in rowset]
111 | curs.close()
112 | return 'success', rows
113 | except Exception as e:
114 | log.error(e)
115 | return 'error', str(e)
116 |
117 |
118 | def _main():
119 | with sqlite3.connect(DB_FILE) as conn:
120 | cur = conn.cursor()
121 | sql = "SELECT name FROM sqlite_master WHERE type = ?"
122 | cur.execute(sql, ('table',))
123 | data = cur.fetchall()
124 | print('Tables:', [tbl[0] for tbl in data])
125 |
126 |
127 | if __name__ == '__main__':
128 | connect(True)
129 | _main()
130 |
131 |
--------------------------------------------------------------------------------
/server/requirements.txt:
--------------------------------------------------------------------------------
1 | Flask>=2.0.0
2 | Flask-Login>=0.6.0
3 |
4 |
--------------------------------------------------------------------------------
/server/testdata.py:
--------------------------------------------------------------------------------
1 | def populate_db(conn):
2 | cur = conn.cursor()
3 |
4 | # Populate lookup tables
5 | conditions = {'F': 'Fine/Like New',
6 | 'NF': 'Near Fine',
7 | 'VG': 'Very Good',
8 | 'G': 'Good',
9 | 'FR': 'Fair',
10 | 'P': 'Poor'}
11 | for code, cond in conditions.items():
12 | cur.execute(f"INSERT INTO Conditions(Code, Condition) "
13 | f"values('{code}', '{cond}')")
14 |
15 | formats = ['Hardcover', 'Paperback', 'Oversized', 'Pamphlet', 'E-book']
16 | for item in formats:
17 | cur.execute(f"INSERT INTO Formats(Format) values('{item}')")
18 |
19 | categories = ['Computers & Tech',
20 | 'Biographies',
21 | 'Sci-Fi & Fantasy',
22 | 'Arts & Music',
23 | 'History']
24 | for item in categories:
25 | cur.execute(f"INSERT INTO Categories(Category) values('{item}')")
26 |
27 | cur.execute(f"INSERT INTO Publishers(Publisher) values('No Starch Press')")
28 | cur.execute(f"INSERT INTO Publishers(Publisher) values('Del Rey Books')")
29 | conn.commit()
30 |
31 | # Populate main table
32 | cur.execute("INSERT INTO Books(Title, Author, IsFiction, "
33 | "Category, DateAcquired, Condition, Location) "
34 | "values('React to Python', 'Sheehan', 0, "
35 | "'Computers & Tech', '2020-12-01', 'F', 'A3')")
36 | cur.execute("INSERT INTO Books(Title, Author, Publisher, "
37 | "ISBN, IsFiction, Category, Format) "
38 | "values('I Robot', 'Isaac Asimov', 'Del Rey Books', "
39 | "'055338256X', 1, 'Sci-Fi & Fantasy', 'Paperback')")
40 | cur.execute("INSERT INTO Books(Title, Author, ISBN, IsFiction, Category) "
41 | "values('The C Programming Language', 'Kernighan & Ritchie', "
42 | "'0131103628', 0, 'Computers & Tech')")
43 | conn.commit()
44 |
45 | cur.execute(f"INSERT INTO Users(Username, Password) values('admin', '')")
46 | conn.commit()
47 |
48 |
--------------------------------------------------------------------------------