Added an error catching decorator and finished account deposit

This commit is contained in:
Thastertyn 2025-02-06 13:19:41 +01:00
parent e28e1027d0
commit 7a7417ac1c
13 changed files with 151 additions and 99 deletions

View File

@ -1,14 +1,20 @@
# ksi # nu (ν)
## Sources ## Sources
### Signal catching ### Signal catching
- [Catch SIGTERM](https://dnmtechs.com/graceful-sigterm-signal-handling-in-python-3-best-practices-and-implementation/) - [Catch SIGTERM](https://dnmtechs.com/graceful-sigterm-signal-handling-in-python-3-best-practices-and-implementation/)
- [Get ENUM name from value](https://stackoverflow.com/a/38716384)
- [Windows termination signals](https://stackoverflow.com/a/35792192) - [Windows termination signals](https://stackoverflow.com/a/35792192)
- ~~[Get ENUM name from value](https://stackoverflow.com/a/38716384)~~
Unused because of required compatibility with lower version python (3.9)
### Networking ### Networking
- [Dynamically finding host IP address](https://stackoverflow.com/a/28950776) - [Dynamically finding host IP address](https://stackoverflow.com/a/28950776)
- [IP Regex](https://ihateregex.io/expr/ip/)
### Database ### Database
- [SqlAlchemy session generator](https://stackoverflow.com/a/71053353) - [SqlAlchemy session generator](https://stackoverflow.com/a/71053353)
- [Error handling decorator](https://chatgpt.com/share/67a46109-d38c-8005-ac36-677e6511ddcd)

View File

@ -51,6 +51,7 @@ class BankNode():
signal.signal(signal.SIGBREAK, self.gracefully_exit) signal.signal(signal.SIGBREAK, self.gracefully_exit)
def start(self): def start(self):
try:
for port in range(self.config.scan_port_start, self.config.scan_port_end + 1): for port in range(self.config.scan_port_start, self.config.scan_port_end + 1):
self.logger.debug("Trying port %d", port) self.logger.debug("Trying port %d", port)
try: try:
@ -60,9 +61,18 @@ class BankNode():
except socket.error as e: except socket.error as e:
if e.errno == 98: # Address is in use if e.errno == 98: # Address is in use
self.logger.info("Port %d in use, trying next port", port) self.logger.info("Port %d in use, trying next port", port)
else:
raise
self.logger.error("All ports are in use") self.logger.error("All ports are in use")
self.exit_with_error() self.exit_with_error()
except socket.error as e:
if e.errno == 99: # Cannot assign to requested address
self.logger.critical("Cannot use the IP address %s", self.config.ip)
else:
self.logger.critical("Unknown error: %s", e)
self.exit_with_error()
def __start_server(self, port: int): def __start_server(self, port: int):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as socket_server: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as socket_server:

View File

@ -39,6 +39,9 @@ class CommandHandler:
command = self.registered_commands[request.command_code] command = self.registered_commands[request.command_code]
try: try:
response = command(request, self.config) response = command(request, self.config)
if response is not None:
return f"{request.command_code} {response}" return f"{request.command_code} {response}"
else:
return request.command_code
except DatabaseError as e: except DatabaseError as e:
return f"ER {e.message}" return f"ER {e.message}"

View File

@ -1,14 +1,19 @@
from core import Request, BankNodeConfig from core import Request, BankNodeConfig
from bank_protocol.exceptions import InvalidRequest from bank_protocol.exceptions import InvalidRequest
from services.account_service import deposit_into_account
from utils.constants import MONEY_AMOUNT_MAXIMUM
def account_deposit(request: Request, config: BankNodeConfig): def account_deposit(request: Request, config: BankNodeConfig):
try:
split_body = request.body.split("/") split_body = request.body.split("/")
split_ip = split_body[1].split(" ") split_ip = split_body[1].split(" ")
account = split_body[0] account = split_body[0]
ip = split_ip[0] ip = split_ip[0]
amount = split_ip[1] amount = split_ip[1]
except IndexError as e:
raise InvalidRequest("Invalid request format") from e
if not account.isdigit(): if not account.isdigit():
raise InvalidRequest("Account must be a number") raise InvalidRequest("Account must be a number")
@ -18,5 +23,15 @@ def account_deposit(request: Request, config: BankNodeConfig):
if account_parsed < 10_000 or account_parsed > 99_999: if account_parsed < 10_000 or account_parsed > 99_999:
raise InvalidRequest("Account number out of range") raise InvalidRequest("Account number out of range")
if not amount.isdigit():
raise InvalidRequest("Deposit amount must be a number")
amount_parsed = int(amount)
if amount_parsed > MONEY_AMOUNT_MAXIMUM:
raise InvalidRequest("Cannot deposit this much")
deposit_into_account(account_parsed, amount_parsed)
__all__ = ["account_deposit"] __all__ = ["account_deposit"]

View File

@ -1,12 +1,10 @@
from typing import Dict from core import Request, Response, BankNodeConfig
from core import Request, Response
from bank_protocol.exceptions import InvalidRequest from bank_protocol.exceptions import InvalidRequest
def bank_code(request: Request, config: Dict) -> Response: def bank_code(request: Request, config: BankNodeConfig) -> Response:
if request.body is not None: if request.body is not None:
raise InvalidRequest("Incorrect usage") raise InvalidRequest("Incorrect usage")
return config["ip"] return config.ip
__all__ = ["bank_code"] __all__ = ["bank_code"]

View File

@ -1,7 +1,12 @@
from core.request import Request from core import Request, Response
from bank_protocol.exceptions import InvalidRequest
from services.account_service import get_account_count
def bank_number_of_clients(request: Request): def bank_number_of_clients(request: Request, _) -> Response:
pass if request.body is not None:
raise InvalidRequest("Incorrect usage")
number_of_clients = get_account_count()
return number_of_clients
__all__ = ["bank_number_of_clients"] __all__ = ["bank_number_of_clients"]

View File

@ -1,7 +1,13 @@
from core.request import Request from core import Request, Response
from bank_protocol.exceptions import InvalidRequest
from services.account_service import get_total_balance
def bank_total_amount(request: Request): def bank_total_amount(request: Request, _):
pass if request.body is not None:
raise InvalidRequest("Incorrect usage")
total_balace = get_total_balance()
return total_balace
__all__ = ["bank_total_amount"] __all__ = ["bank_total_amount"]

View File

@ -48,8 +48,7 @@ class BankNodeConfig:
# IP validation # IP validation
if not re.match(IP_REGEX, ip): if not re.match(IP_REGEX, ip):
self.logger.error("Invalid IP in configuration") raise ConfigError(f"Invalid IP {ip} in configuration")
raise ConfigError("Invalid IP in configuration")
self.used_port: int self.used_port: int
self.ip = ip self.ip = ip

View File

@ -4,9 +4,9 @@ from contextlib import contextmanager
from sqlalchemy.orm import sessionmaker, Session from sqlalchemy.orm import sessionmaker, Session
from sqlalchemy import create_engine, text from sqlalchemy import create_engine, text
from sqlalchemy.exc import DatabaseError from sqlalchemy.exc import DatabaseError as SqlAlchemyDatabaseError
from database.exceptions import DatabaseConnectionError from database.exceptions import DatabaseError
from models.base_model import Base from models.base_model import Base
@ -45,9 +45,9 @@ class DatabaseManager():
connection.execute(text("select 1")) connection.execute(text("select 1"))
self.logger.debug("Database connection successful") self.logger.debug("Database connection successful")
return True return True
except DatabaseError as e: except SqlAlchemyDatabaseError as e:
self.logger.critical("Database connection failed: %s", e) self.logger.critical("Database connection failed: %s", e)
raise DatabaseConnectionError("Database connection failed") from e raise DatabaseError("Database connection failed", DatabaseError.CONNECTION_ERROR) from e
return False return False

View File

@ -0,0 +1,13 @@
from functools import wraps
from sqlalchemy.exc import DatabaseError as SqlAlchemyDatabaseError
from database.exceptions import DatabaseError
def handle_database_errors(func):
@wraps(func)
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except SqlAlchemyDatabaseError as e:
raise DatabaseError(str(e), -1) from e
return wrapper

View File

@ -1,52 +1,24 @@
class DatabaseError(Exception): class DatabaseError(Exception):
def __init__(self, message: str): # Inspired by OSError which also uses errno's
# It's a better approach than using a class for each error
UNKNOWN_ERROR = -1
CONNECTION_ERROR = 1
EMPTY_CONFIG = 2
DUPLICATE_ENTRY = 3
NONEXISTENT_ACCOUNT = 4
OUT_OF_ACCOUNT_SPACE = 5
INSUFFICIENT_BALANCE = 6
INVALID_OPERATION = 7
def __init__(self, message: str, errno: int, **kwargs):
super().__init__(message) super().__init__(message)
self.message = message self.message = message
self.errno = errno
for key, value in kwargs.items():
setattr(self, key, value)
class DatabaseConnectionError(DatabaseError): __all__ = ["DatabaseError"]
def __init__(self, message: str):
super().__init__(message)
self.message = message
class EmptyDatabaseConfigError(Exception):
def __init__(self, message: str, config_name: str):
super().__init__(message)
self.message = message
self.config_name = config_name
class DuplicateEntryError(DatabaseError):
def __init__(self, duplicate_entry_name: str, message: str):
super().__init__(message)
self.duplicate_entry_name = duplicate_entry_name
self.message = message
class NonexistentAccountError(DatabaseError):
def __init__(self, message: str):
super().__init__(message)
self.message = message
class OutOfAccountSpaceError(DatabaseError):
def __init__(self, message: str):
super().__init__(message)
self.message = message
class InsufficientBalance(DatabaseError):
def __init__(self, message: str):
super().__init__(message)
self.message = message
class InvalidOperation(DatabaseError):
def __init__(self, message: str):
super().__init__(message)
self.message = message
__all__ = ["DatabaseError", "DatabaseConnectionError", "DuplicateEntryError", "InsufficientBalance", "OutOfAccountSpaceError", "NonexistentAccountError", "EmptyDatabaseConfigError", "InvalidOperation"]

View File

@ -2,21 +2,24 @@ from sqlalchemy import func
from models import Account from models import Account
from database import DatabaseManager from database import DatabaseManager
from database.exceptions import OutOfAccountSpaceError, NonexistentAccountError, InsufficientBalance, InvalidOperation from database.exceptions import DatabaseError
from database.exception_catcher_decorator import handle_database_errors
from utils.constants import MIN_ACCOUNT_NUMBER, MAX_ACCOUNT_NUMBER from utils.constants import MIN_ACCOUNT_NUMBER, MAX_ACCOUNT_NUMBER
@handle_database_errors
def get_next_id() -> int: def get_next_id() -> int:
with DatabaseManager.get_session() as session: with DatabaseManager.get_session() as session:
current_max_id = session.query(func.max(Account.account_number)).scalar() current_max_id = session.query(func.max(Account.account_number)).scalar()
current_max_id = current_max_id + 1 if current_max_id is not None else MIN_ACCOUNT_NUMBER current_max_id = current_max_id + 1 if current_max_id is not None else MIN_ACCOUNT_NUMBER
if current_max_id > MAX_ACCOUNT_NUMBER: if current_max_id > MAX_ACCOUNT_NUMBER:
raise OutOfAccountSpaceError("Too many users already exist, cannot open new account") raise DatabaseError("Too many users already exist, cannot open new account", DatabaseError.OUT_OF_ACCOUNT_SPACE)
return current_max_id return current_max_id
@handle_database_errors
def create_account() -> int: def create_account() -> int:
new_id = get_next_id() new_id = get_next_id()
@ -27,47 +30,68 @@ def create_account() -> int:
return new_id return new_id
@handle_database_errors
def get_account_balance(account_number: int) -> int: def get_account_balance(account_number: int) -> int:
with DatabaseManager.get_session() as session: with DatabaseManager.get_session() as session:
account: Account = session.query(Account).where(Account.account_number == account_number).one_or_none() account: Account = session.query(Account).where(Account.account_number == account_number).one_or_none()
if account is None: if account is None:
raise NonexistentAccountError(f"Account with number {account_number} doesn't exist") raise DatabaseError(f"Account with number {account_number} doesn't exist", DatabaseError.NONEXISTENT_ACCOUNT)
return account.balance return account.balance
@handle_database_errors
def withdraw_from_account(account_number: int, amount: int): def withdraw_from_account(account_number: int, amount: int):
modify_balance(account_number, amount, True)
def deposit_into_account(account_number: int, amount: int):
modify_balance(account_number, amount, False) modify_balance(account_number, amount, False)
def modify_balance(account_number: int, amount: int, subtract: bool): @handle_database_errors
def deposit_into_account(account_number: int, amount: int):
modify_balance(account_number, amount, True)
@handle_database_errors
def modify_balance(account_number: int, amount: int, add: bool):
with DatabaseManager.get_session() as session: with DatabaseManager.get_session() as session:
account: Account = session.query(Account).where(Account.account_number == account_number).one_or_none() account: Account = session.query(Account).where(Account.account_number == account_number).one_or_none()
if account is None: if account is None:
raise NonexistentAccountError(f"Account with number {account_number} doesn't exist") raise DatabaseError(f"Account with number {account_number} doesn't exist", DatabaseError.NONEXISTENT_ACCOUNT)
if subtract: if add:
account.balance += amount account.balance += amount
else: else:
if account.balance - amount < 0: if account.balance - amount < 0:
raise InsufficientBalance("Not enough funds on account to withdraw this much") raise DatabaseError("Not enough funds on account to withdraw this much", DatabaseError.INSUFFICIENT_BALANCE)
account.balance -= amount account.balance -= amount
session.commit() session.commit()
@handle_database_errors
def delete_account(account_number: int): def delete_account(account_number: int):
with DatabaseManager.get_session() as session: with DatabaseManager.get_session() as session:
account: Account = session.query(Account).where(Account.account_number == account_number).one_or_none() account: Account = session.query(Account).where(Account.account_number == account_number).one_or_none()
if account is None: if account is None:
raise NonexistentAccountError(f"Account with number {account_number} doesn't exist") raise DatabaseError(f"Account with number {account_number} doesn't exist", DatabaseError.NONEXISTENT_ACCOUNT)
if account.balance > 0: if account.balance > 0:
raise InvalidOperation("Cannot delete an account with leftover funds") raise DatabaseError("Cannot delete an account with leftover funds", DatabaseError.INVALID_OPERATION)
session.delete(account) session.delete(account)
session.commit() session.commit()
@handle_database_errors
def get_total_balance() -> int:
with DatabaseManager.get_session() as session:
total_sum = session.query(func.sum(Account.balance)).scalar()
return total_sum if total_sum is not None else 0
@handle_database_errors
def get_account_count() -> int:
with DatabaseManager.get_session() as session:
total_sum = session.query(func.count(Account.account_number)).scalar()
return total_sum if total_sum is not None else 0

View File

@ -1,8 +1,9 @@
import re import re
import sys
IP_REGEX = r"^[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}$" IP_REGEX = r"^(\b25[0-5]|\b2[0-4][0-9]|\b[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$"
ACCOUNT_NUMBER_REGEX = r"[0-9]{9}" ACCOUNT_NUMBER_REGEX = r"^(1[0-9]{8})|([2-9][0-9]{8})$"
MONEY_AMOUNT_MAXIMUM = (2 ^ 63) - 1 MONEY_AMOUNT_MAXIMUM = 2**63 - 1
MIN_ACCOUNT_NUMBER = 10_000 MIN_ACCOUNT_NUMBER = 10_000
MAX_ACCOUNT_NUMBER = 99_999 MAX_ACCOUNT_NUMBER = 99_999