├── .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 | --------------------------------------------------------------------------------