From 21f848cc826a0c35450ace958c98003ae1b58fce Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 5 Oct 2023 22:22:13 -0500 Subject: [PATCH] Move login form to separate control so we can hopefully use that for "manager override" ..? we'll see --- wuttapos/controls/loginform.py | 268 +++++++++++++++++++++++++++++++++ wuttapos/views/base.py | 4 +- wuttapos/views/login.py | 238 +++-------------------------- wuttapos/views/pos.py | 84 +++++------ 4 files changed, 322 insertions(+), 272 deletions(-) create mode 100644 wuttapos/controls/loginform.py diff --git a/wuttapos/controls/loginform.py b/wuttapos/controls/loginform.py new file mode 100644 index 0000000..5848ab8 --- /dev/null +++ b/wuttapos/controls/loginform.py @@ -0,0 +1,268 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttaPOS -- Pythonic Point of Sale System +# Copyright © 2023 Lance Edgar +# +# This file is part of WuttaPOS. +# +# WuttaPOS is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# WuttaPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# WuttaPOS. If not, see . +# +################################################################################ +""" +WuttaPOS - login form control +""" + +import flet as ft + +from .base import WuttaControl +from .keyboard import WuttaKeyboard +from .tenkey import WuttaTenkeyMenu + + +class WuttaLoginForm(WuttaControl): + + def __init__(self, config, *args, **kwargs): + + # permission to be checked for login to succeed + self.perm_required = kwargs.pop('perm_required', 'pos.ring_sales') + + # may or may not show the username field + # nb. must set this before normal __init__ + if 'show_username' in kwargs: + self.show_username = kwargs.pop('show_username') + else: + self.show_username = config.getbool('wuttapos', 'login.show_username', + default=True) + + # may or may not show 10-key menu instead of full keyboard + if 'use_tenkey' in kwargs: + self.use_tenkey = kwargs.pop('use_tenkey') + else: + self.use_tenkey = config.getbool('wuttapos', 'login.use_tenkey', + default=False) + + self.on_login_failure = kwargs.pop('on_login_failure', None) + self.on_authz_failure = kwargs.pop('on_authz_failure', None) + self.on_login_success = kwargs.pop('on_login_success', None) + + super().__init__(config, *args, **kwargs) + + # track which login input has focus + self.focused = None + + def build(self): + + login_form = self.build_login_form() + + if self.use_tenkey: + controls = [ + ft.Row( + [ + login_form, + ft.VerticalDivider(), + WuttaTenkeyMenu(self.config, simple=True, + on_char=self.tenkey_char, + on_enter=self.tenkey_enter), + ], + alignment=ft.MainAxisAlignment.CENTER, + ), + ] + + else: # full keyboard + controls = [ + login_form, + ft.Row(), + ft.Row(), + ft.Row(), + WuttaKeyboard(self.config, on_keypress=self.keyboard_keypress, + on_long_backspace=self.long_backspace), + ] + + return ft.Column(controls=controls, + expand=True, + alignment=ft.MainAxisAlignment.CENTER) + + def build_login_form(self): + form_fields = [] + + self.password = ft.TextField(label="Password", width=200, password=True, + on_submit=self.password_submit, + on_focus=self.password_focus, + autofocus=not self.show_username) + self.focused = self.password + + if self.show_username: + self.username = ft.TextField(label="Login", width=200, + on_submit=self.username_submit, + on_focus=self.username_focus, + autofocus=True) + form_fields.append(self.username) + self.focused = self.username + + form_fields.append(self.password) + + login_button = self.make_button("Login", + height=60, + width=60 * 2.5, + bgcolor='blue', + on_click=self.attempt_login) + + reset_button = self.make_button("Clear", + height=60, + width=60 * 2.5, + on_click=self.clear_login) + + if self.use_tenkey: + form_fields.extend([ + ft.Row(), + ft.Row(), + ft.Row( + [ + reset_button, + login_button, + ], + ), + ]) + return ft.Column( + controls=form_fields, + horizontal_alignment=ft.CrossAxisAlignment.CENTER, + ) + + else: # full keyboard + form_fields.extend([ + login_button, + reset_button, + ]) + return ft.Row( + controls=form_fields, + alignment=ft.MainAxisAlignment.CENTER, + ) + + def keyboard_keypress(self, key): + assert self.focused + + if key == '⏎': # ENTER + + # attempt to submit the login form.. + if self.show_username and self.focused is self.username: + self.username_submit() + else: + if self.password_submit(): + + # here the login has totally worked, which means + # this form has fulfilled its purpose. hence must + # exit early to avoid update() in case we are to + # be redirected etc. otherwise may get errors + # trying to update controls which have already + # been dropped from the page.. + return + + elif key == '⌫': + self.focused.value = self.focused.value[:-1] + + else: + self.focused.value += key + + self.focused.focus() + self.update() + + def keyboard_long_backspace(self): + assert self.focused + self.focused.value = '' + self.focused.focus() + self.update() + + def tenkey_char(self, key): + if key == '@': + return + + self.focused.value = f"{self.focused.value or ''}{key}" + self.update() + + def tenkey_enter(self, e): + if self.show_username and self.focused is self.username: + self.username_submit(e) + self.update() + else: + if not self.password_submit(e): + self.update() + + def username_focus(self, e): + self.focused = self.username + + def username_submit(self, e=None): + if self.username.value: + self.password.focus() + else: + self.username.focus() + + def password_focus(self, e): + self.focused = self.password + + def password_submit(self, e=None): + if self.password.value: + return self.attempt_login(e) + else: + self.password.focus() + return False + + def attempt_login(self, e=None): + if self.show_username and not self.username.value: + self.username.focus() + return False + if not self.password.value: + self.password.focus() + return False + + session = self.app.make_session() + auth = self.app.get_auth_handler() + user = auth.authenticate_user(session, + self.username.value if self.show_username else None, + self.password.value) + user_display = str(user) if user else None + has_perm = auth.has_permission(session, user, self.perm_required) if user else False + session.close() + + if user: + + if has_perm: + if self.on_login_success: + self.on_login_success(user, user_display) + return True + + else: + if self.on_authz_failure: + self.on_authz_failure(user, user_display) + + self.clear_login() + + else: + if self.on_login_failure: + self.on_login_failure(e) + + self.password.focus() + self.update() + + return False + + def clear_login(self, e=None): + if self.show_username: + self.username.value = "" + self.password.value = "" + if self.show_username: + self.username.focus() + else: + self.password.focus() + self.update() diff --git a/wuttapos/views/base.py b/wuttapos/views/base.py index 175f2bd..ea0f12d 100644 --- a/wuttapos/views/base.py +++ b/wuttapos/views/base.py @@ -87,15 +87,13 @@ class WuttaView(ft.View): kwargs.setdefault('height', 100) return ft.Image(src=logo, **kwargs) - def show_snackbar(self, text, bgcolor='yellow', update=True): + def show_snackbar(self, text, bgcolor='yellow'): self.page.snack_bar = ft.SnackBar(ft.Text(text, color='black', size=20, weight=ft.FontWeight.BOLD), bgcolor=bgcolor, duration=1500) self.page.snack_bar.open = True - if update: - self.page.update() class WuttaViewContainer(ft.Container): diff --git a/wuttapos/views/login.py b/wuttapos/views/login.py index 6f2bad8..759f2bb 100644 --- a/wuttapos/views/login.py +++ b/wuttapos/views/login.py @@ -27,8 +27,7 @@ WuttaPOS - login view import flet as ft from .base import WuttaView -from wuttapos.controls.keyboard import WuttaKeyboard -from wuttapos.controls.tenkey import WuttaTenkeyMenu +from wuttapos.controls.loginform import WuttaLoginForm class LoginView(WuttaView): @@ -36,31 +35,9 @@ class LoginView(WuttaView): Main POS view for WuttaPOS """ - def __init__(self, config, *args, **kwargs): - - # may or may not show the username field - # nb. must set this before normal __init__ - if 'show_username' in kwargs: - self.show_username = kwargs.pop('show_username') - else: - self.show_username = config.getbool('wuttapos', 'login.show_username', - default=True) - - # may or may not show 10-key menu instead of full keyboard - if 'use_tenkey' in kwargs: - self.use_tenkey = kwargs.pop('use_tenkey') - else: - self.use_tenkey = config.getbool('wuttapos', 'login.use_tenkey', - default=False) - - # build controls - super().__init__(config, *args, **kwargs) - - # track which login input has focus - self.focused = None - def build_controls(self): title = self.app.get_title() + controls = [ ft.Row( [self.make_logo_image(height=200)], @@ -73,33 +50,12 @@ class LoginView(WuttaView): ft.Row(), ft.Row(), ft.Row(), + WuttaLoginForm(self.config, + on_login_failure=self.login_failure, + on_authz_failure=self.authz_failure, + on_login_success=self.login_success), ] - login_form = self.build_login_form() - - if self.use_tenkey: - controls.extend([ - ft.Row( - [ - login_form, - ft.VerticalDivider(), - WuttaTenkeyMenu(self.config, simple=True, - on_char=self.tenkey_char, - on_enter=self.tenkey_enter), - ], - alignment=ft.MainAxisAlignment.CENTER, - ), - ]) - else: - controls.extend([ - login_form, - ft.Row(), - ft.Row(), - ft.Row(), - WuttaKeyboard(self.config, on_keypress=self.keypress, - on_long_backspace=self.long_backspace), - ]) - return [ self.build_header(), ft.Column(controls=controls, @@ -107,179 +63,21 @@ class LoginView(WuttaView): alignment=ft.MainAxisAlignment.CENTER), ] - def build_login_form(self): - form_fields = [] - - self.password = ft.TextField(label="Password", width=200, password=True, - on_submit=self.password_submit, - on_focus=self.password_focus, - autofocus=not self.show_username) - self.focused = self.password - - if self.show_username: - self.username = ft.TextField(label="Login", width=200, - on_submit=self.username_submit, - on_focus=self.username_focus, - autofocus=True) - form_fields.append(self.username) - self.focused = self.username - - form_fields.append(self.password) - - login_button = ft.Container( - content=ft.Text("Login", size=20, - weight=ft.FontWeight.BOLD), - height=60, - width=60 * 2.5, - alignment=ft.alignment.center, - border=ft.border.all(1, 'black'), - border_radius=ft.border_radius.all(5), - bgcolor='blue', - on_click=self.attempt_login) - - reset_button = ft.Container( - content=ft.Text("Clear", size=20, - weight=ft.FontWeight.BOLD), - height=60, - width=60 * 2.5, - alignment=ft.alignment.center, - border=ft.border.all(1, 'black'), - border_radius=ft.border_radius.all(5), - on_click=self.clear_login) - - if self.use_tenkey: - form_fields.extend([ - ft.Row(), - ft.Row(), - ft.Row( - [ - reset_button, - login_button, - ], - ), - ]) - else: - form_fields.extend([ - login_button, - reset_button, - ]) - - if self.use_tenkey: - return ft.Column( - controls=form_fields, - horizontal_alignment=ft.CrossAxisAlignment.CENTER, - ) - - else: - return ft.Row( - controls=form_fields, - alignment=ft.MainAxisAlignment.CENTER, - ) - - def keypress(self, key): - assert self.focused - if key == '⏎': - if self.show_username and self.focused is self.username: - self.username_submit() - else: - # nb. when this succeeds, we will have been redirected - # to /pos so must exit here to avoid update() etc. - if self.password_submit(): - return - elif key == '⌫': - self.focused.value = self.focused.value[:-1] - else: - self.focused.value += key - - self.focused.focus() - self.update() - - def long_backspace(self): - assert self.focused - self.focused.value = '' - self.focused.focus() - self.update() - - def tenkey_char(self, key): - if key == '@': - return - - self.focused.value = f"{self.focused.value or ''}{key}" + def login_failure(self, e): + self.show_snackbar("Login failed!", bgcolor='yellow') self.page.update() - def tenkey_enter(self, e): - if self.show_username and self.focused is self.username: - self.username_submit() - self.update() - else: - if not self.password_submit(): - self.update() - - def username_focus(self, e): - self.focused = self.username - - def username_submit(self, e=None): - if self.username.value: - self.password.focus() - else: - self.username.focus() - - def password_focus(self, e): - self.focused = self.password - - def password_submit(self, e=None): - if self.password.value: - return self.attempt_login() - else: - self.password.focus() - return False - - def clear_login(self, e): - if self.show_username: - self.username.value = "" - self.password.value = "" - if self.show_username: - self.username.focus() - else: - self.password.focus() + def authz_failure(self, user, user_display): + self.show_snackbar(f"User not allowed to ring sales: {user_display}", + bgcolor='yellow') self.page.update() - def attempt_login(self, e=None): - if self.show_username and not self.username.value: - self.username.focus() - return False - if not self.password.value: - self.password.focus() - return False + def login_success(self, user, user_display): + self.page.session.set('user_uuid', user.uuid) + self.page.session.set('user_display', user_display) - session = self.app.make_session() - auth = self.app.get_auth_handler() - user = auth.authenticate_user(session, - self.username.value if self.show_username else None, - self.password.value) - user_display = str(user) if user else None - session.close() + # TODO: hacky but works for now + if not self.config.production(): + self.page.client_storage.set('user_uuid', user.uuid) - if user: - # handle success - self.page.session.set('user_uuid', user.uuid) - self.page.session.set('user_display', user_display) - - # TODO: hacky but works for now - if not self.config.production(): - self.page.client_storage.set('user_uuid', user.uuid) - - self.page.go('/pos') - return True - - else: - # handle failure - self.page.snack_bar = ft.SnackBar(ft.Text("Login failed!", - color='black', - weight=ft.FontWeight.BOLD), - bgcolor='yellow', - duration=1500) - self.page.snack_bar.open = True - self.password.focus() - self.page.update() - return False + self.page.go('/pos') diff --git a/wuttapos/views/pos.py b/wuttapos/views/pos.py index b8de914..0134300 100644 --- a/wuttapos/views/pos.py +++ b/wuttapos/views/pos.py @@ -142,7 +142,8 @@ class POSView(WuttaView): session.commit() if row.row_type == self.enum.POS_ROW_TYPE_BADPRICE: - self.show_snackbar(f"Product has invalid price: {row.item_entry}") + self.show_snackbar(f"Product has invalid price: {row.item_entry}", + bgcolor='yellow') else: self.add_row_item(row) @@ -864,12 +865,12 @@ class POSView(WuttaView): def adjust_price_click(self, e): if not len(self.items.controls): - self.show_snackbar("There are no line items", update=False) + self.show_snackbar("There are no line items", bgcolor='yellow') self.reset() return if not self.selected_item: - self.show_snackbar("Must first select a line item", update=False) + self.show_snackbar("Must first select a line item", bgcolor='yellow') self.main_input.focus() self.page.update() return @@ -894,7 +895,7 @@ class POSView(WuttaView): try: price = decimal.Decimal(price_override.value) except decimal.InvalidOperation: - self.show_snackbar(f"Price is not valid: {price_override.value}", update=False) + self.show_snackbar(f"Price is not valid: {price_override.value}", bgcolor='yellow') self.main_input.focus() self.page.update() return @@ -1095,52 +1096,37 @@ class POSView(WuttaView): batch = self.get_current_batch(session, create=False) session.close() - if batch: + # nothing to void if no batch + if not batch: + self.show_snackbar("No transaction", bgcolor='yellow') + self.reset() + return - # prompt to void something - target = 'LINE' if self.selected_item else 'TXN' - dlg = ft.AlertDialog( - title=ft.Text("Confirm VOID"), - content=ft.Text(f"Really VOID {target}?"), - actions=[ - ft.Container(content=ft.Text(f"VOID {target}", - size=self.default_font_size, - color='black', - weight=ft.FontWeight.BOLD), - height=self.default_button_size, - width=self.default_button_size * 2.5, - alignment=ft.alignment.center, - bgcolor='red', - border=ft.border.all(1, 'black'), - border_radius=ft.border_radius.all(5), - on_click=confirm), - ft.Container(content=ft.Text("Cancel", - size=self.default_font_size, - weight=ft.FontWeight.BOLD), - height=self.default_button_size, - width=self.default_button_size * 2.5, - alignment=ft.alignment.center, - border=ft.border.all(1, 'black'), - border_radius=ft.border_radius.all(5), - on_click=cancel), - ]) - - else: # nothing to void - - dlg = ft.AlertDialog( - title=ft.Text("No Transaction"), - content=ft.Text("You do not have a transaction open currently.", size=20), - actions=[ - ft.Container(content=ft.Text("Close", - size=self.default_font_size, - weight=ft.FontWeight.BOLD), - height=self.default_button_size, - width=self.default_button_size * 2.5, - alignment=ft.alignment.center, - border=ft.border.all(1, 'black'), - border_radius=ft.border_radius.all(5), - on_click=cancel), - ]) + dlg = ft.AlertDialog( + title=ft.Text("Confirm VOID"), + content=ft.Text("Really VOID this transaction?"), + actions=[ + ft.Container(content=ft.Text("Yes, VOID", + size=self.default_font_size, + color='black', + weight=ft.FontWeight.BOLD), + height=self.default_button_size, + width=self.default_button_size * 2.5, + alignment=ft.alignment.center, + bgcolor='red', + border=ft.border.all(1, 'black'), + border_radius=ft.border_radius.all(5), + on_click=confirm), + ft.Container(content=ft.Text("Cancel", + size=self.default_font_size, + weight=ft.FontWeight.BOLD), + height=self.default_button_size, + width=self.default_button_size * 2.5, + alignment=ft.alignment.center, + border=ft.border.all(1, 'black'), + border_radius=ft.border_radius.all(5), + on_click=cancel), + ]) self.page.dialog = dlg dlg.open = True