From 57ff25198a82b3f6f413440e4005f0ade8dfb8d8 Mon Sep 17 00:00:00 2001 From: pacien Date: Thu, 29 Jul 2021 18:04:48 +0200 Subject: app: render and serve proper web pages --- app/app.py | 4 ++++ app/app_account.py | 39 +++++++++++++++++++++++++++------------ app/app_sessions.py | 27 +++++++++++++++++++++++++++ app/app_templating.py | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ app/app_wallet.py | 46 +++++++++++++++++++++++++++++++++------------- 5 files changed, 142 insertions(+), 25 deletions(-) create mode 100644 app/app_templating.py (limited to 'app') diff --git a/app/app.py b/app/app.py index 3bf0337..b22b3cd 100644 --- a/app/app.py +++ b/app/app.py @@ -3,6 +3,7 @@ # Licence: EUPL-1.2 from fastapi import FastAPI, status +from fastapi.staticfiles import StaticFiles import app_sessions import app_account import app_wallet @@ -15,3 +16,6 @@ main.add_middleware(app_sessions.SessionManager) # Register our request handlers main.include_router(app_account.router) main.include_router(app_wallet.router) + +# Handler for static resource files (CSS, JS, ...) +main.mount('/', StaticFiles(directory='./static/'), name='static') diff --git a/app/app_account.py b/app/app_account.py index 3f4869d..e3d6433 100644 --- a/app/app_account.py +++ b/app/app_account.py @@ -3,6 +3,7 @@ # Licence: EUPL-1.2 from fastapi import APIRouter, Depends, Request, Form, status +from fastapi.responses import RedirectResponse, HTMLResponse from passlib.context import CryptContext import re @@ -10,8 +11,9 @@ import re from embrace.exceptions import IntegrityError from psycopg2.errors import UniqueViolation -from app_sessions import UserSession +from app_sessions import UserSession, FlashMessageQueue from app_database import db_transaction +from app_templating import TemplateRenderer # Password hashing context. @@ -20,42 +22,51 @@ password_ctx = CryptContext(schemes=['bcrypt'], deprecated='auto') username_pattern = re.compile(r'^[a-zA-Z0-9-_]{4,16}$') +to_homepage = RedirectResponse('/', status_code=status.HTTP_303_SEE_OTHER) +to_wallet = RedirectResponse('/wallet', status_code=status.HTTP_303_SEE_OTHER) + router = APIRouter() -@router.get('/') +@router.get('/', response_class=HTMLResponse) def homepage( session: UserSession=Depends(UserSession), + render: TemplateRenderer=Depends(TemplateRenderer), ): if session.is_logged_in(): - return 'Welcome!' + return to_wallet - return 'Homepage here.' + return render('homepage.html.jinja') @router.post('/account/register') def account_register( session: UserSession=Depends(UserSession), + messages: FlashMessageQueue=Depends(FlashMessageQueue), username: str=Form(...), password: str=Form(...), ): try: if username_pattern.match(username) is None: - return 'error: Invalid username format.' + messages.add('error', 'Invalid username format.') + return to_homepage if not 4 <= len(password) <= 32: - return 'error: Invalid password length.' + messages.add('error', 'Invalid password length.') + return to_homepage hash = password_ctx.hash(password) with db_transaction() as tx: user = tx.create_account(username=username, password_hash=hash) session.login(user.id) - return 'Account succesfully created. Welcome!' + messages.add('success', 'Account succesfully created. Welcome!') + return to_wallet except IntegrityError as exception: if isinstance(exception.__cause__, UniqueViolation): - return 'error: This username is already taken.' + messages.add('error', 'This username is already taken.') + return to_homepage else: raise exception @@ -63,6 +74,7 @@ def account_register( @router.post('/account/login') def session_login( session: UserSession=Depends(UserSession), + messages: FlashMessageQueue=Depends(FlashMessageQueue), username: str=Form(...), password: str=Form(...), ): @@ -71,17 +83,20 @@ def session_login( if user is not None and password_ctx.verify(password, user.password_hash): session.login(user.id) - return 'Welcome back!' + messages.add('info', 'Welcome back!') + return to_wallet else: - return 'error: Invalid credentials.' + messages.add('error', 'Invalid credentials.') + return to_homepage @router.post('/account/logout') def session_logout( session: UserSession=Depends(UserSession), + messages: FlashMessageQueue=Depends(FlashMessageQueue), ): if session.is_logged_in(): session.logout() - return 'You have been successfully logged out.' + messages.add('info', 'You have been successfully logged out.') - return 'Nothing to do' + return to_homepage diff --git a/app/app_sessions.py b/app/app_sessions.py index 89521fb..7a931d5 100644 --- a/app/app_sessions.py +++ b/app/app_sessions.py @@ -15,6 +15,33 @@ cookie_key = environ['COOKIE_SECRET_KEY'] SessionManager = partial(SessionMiddleware, secret_key=cookie_key) +class FlashMessageQueue: + """ + Session decorator for managing session flash messages to be displayed to + the user from one page to another. This suits confirmation and error + messages. Messages are stored in the session cookie, which is limited in + size to about 4kb. + """ + + def __init__(self, request: Request): + if 'messages' not in request.session: + request.session['messages'] = [] + + self._messages = request.session['messages'] + + def add(self, class_: str, message: str): + self._messages.append((class_, message)) + + def __iter__(self): + return self + + def __next__(self): + if not self._messages: + raise StopIteration + + return self._messages.pop(0) + + class UserSession: """ Session decorator for managing user login sessions. diff --git a/app/app_templating.py b/app/app_templating.py new file mode 100644 index 0000000..427170b --- /dev/null +++ b/app/app_templating.py @@ -0,0 +1,51 @@ +# UGE / L2 / Intro to relational databases / Python project prototype +# Author: Pacien TRAN-GIRARD +# Licence: EUPL-1.2 + +from typing import Optional, NamedTuple +from decimal import Decimal + +from fastapi import Depends, Request +from fastapi.templating import Jinja2Templates + +from app_sessions import UserSession, FlashMessageQueue +from app_database import db_transaction + + +# Load and parse Jinja HTML templates pages and fragments. +bare_templates = Jinja2Templates(directory='./templates/') + + +class TemplateRenderer: + """ + Template renderer using dependency injection to populate the template + parameters used by all pages. + """ + + def __init__( + self, + request: Request, + messages: FlashMessageQueue=Depends(FlashMessageQueue), + session: UserSession=Depends(UserSession), + ): + self._request = request + self._messages = messages + self._session = session + + def _get_user(self) -> Optional[NamedTuple]: + if not self._session.is_logged_in(): + return None + + with db_transaction() as tx: + return tx.fetch_account(user_id=self._session.get_user_id()) + + def _shared_template_args(self) -> dict: + return { + 'request': self._request, + 'messages': self._messages, + 'user': self._get_user(), + } + + def __call__(self, template: str, **kwargs): + template_args = self._shared_template_args() | kwargs + return bare_templates.TemplateResponse(template, template_args) diff --git a/app/app_wallet.py b/app/app_wallet.py index 9dffbd5..922c683 100644 --- a/app/app_wallet.py +++ b/app/app_wallet.py @@ -5,36 +5,45 @@ from decimal import Decimal from fastapi import APIRouter, Form, Depends, status +from fastapi.responses import RedirectResponse, HTMLResponse from embrace.exceptions import IntegrityError from psycopg2.errors import CheckViolation from psycopg2.extensions import ISOLATION_LEVEL_SERIALIZABLE -from app_sessions import UserSession +from app_sessions import UserSession, FlashMessageQueue from app_database import db_transaction +from app_templating import TemplateRenderer +to_wallet = RedirectResponse('/wallet', status_code=status.HTTP_303_SEE_OTHER) + router = APIRouter() # TODO: add paging for the transaction history -@router.get('/wallet') +@router.get('/wallet', response_class=HTMLResponse) def wallet( session: UserSession=Depends(UserSession.authenticated), + messages: FlashMessageQueue=Depends(FlashMessageQueue), + render: TemplateRenderer=Depends(TemplateRenderer), ): with db_transaction() as tx: history = tx.fetch_transactions(user_id=session.get_user_id()) - return list(history) + # rendering done in the transaction to make use the iteration cursor + return render('wallet.html.jinja', transactions=history) -@router.post('/wallet/transfer') +@router.post('/wallet/transfer', response_class=HTMLResponse) def wallet_transfer( session: UserSession=Depends(UserSession.authenticated), + messages: FlashMessageQueue=Depends(FlashMessageQueue), recipient: str=Form(...), amount: Decimal=Form(...), ): if amount <= 0: - return 'error: Invalid transaction amount.' + messages.add('error', 'Invalid transaction amount.') + return to_wallet try: with db_transaction(ISOLATION_LEVEL_SERIALIZABLE) as tx: @@ -48,14 +57,17 @@ def wallet_transfer( amount=amount, fee=amount * Decimal(0.10)) - return 'Your business is appreciated.' + messages.add('success', 'Your business is appreciated.') + return to_wallet except LookupError as exception: - return 'error: ' + str(exception) + messages.add('error', str(exception)) + return to_wallet except IntegrityError as exception: if isinstance(exception.__cause__, CheckViolation): - return 'error: Insufficient funds.' + messages.add('error', 'Insufficient funds.') + return to_wallet else: raise exception @@ -63,33 +75,41 @@ def wallet_transfer( @router.post('/wallet/deposit') def wallet_deposit( session: UserSession=Depends(UserSession.authenticated), + messages: FlashMessageQueue=Depends(FlashMessageQueue), amount: Decimal=Form(...), ): if amount <= 0: - return 'error: Invalid transaction amount.' + messages.add('error', 'Invalid transaction amount.') + return to_wallet with db_transaction(ISOLATION_LEVEL_SERIALIZABLE) as tx: tx.deposit(user_id=session.get_user_id(), amount=amount) - return 'Your business is appreciated.' + messages.add('success', 'Your business is appreciated.') + return to_wallet @router.post('/wallet/withdraw') def wallet_withdraw( session: UserSession=Depends(UserSession.authenticated), + messages: FlashMessageQueue=Depends(FlashMessageQueue), + render: TemplateRenderer=Depends(TemplateRenderer), amount: Decimal=Form(...), ): if amount <= 0: - return 'error: Invalid transaction amount.' + messages.add('error', 'Invalid transaction amount.') + return to_wallet try: with db_transaction(ISOLATION_LEVEL_SERIALIZABLE) as tx: tx.withdraw(user_id=session.get_user_id(), amount=amount) - return 'Annnnnd... It\'s gone.' + messages.add('success', 'Annnnnd... It\'s gone.') + return render('launder.html.jinja') except IntegrityError as exception: if isinstance(exception.__cause__, CheckViolation): - return 'error: Insufficient funds.' + messages.add('error', 'Insufficient funds.') + return to_wallet else: raise exception -- cgit v1.2.3