700 lines
23 KiB
Python
700 lines
23 KiB
Python
from __future__ import annotations
|
|
|
|
import re
|
|
import sys
|
|
from select import select
|
|
from typing import TYPE_CHECKING, Any, Literal, Self, TypeVar
|
|
|
|
from dibbler.lib.helpers import (
|
|
argmax,
|
|
guess_data_type,
|
|
search_product,
|
|
search_user,
|
|
)
|
|
from dibbler.models import Product, User
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable, Iterable
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
exit_commands: list[str] = ["exit", "abort", "quit", "bye", "eat flaming death", "q"]
|
|
help_commands: list[str] = ["help", "?"]
|
|
context_commands: list[str] = ["what", "??"]
|
|
local_help_commands: list[str] = ["help!", "???"]
|
|
|
|
|
|
class ExitMenuException(Exception):
|
|
pass
|
|
|
|
|
|
MenuItemType = TypeVar("MenuItemType", bound="Menu")
|
|
|
|
|
|
class Menu:
|
|
name: str
|
|
sql_session: Session
|
|
items: list[Menu | tuple[MenuItemType, str] | str]
|
|
prompt: str | None
|
|
end_prompt: str | None
|
|
return_index: bool
|
|
exit_msg: str | None
|
|
exit_confirm_msg: str | None
|
|
exit_disallowed_msg: str | None
|
|
help_text: str | None
|
|
context: str | None
|
|
|
|
def __init__(
|
|
self,
|
|
name: str,
|
|
sql_session: Session,
|
|
items: list[Self | tuple[MenuItemType, str] | str] | None = None,
|
|
prompt: str | None = None,
|
|
end_prompt: str | None = "> ",
|
|
return_index: bool = True,
|
|
exit_msg: str | None = None,
|
|
exit_confirm_msg: str | None = None,
|
|
exit_disallowed_msg: str | None = None,
|
|
help_text: str | None = None,
|
|
) -> None:
|
|
self.name: str = name
|
|
self.sql_session: Session = sql_session
|
|
self.items = items if items is not None else []
|
|
self.prompt = prompt
|
|
self.end_prompt = end_prompt
|
|
self.return_index = return_index
|
|
self.exit_msg = exit_msg
|
|
self.exit_confirm_msg = exit_confirm_msg
|
|
self.exit_disallowed_msg = exit_disallowed_msg
|
|
self.help_text = help_text
|
|
self.context = None
|
|
|
|
assert name is not None
|
|
assert self.sql_session is not None
|
|
|
|
def exit_menu(self) -> None:
|
|
if self.exit_disallowed_msg is not None:
|
|
print(self.exit_disallowed_msg)
|
|
return
|
|
if self.exit_confirm_msg is not None:
|
|
if not self.confirm(self.exit_confirm_msg, default=True):
|
|
return
|
|
raise ExitMenuException()
|
|
|
|
def at_exit(self) -> None:
|
|
if self.exit_msg:
|
|
print(self.exit_msg)
|
|
|
|
def set_context(
|
|
self,
|
|
string: str | None,
|
|
display: bool = True,
|
|
) -> None:
|
|
self.context = string
|
|
if self.context is not None and display:
|
|
print(self.context)
|
|
|
|
def add_to_context(self, string: str) -> None:
|
|
if self.context is not None:
|
|
self.context += string
|
|
else:
|
|
self.context = string
|
|
|
|
def printc(self, string: str) -> None:
|
|
print(string)
|
|
if self.context is None:
|
|
self.context = string
|
|
else:
|
|
self.context += "\n" + string
|
|
|
|
def show_context(self) -> None:
|
|
print(self.header())
|
|
if self.context is not None:
|
|
print(self.context)
|
|
|
|
def item_is_submenu(self, i: int) -> bool:
|
|
return isinstance(self.items[i], Menu)
|
|
|
|
def item_name(self, i: int) -> str:
|
|
if self.item_is_submenu(i):
|
|
return self.items[i].name
|
|
if isinstance(self.items[i], tuple):
|
|
return self.items[i][1]
|
|
return self.items[i]
|
|
|
|
def item_value(self, i: int) -> MenuItemType | int:
|
|
if isinstance(self.items[i], tuple):
|
|
return self.items[i][0]
|
|
if self.return_index:
|
|
return i
|
|
return self.items[i]
|
|
|
|
def input_str(
|
|
self,
|
|
prompt: str | None = None,
|
|
end_prompt: str | None = None,
|
|
regex: str | None = None,
|
|
length_range: tuple[int | None, int | None] = (None, None),
|
|
empty_string_is_none: bool = False,
|
|
timeout: int | None = None,
|
|
default: str | None = None,
|
|
) -> str | None:
|
|
if prompt is None:
|
|
prompt = self.prompt if self.prompt is not None else ""
|
|
if default is not None:
|
|
prompt += f" [{default}]"
|
|
if end_prompt is not None:
|
|
prompt += end_prompt
|
|
elif self.end_prompt is not None:
|
|
prompt += self.end_prompt
|
|
else:
|
|
prompt += " "
|
|
while True:
|
|
try:
|
|
if timeout:
|
|
# assuming line buffering
|
|
sys.stdout.write(prompt)
|
|
sys.stdout.flush()
|
|
rlist, _, _ = select([sys.stdin], [], [], timeout)
|
|
if not rlist:
|
|
# timeout occurred, simulate empty line
|
|
result = ""
|
|
else:
|
|
result = input(prompt).strip()
|
|
else:
|
|
result = input(prompt).strip()
|
|
except EOFError:
|
|
print("quit")
|
|
self.exit_menu()
|
|
continue
|
|
if result in exit_commands:
|
|
self.exit_menu()
|
|
continue
|
|
if result in help_commands:
|
|
self.general_help()
|
|
continue
|
|
if result in local_help_commands:
|
|
self.local_help()
|
|
continue
|
|
if result in context_commands:
|
|
self.show_context()
|
|
continue
|
|
if self.special_input_options(result):
|
|
continue
|
|
if empty_string_is_none and result == "":
|
|
return None
|
|
if default is not None and result == "":
|
|
return default
|
|
if regex is not None and not re.match(regex + "$", result):
|
|
print(f'Value must match regular expression "{regex}"')
|
|
continue
|
|
if length_range != (None, None):
|
|
length = len(result)
|
|
if (length_range[0] and length < length_range[0]) or (
|
|
length_range[1] and length > length_range[1]
|
|
):
|
|
if length_range[0] and length_range[1]:
|
|
print(
|
|
f"Value must have length in range [{length_range[0]:d}, {length_range[1]:d}]",
|
|
)
|
|
elif length_range[0]:
|
|
print(f"Value must have length at least {length_range[0]:d}")
|
|
else:
|
|
print(f"Value must have length at most {length_range[1]:d}")
|
|
continue
|
|
return result
|
|
|
|
def special_input_options(self, result) -> bool:
|
|
"""
|
|
Handles special, magic input for input_str
|
|
|
|
Override this in subclasses to implement magic menu
|
|
choices. Return True if str was some valid magic menu
|
|
choice, False otherwise.
|
|
"""
|
|
return False
|
|
|
|
def special_input_choice(self, in_str: str) -> bool:
|
|
"""
|
|
Handle choices which are not simply menu items.
|
|
|
|
Override this in subclasses to implement magic menu
|
|
choices. Return True if str was some valid magic menu
|
|
choice, False otherwise.
|
|
"""
|
|
return False
|
|
|
|
def input_choice(
|
|
self,
|
|
number_of_choices: int,
|
|
prompt: str | None = None,
|
|
end_prompt: str | None = None,
|
|
) -> int:
|
|
while True:
|
|
result = self.input_str(prompt, end_prompt)
|
|
assert result is not None
|
|
if result == "":
|
|
print("Please enter something")
|
|
else:
|
|
if result.isdigit():
|
|
choice = int(result)
|
|
if choice == 0 and number_of_choices >= 10:
|
|
return 10
|
|
if 0 < choice <= number_of_choices:
|
|
return choice
|
|
if not self.special_input_choice(result):
|
|
self.invalid_menu_choice(result)
|
|
|
|
def invalid_menu_choice(self, in_str: str) -> None:
|
|
print("Please enter a valid choice.")
|
|
|
|
def input_int(
|
|
self,
|
|
prompt: str,
|
|
minimum: int | None = None,
|
|
maximum: int | None = None,
|
|
null_allowed: bool = False,
|
|
zero_allowed: bool = True,
|
|
default: int | None = None,
|
|
) -> int | Literal[False]:
|
|
if minimum is not None and maximum is not None:
|
|
end_prompt = f"({minimum}-{maximum})>"
|
|
elif minimum is not None:
|
|
end_prompt = f"(>{minimum})>"
|
|
elif maximum is not None:
|
|
end_prompt = f"(<{maximum})>"
|
|
else:
|
|
end_prompt = ""
|
|
|
|
while True:
|
|
result = self.input_str(
|
|
prompt + end_prompt,
|
|
default=str(default) if default is not None else None,
|
|
)
|
|
assert result is not None
|
|
if result == "" and null_allowed:
|
|
return False
|
|
try:
|
|
value = int(result)
|
|
if minimum is not None and value < minimum:
|
|
print(f"Value must be at least {minimum:d}")
|
|
continue
|
|
if maximum is not None and value > maximum:
|
|
print(f"Value must be at most {maximum:d}")
|
|
continue
|
|
if not zero_allowed and value == 0:
|
|
print("Value cannot be zero")
|
|
continue
|
|
return value
|
|
except ValueError:
|
|
print("Please enter an integer")
|
|
|
|
def input_user(
|
|
self,
|
|
prompt: str | None = None,
|
|
end_prompt: str | None = None,
|
|
) -> User:
|
|
user = None
|
|
while user is None:
|
|
search_string = self.input_str(prompt, end_prompt)
|
|
assert search_string is not None
|
|
user = self.retrieve_user(search_string)
|
|
return user
|
|
|
|
def retrieve_user(self, search_str: str) -> User | None:
|
|
return self.search_ui(search_user, search_str, "user")
|
|
|
|
def input_product(
|
|
self,
|
|
prompt: str | None = None,
|
|
end_prompt: str | None = None,
|
|
) -> Product:
|
|
product = None
|
|
while product is None:
|
|
search_string = self.input_str(prompt, end_prompt)
|
|
assert search_string is not None
|
|
product = self.retrieve_product(search_string)
|
|
return product
|
|
|
|
def retrieve_product(self, search_str: str) -> Product | None:
|
|
return self.search_ui(search_product, search_str, "product")
|
|
|
|
def input_thing(
|
|
self,
|
|
prompt: str | None = None,
|
|
end_prompt: str | None = None,
|
|
permitted_things: Iterable[str] = ("user", "product"),
|
|
add_nonexisting: Iterable[str] = (),
|
|
empty_input_permitted: bool = False,
|
|
find_hidden_products: bool = True,
|
|
) -> User | Product | None:
|
|
result = None
|
|
while result is None:
|
|
search_str = self.input_str(prompt, end_prompt)
|
|
assert search_str is not None
|
|
if search_str == "" and empty_input_permitted:
|
|
return None
|
|
result = self.search_for_thing(
|
|
search_str,
|
|
permitted_things,
|
|
add_nonexisting,
|
|
find_hidden_products,
|
|
)
|
|
return result
|
|
|
|
def input_multiple(
|
|
self,
|
|
prompt: str | None = None,
|
|
end_prompt: str | None = None,
|
|
permitted_things: Iterable[str] = ("user", "product"),
|
|
add_nonexisting: Iterable[str] = (),
|
|
empty_input_permitted: bool = False,
|
|
find_hidden_products: bool = True,
|
|
) -> tuple[User | Product, int] | None:
|
|
result = None
|
|
num = 0
|
|
while result is None:
|
|
search_str = self.input_str(prompt, end_prompt)
|
|
assert search_str is not None
|
|
search_lst = search_str.split(" ")
|
|
if search_str == "" and empty_input_permitted:
|
|
return None
|
|
result = self.search_for_thing(
|
|
search_str,
|
|
permitted_things,
|
|
add_nonexisting,
|
|
find_hidden_products,
|
|
)
|
|
num = 1
|
|
|
|
if (result is None) and (len(search_lst) > 1):
|
|
print('Interpreting input as "<number> <product>"')
|
|
try:
|
|
num = int(search_lst[0])
|
|
result = self.search_for_thing(
|
|
" ".join(search_lst[1:]),
|
|
permitted_things,
|
|
add_nonexisting,
|
|
find_hidden_products,
|
|
)
|
|
# Her kan det legges inn en except ValueError,
|
|
# men da blir det fort mye plaging av brukeren
|
|
except Exception as e:
|
|
print(e)
|
|
return result, num
|
|
|
|
def search_for_thing(
|
|
self,
|
|
search_str: str,
|
|
permitted_things: Iterable[str] = ("user", "product"),
|
|
add_non_existing: Iterable[str] = (),
|
|
find_hidden_products: bool = True,
|
|
) -> User | Product | None:
|
|
search_fun = {
|
|
"user": search_user,
|
|
"product": search_product,
|
|
}
|
|
results = {}
|
|
result_values = {}
|
|
for thing in permitted_things:
|
|
results[thing] = search_fun[thing](search_str, self.sql_session, find_hidden_products)
|
|
result_values[thing] = self.search_result_value(results[thing])
|
|
selected_thing = argmax(result_values)
|
|
if not results[selected_thing]:
|
|
thing_for_type = {
|
|
"card": "user",
|
|
"username": "user",
|
|
"bar_code": "product",
|
|
"rfid": "rfid",
|
|
}
|
|
type_guess = guess_data_type(search_str)
|
|
if type_guess is not None and thing_for_type[type_guess] in add_non_existing:
|
|
return self.search_add(search_str)
|
|
# print('No match found for "%s".' % search_str)
|
|
return None
|
|
return self.search_ui2(
|
|
search_str,
|
|
results[selected_thing],
|
|
selected_thing,
|
|
)
|
|
|
|
@staticmethod
|
|
def search_result_value(result) -> Literal[0, 1, 2, 3]:
|
|
if result is None:
|
|
return 0
|
|
if not isinstance(result, list):
|
|
return 3
|
|
if len(result) == 0:
|
|
return 0
|
|
if len(result) == 1:
|
|
return 2
|
|
return 1
|
|
|
|
def search_add(self, string: str) -> User | None:
|
|
type_guess = guess_data_type(string)
|
|
if type_guess == "username":
|
|
print(f'"{string}" looks like a username, but no such user exists.')
|
|
if self.confirm(f"Create user {string}?"):
|
|
user = User(string, None)
|
|
self.sql_session.add(user)
|
|
return user
|
|
return None
|
|
if type_guess == "card":
|
|
selector = Selector(
|
|
f'"{string}" looks like a card number, but no user with that card number exists.',
|
|
self.sql_session,
|
|
[
|
|
("create", f"Create user with card number {string}"),
|
|
("set", f"Set card number of an existing user to {string}"),
|
|
],
|
|
)
|
|
selection = selector.execute()
|
|
if selection == "create":
|
|
username = self.input_str(
|
|
prompt="Username for new user (should be same as PVV username)",
|
|
end_prompt=None,
|
|
regex=User.name_re,
|
|
length_range=(1, 10),
|
|
)
|
|
assert username is not None
|
|
user = User(username, string)
|
|
self.sql_session.add(user)
|
|
return user
|
|
if selection == "set":
|
|
user = self.input_user("User to set card number for")
|
|
old_card = user.card
|
|
user.card = string
|
|
print(f"Card number of {user.name} set to {string} (was {old_card})")
|
|
return user
|
|
return None
|
|
if type_guess == "bar_code":
|
|
print(f'"{string}" looks like the bar code for a product, but no such product exists.')
|
|
return None
|
|
|
|
def search_ui(
|
|
self,
|
|
search_fun: Callable[[str, Session], list[Any] | Any],
|
|
search_str: str,
|
|
thing: str,
|
|
) -> Any:
|
|
result = search_fun(search_str, self.sql_session)
|
|
return self.search_ui2(search_str, result, thing)
|
|
|
|
def search_ui2(
|
|
self,
|
|
search_str: str,
|
|
result: list[Any] | Any,
|
|
thing: str,
|
|
) -> Any:
|
|
if not isinstance(result, list):
|
|
return result
|
|
if len(result) == 0:
|
|
print(f'No {thing}s matching "{search_str}"')
|
|
return None
|
|
if len(result) == 1:
|
|
msg = f'One {thing} matching "{search_str}": {str(result[0])}. Use this?'
|
|
if self.confirm(msg, default=True):
|
|
return result[0]
|
|
return None
|
|
limit = 9
|
|
if len(result) > limit:
|
|
select_header = (
|
|
f'{len(result):d} {thing}s matching "{search_str}"; showing first {limit:d}'
|
|
)
|
|
select_items = result[:limit]
|
|
else:
|
|
select_header = f'{len(result):d} {thing}s matching "{search_str}"'
|
|
select_items = result
|
|
selector = Selector(
|
|
select_header,
|
|
self.sql_session,
|
|
items=select_items,
|
|
return_index=False,
|
|
)
|
|
return selector.execute()
|
|
|
|
def confirm(
|
|
self,
|
|
prompt: str,
|
|
end_prompt: str | None = None,
|
|
default: bool | None = None,
|
|
timeout: int | None = None,
|
|
) -> bool:
|
|
return ConfirmMenu(
|
|
self.sql_session,
|
|
prompt,
|
|
end_prompt=None,
|
|
default=default,
|
|
timeout=timeout,
|
|
).execute()
|
|
|
|
def header(self) -> str:
|
|
return f"[{self.name}]"
|
|
|
|
def print_header(self) -> None:
|
|
print("")
|
|
print(self.header())
|
|
|
|
def pause(self) -> None:
|
|
self.input_str(".", end_prompt="")
|
|
|
|
@staticmethod
|
|
def general_help() -> None:
|
|
print(
|
|
"""
|
|
DIBBLER HELP
|
|
|
|
The following commands are recognized (almost) everywhere:
|
|
|
|
help, ? -- display this help
|
|
what, ?? -- redisplay the current context
|
|
help!, ??? -- display context-specific help (if any)
|
|
faq -- display frequently asked questions (with answers)
|
|
exit, quit, etc. -- exit from the current menu
|
|
|
|
When prompted for a user, you can type (parts of) the user name or
|
|
card number. When prompted for a product, you can type (parts of) the
|
|
product name or barcode.
|
|
|
|
About payment and "credit": When paying for something, use either
|
|
Dibbler or the good old money box -- never both at the same time.
|
|
Dibbler keeps track of a "credit" for each user, which is the amount
|
|
of money PVVVV owes the user. This value decreases with the
|
|
appropriate amount when you register a purchase, and you may increase
|
|
it by putting money in the box and using the "Adjust credit" menu.
|
|
""",
|
|
)
|
|
|
|
def local_help(self) -> None:
|
|
if self.help_text is None:
|
|
print("no help here")
|
|
else:
|
|
print("")
|
|
print(f"Help for {self.header()}:")
|
|
print(self.help_text)
|
|
|
|
def execute(self, **_kwargs) -> MenuItemType | int | None:
|
|
self.set_context(None)
|
|
try:
|
|
return self._execute(**_kwargs)
|
|
except ExitMenuException:
|
|
self.at_exit()
|
|
return None
|
|
|
|
def _execute(self, **_kwargs) -> MenuItemType | int | None:
|
|
while True:
|
|
self.print_header()
|
|
self.set_context(None)
|
|
if len(self.items) == 0:
|
|
self.printc("(empty menu)")
|
|
self.pause()
|
|
return None
|
|
for i in range(len(self.items)):
|
|
length = len(str(len(self.items)))
|
|
self.printc(f"{i + 1:>{length}} ) {self.item_name(i)}")
|
|
item_i = self.input_choice(len(self.items)) - 1
|
|
if self.item_is_submenu(item_i):
|
|
self.items[item_i].execute()
|
|
else:
|
|
return self.item_value(item_i)
|
|
|
|
|
|
class MessageMenu(Menu):
|
|
message: str
|
|
pause_after_message: bool
|
|
|
|
def __init__(
|
|
self,
|
|
name: str,
|
|
message: str,
|
|
sql_session: Session,
|
|
pause_after_message: bool = True,
|
|
) -> None:
|
|
super().__init__(name, sql_session)
|
|
self.message = message.strip()
|
|
self.pause_after_message = pause_after_message
|
|
|
|
def _execute(self, **_kwargs) -> None:
|
|
self.print_header()
|
|
print("")
|
|
print(self.message)
|
|
if self.pause_after_message:
|
|
self.pause()
|
|
|
|
|
|
class ConfirmMenu(Menu):
|
|
def __init__(
|
|
self,
|
|
sql_session: Session,
|
|
prompt: str = "confirm? ",
|
|
end_prompt: str | None = ": ",
|
|
default: bool | None = None,
|
|
timeout: int | None = 0,
|
|
) -> None:
|
|
super().__init__(
|
|
"question",
|
|
sql_session,
|
|
prompt=prompt,
|
|
end_prompt=end_prompt,
|
|
exit_disallowed_msg="Please answer yes or no",
|
|
)
|
|
self.default = default
|
|
self.timeout = timeout
|
|
|
|
def _execute(self, **_kwargs) -> bool:
|
|
options = {True: "[Y/n]", False: "[y/N]", None: "[y/n]"}[self.default]
|
|
while True:
|
|
result = self.input_str(
|
|
f"{self.prompt} {options}",
|
|
end_prompt=": ",
|
|
timeout=self.timeout,
|
|
)
|
|
result = result.lower().strip()
|
|
if result in ["y", "yes"]:
|
|
return True
|
|
if result in ["n", "no"]:
|
|
return False
|
|
if self.default is not None and result == "":
|
|
return self.default
|
|
print("Please answer yes or no")
|
|
|
|
|
|
class Selector(Menu):
|
|
def __init__(
|
|
self,
|
|
name: str,
|
|
sql_session: Session,
|
|
items: list[Self | tuple[MenuItemType, str] | str] | None = None,
|
|
prompt: str | None = "select",
|
|
return_index: bool = True,
|
|
exit_msg: str | None = None,
|
|
exit_confirm_msg: str | None = None,
|
|
help_text: str | None = None,
|
|
) -> None:
|
|
if items is None:
|
|
items = []
|
|
super().__init__(
|
|
name,
|
|
sql_session,
|
|
items,
|
|
prompt,
|
|
return_index=return_index,
|
|
exit_msg=exit_msg,
|
|
help_text=help_text,
|
|
)
|
|
|
|
def header(self) -> str:
|
|
return self.name
|
|
|
|
def print_header(self) -> None:
|
|
print(self.header())
|
|
|
|
def local_help(self) -> None:
|
|
if self.help_text is None:
|
|
print("This is a selection menu. Enter one of the listed numbers, or")
|
|
print("'exit' to go out and do something else.")
|
|
else:
|
|
print("")
|
|
print(f"Help for selector ({self.name}):")
|
|
print(self.help_text)
|