Move login form to separate control

so we can hopefully use that for "manager override" ..?  we'll see
This commit is contained in:
Lance Edgar 2023-10-05 22:22:13 -05:00
parent 9340e0d1bc
commit 21f848cc82
4 changed files with 322 additions and 272 deletions

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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()

View file

@ -87,15 +87,13 @@ class WuttaView(ft.View):
kwargs.setdefault('height', 100) kwargs.setdefault('height', 100)
return ft.Image(src=logo, **kwargs) 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', self.page.snack_bar = ft.SnackBar(ft.Text(text, color='black',
size=20, size=20,
weight=ft.FontWeight.BOLD), weight=ft.FontWeight.BOLD),
bgcolor=bgcolor, bgcolor=bgcolor,
duration=1500) duration=1500)
self.page.snack_bar.open = True self.page.snack_bar.open = True
if update:
self.page.update()
class WuttaViewContainer(ft.Container): class WuttaViewContainer(ft.Container):

View file

@ -27,8 +27,7 @@ WuttaPOS - login view
import flet as ft import flet as ft
from .base import WuttaView from .base import WuttaView
from wuttapos.controls.keyboard import WuttaKeyboard from wuttapos.controls.loginform import WuttaLoginForm
from wuttapos.controls.tenkey import WuttaTenkeyMenu
class LoginView(WuttaView): class LoginView(WuttaView):
@ -36,31 +35,9 @@ class LoginView(WuttaView):
Main POS view for WuttaPOS 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): def build_controls(self):
title = self.app.get_title() title = self.app.get_title()
controls = [ controls = [
ft.Row( ft.Row(
[self.make_logo_image(height=200)], [self.make_logo_image(height=200)],
@ -73,33 +50,12 @@ class LoginView(WuttaView):
ft.Row(), ft.Row(),
ft.Row(), 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 [ return [
self.build_header(), self.build_header(),
ft.Column(controls=controls, ft.Column(controls=controls,
@ -107,179 +63,21 @@ class LoginView(WuttaView):
alignment=ft.MainAxisAlignment.CENTER), alignment=ft.MainAxisAlignment.CENTER),
] ]
def build_login_form(self): def login_failure(self, e):
form_fields = [] self.show_snackbar("Login failed!", bgcolor='yellow')
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}"
self.page.update() self.page.update()
def tenkey_enter(self, e): def authz_failure(self, user, user_display):
if self.show_username and self.focused is self.username: self.show_snackbar(f"User not allowed to ring sales: {user_display}",
self.username_submit() bgcolor='yellow')
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()
self.page.update() self.page.update()
def attempt_login(self, e=None): def login_success(self, user, user_display):
if self.show_username and not self.username.value: self.page.session.set('user_uuid', user.uuid)
self.username.focus() self.page.session.set('user_display', user_display)
return False
if not self.password.value:
self.password.focus()
return False
session = self.app.make_session() # TODO: hacky but works for now
auth = self.app.get_auth_handler() if not self.config.production():
user = auth.authenticate_user(session, self.page.client_storage.set('user_uuid', user.uuid)
self.username.value if self.show_username else None,
self.password.value)
user_display = str(user) if user else None
session.close()
if user: self.page.go('/pos')
# 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

View file

@ -142,7 +142,8 @@ class POSView(WuttaView):
session.commit() session.commit()
if row.row_type == self.enum.POS_ROW_TYPE_BADPRICE: 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: else:
self.add_row_item(row) self.add_row_item(row)
@ -864,12 +865,12 @@ class POSView(WuttaView):
def adjust_price_click(self, e): def adjust_price_click(self, e):
if not len(self.items.controls): 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() self.reset()
return return
if not self.selected_item: 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.main_input.focus()
self.page.update() self.page.update()
return return
@ -894,7 +895,7 @@ class POSView(WuttaView):
try: try:
price = decimal.Decimal(price_override.value) price = decimal.Decimal(price_override.value)
except decimal.InvalidOperation: 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.main_input.focus()
self.page.update() self.page.update()
return return
@ -1095,52 +1096,37 @@ class POSView(WuttaView):
batch = self.get_current_batch(session, create=False) batch = self.get_current_batch(session, create=False)
session.close() 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 dlg = ft.AlertDialog(
target = 'LINE' if self.selected_item else 'TXN' title=ft.Text("Confirm VOID"),
dlg = ft.AlertDialog( content=ft.Text("Really VOID this transaction?"),
title=ft.Text("Confirm VOID"), actions=[
content=ft.Text(f"Really VOID {target}?"), ft.Container(content=ft.Text("Yes, VOID",
actions=[ size=self.default_font_size,
ft.Container(content=ft.Text(f"VOID {target}", color='black',
size=self.default_font_size, weight=ft.FontWeight.BOLD),
color='black', height=self.default_button_size,
weight=ft.FontWeight.BOLD), width=self.default_button_size * 2.5,
height=self.default_button_size, alignment=ft.alignment.center,
width=self.default_button_size * 2.5, bgcolor='red',
alignment=ft.alignment.center, border=ft.border.all(1, 'black'),
bgcolor='red', border_radius=ft.border_radius.all(5),
border=ft.border.all(1, 'black'), on_click=confirm),
border_radius=ft.border_radius.all(5), ft.Container(content=ft.Text("Cancel",
on_click=confirm), size=self.default_font_size,
ft.Container(content=ft.Text("Cancel", weight=ft.FontWeight.BOLD),
size=self.default_font_size, height=self.default_button_size,
weight=ft.FontWeight.BOLD), width=self.default_button_size * 2.5,
height=self.default_button_size, alignment=ft.alignment.center,
width=self.default_button_size * 2.5, border=ft.border.all(1, 'black'),
alignment=ft.alignment.center, border_radius=ft.border_radius.all(5),
border=ft.border.all(1, 'black'), on_click=cancel),
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),
])
self.page.dialog = dlg self.page.dialog = dlg
dlg.open = True dlg.open = True