Compare commits
10 Commits
a70d052cbf
...
320044f4b8
Author | SHA1 | Date | |
---|---|---|---|
320044f4b8 | |||
0991aad105 | |||
9e62d2517c | |||
1cfc561301 | |||
756d6d3dd4 | |||
da7c809a16 | |||
7a7417ac1c | |||
e28e1027d0 | |||
35a9e64a10 | |||
587756a51b |
@ -8,10 +8,6 @@ RESPONSE_TIMEOUT=5
|
||||
# within this timeframe
|
||||
CLIENT_IDLE_TIMEOUT=60
|
||||
|
||||
# A valid port number
|
||||
# If not provided or invalid, defaults to 65526
|
||||
PORT=65526
|
||||
|
||||
# DEBUG, INFO, WARNING, ERROR, CRITICAL are valid
|
||||
# If an invalid value is provided, the app defaults to INFO
|
||||
VERBOSITY=DEBUG
|
||||
@ -20,3 +16,6 @@ VERBOSITY=DEBUG
|
||||
# Only two port numbers must be provided
|
||||
# Anything else results in an exit
|
||||
SCAN_PORT_RANGE=65525:65535
|
||||
|
||||
# Manually set the IP address
|
||||
# IP=127.0.0.1
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -168,3 +168,4 @@ cython_debug/
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
bank.db
|
103
README.md
103
README.md
@ -1,14 +1,113 @@
|
||||
# ksi
|
||||
# nu (ν)
|
||||
|
||||
# Running
|
||||
|
||||
## Configuration
|
||||
|
||||
The application can be configured using a `.env` file. Here are the available options:
|
||||
|
||||
`RESPONSE_TIMEOUT`
|
||||
|
||||
- Specifies the response timeout in seconds.
|
||||
|
||||
- Default: `5`
|
||||
|
||||
- Example: `RESPONSE_TIMEOUT=5`
|
||||
|
||||
`CLIENT_IDLE_TIMEOUT`
|
||||
|
||||
- Defines the client idle timeout in seconds.
|
||||
|
||||
- Default: `60`
|
||||
|
||||
- Example: `CLIENT_IDLE_TIMEOUT=60`
|
||||
|
||||
`VERBOSITY`
|
||||
|
||||
- Determines the logging verbosity level.
|
||||
|
||||
- Valid values: DEBUG, INFO, WARNING, ERROR, CRITICAL.
|
||||
|
||||
- Default: `INFO`
|
||||
|
||||
- Example: `VERBOSITY=DEBUG`
|
||||
|
||||
`SCAN_PORT_RANGE`
|
||||
|
||||
- Defines the port range to scan as `PORT:PORT` separated by colon (`:`).
|
||||
|
||||
- The ports must be valid and there must be exactly two of them.
|
||||
|
||||
- Example: `SCAN_PORT_RANGE=65525:65535`
|
||||
|
||||
`IP`
|
||||
|
||||
- Manually set the IP address.
|
||||
|
||||
- Example: `IP=127.0.0.1`
|
||||
|
||||
## Running
|
||||
|
||||
The application requires a few dependenices, specified in `requirements.txt`.
|
||||
|
||||
To install these dependencies run
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Upon successful installation, the program is ready to run. The database used is SQLite, hence no SQL structure is needed to be imported, nor are any credentials required.
|
||||
|
||||
To run the app, run
|
||||
|
||||
```bash
|
||||
python src/app.py
|
||||
```
|
||||
|
||||
If the verbosity set in `.env` is `DEBUG`, the program will print many useful descriptions of it's actions together with a detailed location of the action including file, line and process name.
|
||||
|
||||
## Definitions
|
||||
|
||||
The program uses the following protocol over TCP
|
||||
|
||||
| Operation | Request Format | Response Format |
|
||||
| ---------------------- | -------------------------------- | --------------- |
|
||||
| Bank code | BC | BC \<ip\> |
|
||||
| Account create | AC \<account\>/\<ip\> | AC |
|
||||
| Account deposit | AD \<account\>/\<ip\> \<number\> | AD |
|
||||
| Account withdrawal | AW \<account\>/\<ip\> \<number\> | AW |
|
||||
| Account balance | AB \<account\>/\<ip\> | AB \<number\> |
|
||||
| Account remove | AR \<account\>/\<ip\> | AR |
|
||||
| Bank (total) amount | BA | BA \<number\> |
|
||||
| Bank number of clients | BN | BN \<number\> |
|
||||
|
||||
Any of these commands may return an error message in format
|
||||
|
||||
`ER <message>`
|
||||
|
||||
## Sources
|
||||
|
||||
### Signal catching
|
||||
|
||||
- [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)
|
||||
- ~~[Get ENUM name from value](https://stackoverflow.com/a/38716384)~~
|
||||
Unused because of required compatibility with lower version python (3.9)
|
||||
|
||||
### Networking
|
||||
|
||||
- [Dynamically finding host IP address](https://stackoverflow.com/a/28950776)
|
||||
- [IP Regex](https://ihateregex.io/expr/ip/)
|
||||
|
||||
### Database
|
||||
|
||||
- [SqlAlchemy session generator](https://stackoverflow.com/a/71053353)
|
||||
- [Error handling decorator](https://chatgpt.com/share/67a46109-d38c-8005-ac36-677e6511ddcd)
|
||||
|
||||
### Platform specific problems
|
||||
|
||||
- [Windows sends partial data](https://stackoverflow.com/a/31754798)
|
||||
|
||||
### Threading
|
||||
|
||||
- [Events to stop scan](https://www.instructables.com/Starting-and-Stopping-Python-Threads-With-Events-i/)
|
||||
|
46
poetry.lock
generated
46
poetry.lock
generated
@ -135,6 +135,7 @@ files = [
|
||||
|
||||
[package.dependencies]
|
||||
mypy_extensions = ">=1.0.0"
|
||||
tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
|
||||
typing_extensions = ">=4.6.0"
|
||||
|
||||
[package.extras]
|
||||
@ -265,6 +266,47 @@ postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"]
|
||||
pymysql = ["pymysql"]
|
||||
sqlcipher = ["sqlcipher3_binary"]
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.2.1"
|
||||
description = "A lil' TOML parser"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"},
|
||||
{file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"},
|
||||
{file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"},
|
||||
{file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"},
|
||||
{file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"},
|
||||
{file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"},
|
||||
{file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"},
|
||||
{file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"},
|
||||
{file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"},
|
||||
{file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"},
|
||||
{file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"},
|
||||
{file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"},
|
||||
{file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"},
|
||||
{file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"},
|
||||
{file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"},
|
||||
{file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"},
|
||||
{file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"},
|
||||
{file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"},
|
||||
{file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"},
|
||||
{file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"},
|
||||
{file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"},
|
||||
{file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"},
|
||||
{file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"},
|
||||
{file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"},
|
||||
{file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"},
|
||||
{file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"},
|
||||
{file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"},
|
||||
{file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"},
|
||||
{file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"},
|
||||
{file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"},
|
||||
{file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"},
|
||||
{file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.12.2"
|
||||
@ -278,5 +320,5 @@ files = [
|
||||
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = "^3.12"
|
||||
content-hash = "c924c9033c266a86318eb310c7ef433b346d0ad4ff1574e44f7c8c664653106f"
|
||||
python-versions = "^3.8"
|
||||
content-hash = "822be0591477b901b0d6f8a34048f570227733ebf8eda1cae6a079ab2f5a1415"
|
||||
|
@ -6,7 +6,7 @@ authors = ["Thastertyn <thastertyn@thastertyn.xyz>"]
|
||||
readme = "README.md"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.6"
|
||||
python = "^3.8"
|
||||
sqlalchemy = "^2.0.37"
|
||||
python-dotenv = "^1.0.1"
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
greenlet==3.1.1 ; python_version < "3.14" and (platform_machine == "aarch64" or platform_machine == "ppc64le" or platform_machine == "x86_64" or platform_machine == "amd64" or platform_machine == "AMD64" or platform_machine == "win32" or platform_machine == "WIN32") and python_version >= "3.12"
|
||||
python-dotenv==1.0.1 ; python_version >= "3.12" and python_version < "4.0"
|
||||
sqlalchemy==2.0.37 ; python_version >= "3.12" and python_version < "4.0"
|
||||
typing-extensions==4.12.2 ; python_version >= "3.12" and python_version < "4.0"
|
||||
greenlet==3.1.1
|
||||
python-dotenv==1.0.1
|
||||
sqlalchemy==2.0.37
|
||||
typing-extensions==4.12.2
|
||||
|
@ -2,6 +2,7 @@ import socket
|
||||
import signal
|
||||
import sys
|
||||
import logging
|
||||
import errno
|
||||
|
||||
from core.config import BankNodeConfig
|
||||
from core.exceptions import ConfigError
|
||||
@ -46,28 +47,37 @@ class BankNode():
|
||||
|
||||
# Handle windows related signals
|
||||
if sys.platform == "win32":
|
||||
signal.signal(signal.CTRL_C_EVENT, self.gracefully_exit)
|
||||
signal.signal(signal.CTRL_BREAK_EVENT, self.gracefully_exit)
|
||||
signal.signal(signal.SIGBREAK, self.gracefully_exit)
|
||||
|
||||
def start(self):
|
||||
for port in range(self.config.scan_port_start, self.config.scan_port_end + 1):
|
||||
self.logger.debug("Trying port %d", port)
|
||||
try:
|
||||
self.config.used_port = port
|
||||
self.__start_server(port)
|
||||
return
|
||||
except socket.error as e:
|
||||
if e.errno == 98: # Address is in use
|
||||
self.logger.info("Port %d in use, trying next port", port)
|
||||
try:
|
||||
for port in range(self.config.scan_port_start, self.config.scan_port_end + 1):
|
||||
self.logger.debug("Trying port %d", port)
|
||||
try:
|
||||
self.config.used_port = port
|
||||
self.__start_server(port)
|
||||
return
|
||||
except socket.error as e:
|
||||
if e.errno == 98: # Address is in use
|
||||
self.logger.info("Port %d in use, trying next port", port)
|
||||
else:
|
||||
raise
|
||||
|
||||
self.logger.error("All ports are in use")
|
||||
self.exit_with_error()
|
||||
|
||||
self.logger.error("All ports are in use")
|
||||
self.exit_with_error()
|
||||
except socket.error as e:
|
||||
if e.errno == 98 or e.errno == errno.WSAEADDRINUSE: # 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):
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as socket_server:
|
||||
socket_server.bind((self.config.ip, port))
|
||||
socket_server.listen()
|
||||
socket_server.setblocking(True)
|
||||
self.socket_server = socket_server
|
||||
self.logger.info("Listening on %s:%s", self.config.ip, port)
|
||||
|
||||
@ -75,7 +85,7 @@ class BankNode():
|
||||
client_socket, address = socket_server.accept()
|
||||
self.logger.info("%s connected", address[0])
|
||||
|
||||
process = BankWorker(client_socket, address, self.config.to_dict())
|
||||
process = BankWorker(client_socket, address, self.config)
|
||||
process.start()
|
||||
|
||||
def exit_with_error(self):
|
||||
@ -95,3 +105,5 @@ class BankNode():
|
||||
def cleanup(self):
|
||||
self.logger.debug("Closing socket server")
|
||||
self.socket_server.close()
|
||||
self.logger.debug("Closing database connection")
|
||||
self.database_manager.cleanup()
|
||||
|
@ -1,28 +1,28 @@
|
||||
import socket
|
||||
import multiprocessing
|
||||
import logging
|
||||
from typing import Tuple, Dict
|
||||
from typing import Tuple
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
|
||||
from bank_protocol.command_handler import CommandHandler
|
||||
from core import Request, Response
|
||||
from core import Request, Response, BankNodeConfig
|
||||
from core.exceptions import BankNodeError
|
||||
|
||||
from utils.logger import setup_logger
|
||||
from database import DatabaseManager
|
||||
|
||||
class BankWorker(multiprocessing.Process):
|
||||
def __init__(self, client_socket: socket.socket, client_address: Tuple, config: Dict):
|
||||
def __init__(self, client_socket: socket.socket, client_address: Tuple, config: BankNodeConfig):
|
||||
super().__init__()
|
||||
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
self.client_socket = client_socket
|
||||
self.client_socket.settimeout(config["client_idle_timeout"])
|
||||
self.client_address = client_address
|
||||
|
||||
self.command_handler = CommandHandler(config)
|
||||
self.config = config
|
||||
|
||||
self.logger = None
|
||||
self.command_handler = None
|
||||
|
||||
def __setup_signals(self):
|
||||
self.logger.debug("Setting up exit signal hooks for worker")
|
||||
|
||||
@ -32,45 +32,91 @@ class BankWorker(multiprocessing.Process):
|
||||
|
||||
# Handle windows related signals
|
||||
if sys.platform == "win32":
|
||||
signal.signal(signal.CTRL_C_EVENT, self.gracefully_exit_worker)
|
||||
signal.signal(signal.CTRL_BREAK_EVENT, self.gracefully_exit_worker)
|
||||
signal.signal(signal.SIGBREAK, self.gracefully_exit_worker)
|
||||
|
||||
def run(self):
|
||||
# Logging behaves weirdly with processes on windows
|
||||
# and loses its configuration by default
|
||||
# -> Set it up again in the fresh process
|
||||
if sys.platform == "win32":
|
||||
DatabaseManager()
|
||||
setup_logger(self.config.verbosity)
|
||||
|
||||
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.command_handler = CommandHandler(self.config)
|
||||
|
||||
self.client_socket.setblocking(True)
|
||||
self.client_socket.settimeout(self.config.client_idle_timeout)
|
||||
self.__setup_signals()
|
||||
|
||||
with self.client_socket:
|
||||
while True:
|
||||
try:
|
||||
raw_request = self.client_socket.recv(1024).decode("utf-8")
|
||||
|
||||
if not raw_request:
|
||||
self.logger.debug("%s disconnected", self.client_address[0])
|
||||
break
|
||||
|
||||
request = Request(raw_request)
|
||||
self.logger.debug("Received request from %s - %s", self.client_address[0], request)
|
||||
|
||||
response: Response = self.command_handler.execute(request) + "\n\r"
|
||||
|
||||
self.client_socket.sendall(response.encode("utf-8"))
|
||||
|
||||
except socket.timeout:
|
||||
self.logger.debug("Client was idle for too long. Ending connection")
|
||||
response = "ER Idle too long\n\r"
|
||||
self.client_socket.sendall(response.encode("utf-8"))
|
||||
self.client_socket.shutdown(socket.SHUT_RDWR)
|
||||
self.client_socket.close()
|
||||
break
|
||||
except BankNodeError as e:
|
||||
response = "ER " + e.message + "\n\r"
|
||||
self.client_socket.sendall(response.encode("utf-8"))
|
||||
except socket.error as e:
|
||||
response = "ER Internal server error\n\r"
|
||||
self.logger.error(e)
|
||||
break
|
||||
self.serve_client()
|
||||
|
||||
self.logger.debug("Closing process for %s", self.client_address[0])
|
||||
|
||||
def serve_client(self):
|
||||
buffer = ""
|
||||
ending = None
|
||||
|
||||
while True:
|
||||
try:
|
||||
data = self.client_socket.recv(1024).decode("utf-8")
|
||||
|
||||
if not data:
|
||||
self.logger.debug("%s disconnected", self.client_address[0])
|
||||
break
|
||||
|
||||
buffer += data
|
||||
self.logger.debug("Buffer updated: %r", buffer)
|
||||
|
||||
if "\r\n" in buffer:
|
||||
ending = "\r\n"
|
||||
self.logger.debug("CRLF detected")
|
||||
elif "\n" in buffer:
|
||||
ending = "\n"
|
||||
self.logger.debug("LF detected")
|
||||
elif "\r" in buffer:
|
||||
ending = "\r"
|
||||
self.logger.debug("CR detected")
|
||||
if ending is None:
|
||||
self.logger.debug("No line ending found. Assuming another part will come")
|
||||
continue
|
||||
|
||||
response = self.process_request(buffer, ending)
|
||||
self.client_socket.sendall(response.encode("utf-8"))
|
||||
self.logger.debug("Response sent to %s", self.client_address[0])
|
||||
|
||||
buffer = ""
|
||||
ending = None
|
||||
|
||||
except socket.timeout:
|
||||
self.logger.debug("Client was idle for too long. Ending connection")
|
||||
response = "ER Idle too long" + (ending or "\n")
|
||||
self.client_socket.sendall(response.encode("utf-8"))
|
||||
self.client_socket.shutdown(socket.SHUT_RDWR)
|
||||
self.client_socket.close()
|
||||
break
|
||||
except UnicodeDecodeError:
|
||||
self.logger.warning("Received a non utf-8 message")
|
||||
response = "ER Not utf-8 message" + (ending or "\n")
|
||||
self.client_socket.sendall(response.encode("utf-8"))
|
||||
break
|
||||
except BankNodeError as e:
|
||||
response = "ER " + e.message + (ending or "\n")
|
||||
self.client_socket.sendall(response.encode("utf-8"))
|
||||
buffer = ""
|
||||
except socket.error as e:
|
||||
self.logger.error(e)
|
||||
response = "ER Internal server error" + (ending or "\n")
|
||||
break
|
||||
|
||||
def process_request(self, buffer: str, line_ending: str) -> Response:
|
||||
self.logger.debug("Processing request: %r", buffer)
|
||||
request = Request(buffer)
|
||||
response: Response = self.command_handler.execute(request) + line_ending
|
||||
return response
|
||||
|
||||
def gracefully_exit_worker(self, signum, _):
|
||||
"""Log the signal caught and exit with status 0"""
|
||||
|
||||
|
54
src/bank_protocol/bank_scanner.py
Normal file
54
src/bank_protocol/bank_scanner.py
Normal file
@ -0,0 +1,54 @@
|
||||
import socket
|
||||
import threading
|
||||
import logging
|
||||
import errno
|
||||
|
||||
from core.peer import BankPeer
|
||||
|
||||
|
||||
class BankScanner(threading.Thread):
|
||||
def __init__(self, host: str, port: str, result_peer: BankPeer, bank_found_event: threading.Event, lock: threading.Lock, timeout: int):
|
||||
super().__init__(name="BankScannerThread-{self.host}:{port}")
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.result_peer = result_peer
|
||||
self.bank_found_event = bank_found_event
|
||||
self.lock = lock
|
||||
self.timeout = timeout
|
||||
|
||||
def run(self):
|
||||
if self.bank_found_event.is_set():
|
||||
return
|
||||
|
||||
self.__probe_for_open_ports(self.host, self.port)
|
||||
|
||||
def __probe_for_open_ports(self, host: str, port: int):
|
||||
try:
|
||||
connection = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
connection.settimeout(self.timeout)
|
||||
connection.connect((host, port))
|
||||
self.__scan_for_bank(connection)
|
||||
except socket.timeout:
|
||||
self.logger.debug("Connection for port %d timed out", port)
|
||||
except socket.error as e:
|
||||
if e.errno == 111 or e.errno == errno.WSAECONNREFUSED: # Connection refused
|
||||
self.logger.debug("Port %d not open", port)
|
||||
else:
|
||||
self.logger.debug("Unknown error occurred when probing port: %s", e)
|
||||
|
||||
def __scan_for_bank(self, connection: socket.socket):
|
||||
ping_command = "BC\r\n"
|
||||
connection.sendall(ping_command.encode("utf-8"))
|
||||
response = connection.recv(1024).decode("utf-8")
|
||||
|
||||
if response.strip() == f"BC {self.host}":
|
||||
self.logger.debug("Bank application found on %s:%s", self.host, self.port)
|
||||
|
||||
with self.lock:
|
||||
if not self.bank_found_event.is_set():
|
||||
self.result_peer.port = self.port
|
||||
self.result_peer.bank_socket = connection
|
||||
self.bank_found_event.set()
|
||||
else:
|
||||
self.logger.debug("Port is open, but no bank application found")
|
@ -3,6 +3,7 @@ from typing import Dict, Callable
|
||||
|
||||
from core import Request, Response
|
||||
from core.exceptions import BankNodeError
|
||||
from database.exceptions import DatabaseError
|
||||
|
||||
from bank_protocol.commands import (account_balance,
|
||||
account_create,
|
||||
@ -35,8 +36,14 @@ class CommandHandler:
|
||||
self.logger.warning("Unknown command %s", request.command_code)
|
||||
raise BankNodeError(f"Unknown command {request.command_code}")
|
||||
|
||||
self.logger.debug("Serving %s", request.command_code)
|
||||
|
||||
command = self.registered_commands[request.command_code]
|
||||
|
||||
response = command(request, self.config)
|
||||
|
||||
return f"{request.command_code} {response}"
|
||||
try:
|
||||
response = command(request, self.config)
|
||||
if response is not None:
|
||||
return str(response).strip()
|
||||
else:
|
||||
return request.command_code
|
||||
except DatabaseError as e:
|
||||
return f"ER {e.message}"
|
||||
|
@ -1,8 +1,39 @@
|
||||
from core.request import Request
|
||||
import re
|
||||
import logging
|
||||
from core import Request, BankNodeConfig, Response
|
||||
from bank_protocol.exceptions import InvalidRequest
|
||||
from services.account_service import get_account_balance
|
||||
from bank_protocol.proxy_handler import BankProxy
|
||||
|
||||
def account_balance(request: Request):
|
||||
pass
|
||||
def account_balance(request: Request, config: BankNodeConfig) -> Response:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if request.body is None:
|
||||
raise InvalidRequest("Invalid request format")
|
||||
|
||||
try:
|
||||
split_body = request.body.split("/")
|
||||
|
||||
account = split_body[0]
|
||||
ip = split_body[1]
|
||||
|
||||
except IndexError as e:
|
||||
raise InvalidRequest("Invalid request format") from e
|
||||
|
||||
if ip != config.ip:
|
||||
bank_proxy = BankProxy(request, ip, config)
|
||||
return bank_proxy.proxy_request()
|
||||
|
||||
if not account.isdigit():
|
||||
raise InvalidRequest("Account must be a number")
|
||||
|
||||
account_parsed = int(account)
|
||||
|
||||
if account_parsed < 10_000 or account_parsed > 99_999:
|
||||
raise InvalidRequest("Account number out of range")
|
||||
|
||||
balance = get_account_balance(account_parsed)
|
||||
|
||||
return f"AB {str(balance)}"
|
||||
|
||||
|
||||
__all__ = ["account_balance"]
|
||||
|
@ -1,7 +1,16 @@
|
||||
from core.request import Request
|
||||
from core import Request, Response, BankNodeConfig
|
||||
from bank_protocol.exceptions import InvalidRequest
|
||||
from services.account_service import create_account
|
||||
|
||||
|
||||
def account_create(request: Request, config: BankNodeConfig) -> Response:
|
||||
if request.body is not None:
|
||||
raise InvalidRequest("Incorrect usage")
|
||||
|
||||
account_number = create_account()
|
||||
|
||||
return f"AC {account_number}/{config.ip}"
|
||||
|
||||
def account_create(request: Request):
|
||||
pass
|
||||
|
||||
|
||||
__all__ = ["account_create"]
|
||||
|
@ -1,7 +1,48 @@
|
||||
from core.request import Request
|
||||
import logging
|
||||
from core import Request, BankNodeConfig, Response
|
||||
from bank_protocol.exceptions import InvalidRequest
|
||||
from services.account_service import deposit_into_account
|
||||
from utils.constants import MONEY_AMOUNT_MAXIMUM
|
||||
from bank_protocol.proxy_handler import BankProxy
|
||||
|
||||
def account_deposit(request: Request):
|
||||
pass
|
||||
def account_deposit(request: Request, config: BankNodeConfig) -> Response:
|
||||
if request.body is None:
|
||||
raise InvalidRequest("Invalid request format")
|
||||
|
||||
try:
|
||||
split_body = request.body.split("/")
|
||||
split_ip = split_body[1].split(" ")
|
||||
|
||||
account = split_body[0]
|
||||
ip = split_ip[0]
|
||||
amount = split_ip[1]
|
||||
|
||||
except IndexError as e:
|
||||
raise InvalidRequest("Invalid request format") from e
|
||||
|
||||
if ip != config.ip:
|
||||
bank_proxy = BankProxy(request, ip, config)
|
||||
return bank_proxy.proxy_request()
|
||||
|
||||
if not account.isdigit():
|
||||
raise InvalidRequest("Account must be a number")
|
||||
|
||||
account_parsed = int(account)
|
||||
|
||||
if account_parsed < 10_000 or account_parsed > 99_999:
|
||||
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)
|
||||
|
||||
return "AD"
|
||||
|
||||
|
||||
__all__ = ["account_deposit"]
|
||||
|
@ -1,7 +1,37 @@
|
||||
from core.request import Request
|
||||
from core import Request, BankNodeConfig, Response
|
||||
from bank_protocol.exceptions import InvalidRequest
|
||||
from services.account_service import delete_account
|
||||
from bank_protocol.proxy_handler import BankProxy
|
||||
|
||||
def account_remove(request: Request):
|
||||
pass
|
||||
|
||||
def account_remove(request: Request, config: BankNodeConfig) -> Response:
|
||||
if request.body is None:
|
||||
raise InvalidRequest("Invalid request format")
|
||||
|
||||
try:
|
||||
split_body = request.body.split("/")
|
||||
|
||||
account = split_body[0]
|
||||
ip = split_body[1]
|
||||
|
||||
except IndexError as e:
|
||||
raise InvalidRequest("Invalid request format") from e
|
||||
|
||||
if ip != config.ip:
|
||||
bank_proxy = BankProxy(request, ip, config)
|
||||
return bank_proxy.proxy_request()
|
||||
|
||||
if not account.isdigit():
|
||||
raise InvalidRequest("Account must be a number")
|
||||
|
||||
account_parsed = int(account)
|
||||
|
||||
if account_parsed < 10_000 or account_parsed > 99_999:
|
||||
raise InvalidRequest("Account number out of range")
|
||||
|
||||
delete_account(account_parsed)
|
||||
|
||||
return "AR"
|
||||
|
||||
|
||||
__all__ = ["account_remove"]
|
||||
|
@ -1,7 +1,48 @@
|
||||
from core.request import Request
|
||||
from core import Request, BankNodeConfig, Response
|
||||
from bank_protocol.exceptions import InvalidRequest
|
||||
from services.account_service import withdraw_from_account
|
||||
from utils.constants import MONEY_AMOUNT_MAXIMUM
|
||||
from bank_protocol.proxy_handler import BankProxy
|
||||
|
||||
def account_withdrawal(request: Request):
|
||||
pass
|
||||
|
||||
def account_withdrawal(request: Request, config: BankNodeConfig) -> Response:
|
||||
if request.body is None:
|
||||
raise InvalidRequest("Invalid request format")
|
||||
|
||||
try:
|
||||
split_body = request.body.split("/")
|
||||
split_ip = split_body[1].split(" ")
|
||||
|
||||
account = split_body[0]
|
||||
ip = split_ip[0]
|
||||
amount = split_ip[1]
|
||||
|
||||
except IndexError as e:
|
||||
raise InvalidRequest("Invalid request format") from e
|
||||
|
||||
if ip != config.ip:
|
||||
bank_proxy = BankProxy(request, ip, config)
|
||||
return bank_proxy.proxy_request()
|
||||
|
||||
if not account.isdigit():
|
||||
raise InvalidRequest("Account must be a number")
|
||||
|
||||
account_parsed = int(account)
|
||||
|
||||
if account_parsed < 10_000 or account_parsed > 99_999:
|
||||
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")
|
||||
|
||||
withdraw_from_account(account_parsed, amount_parsed)
|
||||
|
||||
return "AW"
|
||||
|
||||
|
||||
__all__ = ["account_withdrawal"]
|
||||
|
@ -1,12 +1,10 @@
|
||||
from typing import Dict
|
||||
|
||||
from core import Request, Response
|
||||
from core import Request, Response, BankNodeConfig
|
||||
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:
|
||||
raise InvalidRequest("Incorrect usage")
|
||||
|
||||
return config["ip"]
|
||||
return f"BC {config.ip}"
|
||||
|
||||
__all__ = ["bank_code"]
|
||||
|
@ -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):
|
||||
pass
|
||||
def bank_number_of_clients(request: Request, _) -> Response:
|
||||
if request.body is not None:
|
||||
raise InvalidRequest("Incorrect usage")
|
||||
|
||||
number_of_clients = get_account_count()
|
||||
return f"BN {number_of_clients}"
|
||||
|
||||
__all__ = ["bank_number_of_clients"]
|
||||
|
@ -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):
|
||||
pass
|
||||
def bank_total_amount(request: Request, _) -> Response:
|
||||
if request.body is not None:
|
||||
raise InvalidRequest("Incorrect usage")
|
||||
|
||||
total_balace = get_total_balance()
|
||||
return f"BA {total_balace}"
|
||||
|
||||
|
||||
__all__ = ["bank_total_amount"]
|
||||
|
@ -2,7 +2,31 @@
|
||||
from core.exceptions import BankNodeError
|
||||
|
||||
|
||||
class ProxyError(BankNodeError):
|
||||
def __init__(self, message):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
|
||||
|
||||
class InvalidRequest(BankNodeError):
|
||||
def __init__(self, message):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
|
||||
|
||||
class RequestTimeoutError(BankNodeError):
|
||||
def __init__(self, message):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
|
||||
|
||||
class HostUnreachableError(BankNodeError):
|
||||
def __init__(self, message):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
|
||||
|
||||
class NoPortsOpenError(BankNodeError):
|
||||
def __init__(self, message):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
|
@ -0,0 +1,45 @@
|
||||
import logging
|
||||
import threading
|
||||
|
||||
from core import Request, Response, BankNodeConfig
|
||||
from core.peer import BankPeer
|
||||
from bank_protocol.exceptions import NoPortsOpenError
|
||||
from bank_protocol.bank_scanner import BankScanner
|
||||
|
||||
|
||||
class BankProxy():
|
||||
def __init__(self, request: Request, host: str, config: BankNodeConfig):
|
||||
self.request = request
|
||||
self.host = host
|
||||
self.config = config
|
||||
|
||||
self.peer = BankPeer(self.host, None, None)
|
||||
self.bank_found_event = threading.Event()
|
||||
self.lock = threading.Lock()
|
||||
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.logger.info("Proxying request")
|
||||
|
||||
def proxy_request(self) -> Response:
|
||||
scanner_threads = []
|
||||
|
||||
for port in range(self.config.scan_port_start, self.config.scan_port_end + 1):
|
||||
if self.bank_found_event.is_set():
|
||||
break
|
||||
|
||||
t = BankScanner(self.host, port, self.peer, self.bank_found_event, self.lock, self.config.timeout)
|
||||
t.start()
|
||||
scanner_threads.append(t)
|
||||
|
||||
for t in scanner_threads:
|
||||
t.join()
|
||||
|
||||
if not self.bank_found_event.is_set():
|
||||
self.logger.warning("No bank application found on host %s", self.host)
|
||||
raise NoPortsOpenError("Bank is unreachable")
|
||||
|
||||
# with self.peer.bank_socket as bank_socket:
|
||||
self.peer.bank_socket.sendall((str(self.request) + "\r\n").encode("utf-8"))
|
||||
response = self.peer.bank_socket.recv(1024).decode("utf-8")
|
||||
|
||||
return response
|
@ -1,7 +1,9 @@
|
||||
from .request import *
|
||||
from .response import *
|
||||
from .config import BankNodeConfig
|
||||
|
||||
__all__ = [
|
||||
*request.__all__,
|
||||
*response.__all__
|
||||
*response.__all__,
|
||||
*config.__all__
|
||||
]
|
@ -26,13 +26,11 @@ class BankNodeConfig:
|
||||
|
||||
# Port validation
|
||||
if not scan_port_range or scan_port_range == "":
|
||||
self.logger.error("Scan port range not defined")
|
||||
raise ConfigError("Scan port range not defined")
|
||||
|
||||
range_split = scan_port_range.split(":")
|
||||
|
||||
if len(range_split) != 2 or not range_split[0].isdigit() or not range_split[1].isdigit():
|
||||
self.logger.error("Scan port range is not in valid format")
|
||||
raise ConfigError("Scan port range is not in valid format")
|
||||
|
||||
# Timeout validation
|
||||
@ -48,8 +46,7 @@ class BankNodeConfig:
|
||||
|
||||
# IP validation
|
||||
if not re.match(IP_REGEX, ip):
|
||||
self.logger.error("Invalid IP in configuration")
|
||||
raise ConfigError("Invalid IP in configuration")
|
||||
raise ConfigError(f"Invalid IP {ip} in configuration")
|
||||
|
||||
self.used_port: int
|
||||
self.ip = ip
|
||||
@ -69,3 +66,6 @@ class BankNodeConfig:
|
||||
"scan_port_start": self.scan_port_start,
|
||||
"scan_port_end": self.scan_port_end,
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["BankNodeConfig"]
|
||||
|
@ -0,0 +1,8 @@
|
||||
import socket
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class BankPeer():
|
||||
host: str
|
||||
port: int
|
||||
bank_socket: socket.socket
|
@ -1,17 +1,23 @@
|
||||
import re
|
||||
|
||||
from bank_protocol.exceptions import InvalidRequest
|
||||
import logging
|
||||
|
||||
|
||||
class Request():
|
||||
|
||||
def __init__(self, raw_request: str):
|
||||
logger = logging.getLogger(__name__)
|
||||
raw_request = raw_request.strip()
|
||||
|
||||
if re.match(r"^[A-Z]{2}$", raw_request):
|
||||
self.command_code = raw_request[0:2] # Still take the first 2 characters, because of lingering crlf
|
||||
logger.debug("Found 2 char command")
|
||||
self.command_code = raw_request[0:2] # Still take the first 2 characters, because of lingering crlf
|
||||
self.body = None
|
||||
elif re.match(r"^[A-Z]{2} .+", raw_request):
|
||||
logger.debug("Found command with arguments")
|
||||
command_code: str = raw_request[0:2]
|
||||
body: str = raw_request[3:-1] or ""
|
||||
body: str = raw_request[3:] or ""
|
||||
|
||||
if len(body.split("\n")) > 1:
|
||||
raise InvalidRequest("Multiline requests are not supported")
|
||||
@ -22,7 +28,10 @@ class Request():
|
||||
raise InvalidRequest("Invalid request")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.command_code} {self.body}"
|
||||
if self.body:
|
||||
return f"{self.command_code} {self.body}"
|
||||
else:
|
||||
return self.command_code
|
||||
|
||||
|
||||
__all__ = ["Request"]
|
||||
|
@ -1,12 +1,12 @@
|
||||
import logging
|
||||
from typing import Generator
|
||||
from contextlib import contextmanager
|
||||
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.exc import DatabaseError as SqlAlchemyDatabaseError
|
||||
|
||||
from sqlalchemy.exc import DatabaseError
|
||||
|
||||
from database.exceptions import DatabaseConnectionError
|
||||
from database.exceptions import DatabaseError
|
||||
from models.base_model import Base
|
||||
|
||||
|
||||
@ -29,13 +29,13 @@ class DatabaseManager():
|
||||
self.engine = create_engine('sqlite:///bank.db')
|
||||
|
||||
self.Session = sessionmaker(bind=self.engine)
|
||||
self.create_tables()
|
||||
|
||||
def create_tables(self):
|
||||
self.logger.debug("Creating tables")
|
||||
Base.metadata.create_all(self.engine)
|
||||
|
||||
def cleanup(self) -> None:
|
||||
self.logger.debug("Closing connection")
|
||||
self.engine.dispose()
|
||||
|
||||
def test_connection(self) -> bool:
|
||||
@ -45,14 +45,15 @@ class DatabaseManager():
|
||||
connection.execute(text("select 1"))
|
||||
self.logger.debug("Database connection successful")
|
||||
return True
|
||||
except DatabaseError as e:
|
||||
except SqlAlchemyDatabaseError as 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
|
||||
|
||||
@classmethod
|
||||
def get_session(cls) -> Generator:
|
||||
@contextmanager
|
||||
def get_session(cls) -> Generator[Session, None, None]:
|
||||
session = cls._instance.Session()
|
||||
try:
|
||||
yield session
|
||||
|
13
src/database/exception_catcher_decorator.py
Normal file
13
src/database/exception_catcher_decorator.py
Normal 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
|
@ -1,28 +1,25 @@
|
||||
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
|
||||
MONEY_OVERFLOW = 8
|
||||
|
||||
def __init__(self, message: str, errno: int, **kwargs):
|
||||
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.errno = errno
|
||||
|
||||
for key, value in kwargs.items():
|
||||
setattr(self, key, value)
|
||||
|
||||
|
||||
class DatabaseConnectionError(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
|
||||
|
||||
|
||||
__all__ = ["DatabaseError", "DatabaseConnectionError", "DuplicateEntryError"]
|
||||
__all__ = ["DatabaseError"]
|
||||
|
@ -1,13 +1,13 @@
|
||||
from sqlalchemy import Column, Integer, CheckConstraint
|
||||
from sqlalchemy import Column, BigInteger, Integer, CheckConstraint
|
||||
|
||||
from .base_model import Base
|
||||
|
||||
|
||||
class Account(Base):
|
||||
__tablename__ = 'account'
|
||||
__table_args__ = (CheckConstraint('account_number > 10000 and account_number <= 99999'),)
|
||||
__table_args__ = (CheckConstraint('account_number >= 10000 and account_number <= 99999'),)
|
||||
|
||||
account_number = Column(Integer, nullable=False, primary_key=True)
|
||||
account_number = Column(BigInteger, nullable=False, primary_key=True)
|
||||
balance = Column(Integer)
|
||||
|
||||
|
||||
|
102
src/services/account_service.py
Normal file
102
src/services/account_service.py
Normal file
@ -0,0 +1,102 @@
|
||||
from sqlalchemy import func
|
||||
|
||||
from models import Account
|
||||
from database import DatabaseManager
|
||||
from database.exceptions import DatabaseError
|
||||
from database.exception_catcher_decorator import handle_database_errors
|
||||
from utils.constants import MIN_ACCOUNT_NUMBER, MAX_ACCOUNT_NUMBER, MONEY_AMOUNT_MAXIMUM
|
||||
|
||||
|
||||
@handle_database_errors
|
||||
def get_next_id() -> int:
|
||||
with DatabaseManager.get_session() as session:
|
||||
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
|
||||
|
||||
if current_max_id > MAX_ACCOUNT_NUMBER:
|
||||
raise DatabaseError("Too many users already exist, cannot open new account", DatabaseError.OUT_OF_ACCOUNT_SPACE)
|
||||
|
||||
return current_max_id
|
||||
|
||||
|
||||
@handle_database_errors
|
||||
def create_account() -> int:
|
||||
new_id = get_next_id()
|
||||
|
||||
with DatabaseManager.get_session() as session:
|
||||
new_account = Account(account_number=new_id, balance=0)
|
||||
session.add(new_account)
|
||||
session.commit()
|
||||
return new_id
|
||||
|
||||
|
||||
@handle_database_errors
|
||||
def get_account_balance(account_number: int) -> int:
|
||||
with DatabaseManager.get_session() as session:
|
||||
account: Account = session.query(Account).where(Account.account_number == account_number).one_or_none()
|
||||
if account is None:
|
||||
raise DatabaseError(f"Account with number {account_number} doesn't exist", DatabaseError.NONEXISTENT_ACCOUNT)
|
||||
|
||||
return account.balance
|
||||
|
||||
|
||||
@handle_database_errors
|
||||
def withdraw_from_account(account_number: int, amount: int):
|
||||
modify_balance(account_number, amount, False)
|
||||
|
||||
|
||||
@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:
|
||||
account: Account = session.query(Account).where(Account.account_number == account_number).one_or_none()
|
||||
if account is None:
|
||||
raise DatabaseError(f"Account with number {account_number} doesn't exist", DatabaseError.NONEXISTENT_ACCOUNT)
|
||||
|
||||
if add:
|
||||
if account.balance == MONEY_AMOUNT_MAXIMUM:
|
||||
raise DatabaseError("Reached maximum funds amount", DatabaseError.MONEY_OVERFLOW)
|
||||
elif account.balance + amount > MONEY_AMOUNT_MAXIMUM:
|
||||
account.balance = MONEY_AMOUNT_MAXIMUM
|
||||
else:
|
||||
account.balance += amount
|
||||
else:
|
||||
if account.balance - amount < 0:
|
||||
raise DatabaseError("Not enough funds on account to withdraw this much", DatabaseError.INSUFFICIENT_BALANCE)
|
||||
account.balance -= amount
|
||||
|
||||
session.commit()
|
||||
|
||||
|
||||
@handle_database_errors
|
||||
def delete_account(account_number: int):
|
||||
with DatabaseManager.get_session() as session:
|
||||
account: Account = session.query(Account).where(Account.account_number == account_number).one_or_none()
|
||||
if account is None:
|
||||
raise DatabaseError(f"Account with number {account_number} doesn't exist", DatabaseError.NONEXISTENT_ACCOUNT)
|
||||
|
||||
if account.balance > 0:
|
||||
raise DatabaseError("Cannot delete an account with leftover funds", DatabaseError.INVALID_OPERATION)
|
||||
|
||||
session.delete(account)
|
||||
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
|
@ -1,5 +1,9 @@
|
||||
import re
|
||||
import sys
|
||||
|
||||
IP_REGEX = r"^[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}$"
|
||||
ACCOUNT_NUMBER_REGEX = r"[0-9]{9}"
|
||||
MONEY_AMOUNT_MAXIMUM = (2 ^ 63) - 1
|
||||
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"^(1[0-9]{8})|([2-9][0-9]{8})$"
|
||||
MONEY_AMOUNT_MAXIMUM = 2**63 - 1
|
||||
|
||||
MIN_ACCOUNT_NUMBER = 10_000
|
||||
MAX_ACCOUNT_NUMBER = 99_999
|
||||
|
@ -4,7 +4,7 @@ import logging
|
||||
|
||||
def setup_logger(verbosity: str):
|
||||
if verbosity == "DEBUG":
|
||||
log_format = "[ %(levelname)s / %(processName)s ] - %(name)s:%(lineno)d - %(message)s"
|
||||
log_format = "[ %(levelname)s / %(processName)s ] - %(filename)s:%(lineno)d - %(message)s"
|
||||
else:
|
||||
log_format = "[ %(levelname)s ] - %(message)s"
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user