diff --git a/.gitignore b/.gitignore index a9998749..4d3ed606 100644 --- a/.gitignore +++ b/.gitignore @@ -19,5 +19,6 @@ venv3/ .env htmlcov/ .DS_Store +pgdb/ node_modules/ .python-version diff --git a/README.md b/README.md index 7bc5ed6e..806ecb1d 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,13 @@ The following was tested on a fresh installation of Ubuntu 20.04. source venv/bin/activate pip install -r requirements.txt - # Review your settings + # Create a development database server + python manage.py make_db + + # Start the development database server + python manage.py start_db + + # Review your settings, including the database settings from the output from make_db. cp fm_eventmanager/settings.py.devel fm_eventmanager/settings.py python manage.py migrate diff --git a/fm_eventmanager/settings.py.devel b/fm_eventmanager/settings.py.devel index f2c83dcd..8cebf48c 100644 --- a/fm_eventmanager/settings.py.devel +++ b/fm_eventmanager/settings.py.devel @@ -130,12 +130,19 @@ WSGI_APPLICATION = 'fm_eventmanager.wsgi.application' # Database # https://docs.djangoproject.com/en/3.2/ref/settings/#databases +# Remove this class and replace 'HOST' in DATABASES['default'] +# with the output from `python manage.py make_db` or replace +# with your own Postgres server configuration. +class _ReadTheREADME: + pass + DATABASES = { 'default': { - 'ENGINE' : 'django.db.backends.sqlite3', - 'NAME' : os.path.join(BASE_DIR, 'db.sqlite3'), + 'ENGINE': 'django.db.backends.postgresql', + 'HOST': _ReadTheREADME, + 'NAME' : 'apis_dev', 'TEST' : { - 'NAME' : os.path.join(BASE_DIR, 'test-db.sqlite3'), + 'NAME' : 'apis_test', } } } diff --git a/registration/management/commands/make_db.py b/registration/management/commands/make_db.py new file mode 100644 index 00000000..1a5ed306 --- /dev/null +++ b/registration/management/commands/make_db.py @@ -0,0 +1,111 @@ +from pathlib import Path + +from django.conf import settings +from django.core.management.base import BaseCommand + +from registration.utils.database import DatabaseStatus, Postgres + + +config_block = """ +DATABASES = {{ + 'default': {{ + 'ENGINE': 'django.db.backends.postgresql', + 'HOST': '{db_path}', + 'NAME' : '{db_name}', + }} +}} +""" + +config_block_with_test = """ +DATABASES = {{ + 'default': {{ + 'ENGINE': 'django.db.backends.postgresql', + 'HOST': '{db_path}', + 'NAME' : '{db_name}', + 'TEST' : {{ + 'NAME' : '{test_db_name}', + }} + }} +}} +""" + + +class Command(BaseCommand): + help = "Creates a development postgresql database." + + def add_arguments(self, parser): + base_dir = Path(settings.BASE_DIR) + db_dir = (base_dir / "pgdb").absolute() + + parser.add_argument( + "--db-path", + type=str, + default=str(db_dir), + help=f"Path where the postgresql database will be created, default {db_dir}.", + ) + parser.add_argument( + "--db-name", + type=str, + default="apis_dev", + help="The database name to create, default apis_dev.", + ) + parser.add_argument( + "--test-db-name", + type=str, + default="apis_test", + help="The test database name to create, default apis_test.", + ) + parser.add_argument( + "--skip-test-db", + type=bool, + default=False, + help="Whether or not to skip creating the test database, default False.", + ) + parser.add_argument( + "--silent", + type=bool, + default=False, + help="Whether to suppress configuration information, default False." + ) + + def handle(self, *args, **options): + postgres = Postgres(options["db_path"]) + + status = postgres.get_status() + + if status in (DatabaseStatus.UNKNOWN, DatabaseStatus.INVALID_DIRECTORY): + postgres.delete() + + status = DatabaseStatus.DOES_NOT_EXIST + + if status in (DatabaseStatus.DOES_NOT_EXIST, DatabaseStatus.DIRECTORY_EMPTY): + postgres.init() + + status = DatabaseStatus.STOPPED + + db_name = options["db_name"] + postgres.create_db(db_name) + + skip_test_db = options["skip_test_db"] + test_db_name = options["test_db_name"] + if test_db_name and not skip_test_db: + postgres.create_db(test_db_name) + + if options["silent"]: + return + + if test_db_name and not skip_test_db: + config_text = config_block_with_test.format( + db_path=postgres.db_path, + db_name=db_name, + test_db_name=test_db_name, + ) + else: + config_text = config_block.format( + db_path=postgres.db_path, + db_name=db_name, + ) + + print(f"Created database {db_name} at {postgres.db_path}") + print("Add the following to your settings.py") + print(config_text) diff --git a/registration/management/commands/start_db.py b/registration/management/commands/start_db.py new file mode 100644 index 00000000..f170d7f7 --- /dev/null +++ b/registration/management/commands/start_db.py @@ -0,0 +1,35 @@ +from pathlib import Path +import sys + +from django.conf import settings +from django.core.management.base import BaseCommand + +from registration.utils.database import DatabaseStatus, Postgres + + +class Command(BaseCommand): + help = "Starts the development postgresql database." + + def add_arguments(self, parser): + base_dir = Path(settings.BASE_DIR) + db_dir = (base_dir / "pgdb").absolute() + + parser.add_argument( + "--db-path", + type=str, + default=str(db_dir), + help=f"Path where the postgresql database will be stopped, default {db_dir}.", + ) + + def handle(self, *args, **options): + postgres = Postgres(options["db_path"]) + + status = postgres.get_status() + if status == DatabaseStatus.STOPPED: + postgres.start() + + print("Postgres server started") + elif status == DatabaseStatus.RUNNING: + print("Postgres server is already running") + else: + print(f"Cannot start Postgres server, invalid status: {status}", file=sys.stderr) diff --git a/registration/management/commands/stop_db.py b/registration/management/commands/stop_db.py new file mode 100644 index 00000000..20173e57 --- /dev/null +++ b/registration/management/commands/stop_db.py @@ -0,0 +1,37 @@ +from pathlib import Path + +from django.conf import settings +from django.core.management.base import BaseCommand + +from registration.utils.database import DatabaseStatus, Postgres + + +class Command(BaseCommand): + help = "Stops the development postgresql database." + + def add_arguments(self, parser): + base_dir = Path(settings.BASE_DIR) + db_dir = (base_dir / "pgdb").absolute() + + parser.add_argument( + "--db-path", + type=str, + default=str(db_dir), + help=f"Path where the postgresql database will be stopped, default {db_dir}.", + ) + parser.add_argument( + "--delete", + type=bool, + default=False, + help="Whether or not to delete the database after it has been stopped, default False." + ) + + def handle(self, *args, **options): + postgres = Postgres(options["db_path"]) + + status = postgres.get_status() + if status == DatabaseStatus.RUNNING: + postgres.stop() + + if options["delete"]: + postgres.delete() diff --git a/registration/utils/__init__.py b/registration/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/registration/utils/database.py b/registration/utils/database.py new file mode 100644 index 00000000..92498a2a --- /dev/null +++ b/registration/utils/database.py @@ -0,0 +1,195 @@ +import enum +import errno +import fcntl +import os +import pathlib +import shutil +import subprocess +import sys + + +@enum.unique +class DatabaseStatus(enum.Enum): + UNKNOWN = enum.auto() + DOES_NOT_EXIST = enum.auto() + DIRECTORY_EMPTY = enum.auto() + INVALID_DIRECTORY = enum.auto() + STOPPED = enum.auto() + RUNNING = enum.auto() + + +class MissingExecutable(Exception): + def __init__(self, executable: str): + self.message = f"Unable to find system executable: {executable}" + + +class Postgres: + """ + Utility class for interacting with Postgres databases. + """ + def __init__(self, db_path_str: str): + self.db_path = pathlib.Path(db_path_str).absolute() + + pg_ctl = shutil.which("pg_ctl") + if not pg_ctl: + raise MissingExecutable("pg_ctl") + self.pg_ctl = pg_ctl + + createdb = shutil.which("createdb") + if not createdb: + raise MissingExecutable("createdb") + self.createdb = createdb + + def _run(self, args: list[str]) -> subprocess.CompletedProcess: + """ + Run a process given a list of arguments. + + Rather than use subprocess.run, we have to jump through a few + extra hoops to ensure that we don't hit the deadlock when using pipes. + """ + proc = subprocess.Popen( + args, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + bufsize=0, + close_fds=True, + text=True, + ) + + # Wait for the process to finish + while proc.poll() is None: + pass + + stdout_fd = proc.stdout.fileno() + + # Set the process' stdout file descriptor to non-blocking + flags = fcntl.fcntl(stdout_fd, fcntl.F_GETFL) + fcntl.fcntl(stdout_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK) + + # Read all the data from stdout + stdout_data = "" + while True: + try: + data = os.read(stdout_fd, 10) + except OSError as exc: + if exc.errno == errno.EAGAIN: + break + else: + raise + + if not data: + break + + stdout_data += data.decode("utf-8") + + return subprocess.CompletedProcess( + args=args, + returncode=proc.returncode, + stdout=stdout_data, + stderr="", + ) + + def init(self) -> str: + """ + Creates a new Postgres instance. + """ + if not self.db_path.exists(): + self.db_path.mkdir() + + init_args = [ + self.pg_ctl, "initdb", "--pgdata", self.db_path + ] + result = self._run(init_args) + output = result.stdout.strip() + return output + + def create_db(self, db_name: str) -> bool: + """ + Creates a new database within the Postgres instance. + """ + create_db_args = [ + self.createdb, "-h", self.db_path, db_name, + ] + result = self._run(create_db_args) + output = result.stdout.strip() + + # An empty response means the database was created successfully. + return output == "" + + def start(self) -> bool: + """ + Starts the Postgres database server. + + It is up to the caller to make sure init() is called first. + Note that the only way to connect to this server is through + a unix socket. + """ + start_args = [ + self.pg_ctl, + "--pgdata", + str(self.db_path), + "--wait", + '--options', + f'-h "" -k "{self.db_path}"', + "start", + ] + + result = self._run(start_args) + if result.returncode != 0: + print("An error occured!", file=sys.stderr) + output = result.stdout.strip() + print(output, file=sys.stderr) + + status = self.get_status() + return status == DatabaseStatus.RUNNING + + def stop(self) -> bool: + """ + Stops the Postgres database server. + """ + stop_args = [ + self.pg_ctl, "-D", self.db_path, "-m", "immediate", "stop" + ] + result = self._run(stop_args) + + if result.returncode != 0: + print("An error occured!", file=sys.stderr) + output = result.stdout.strip() + print(output, file=sys.stderr) + + status = self.get_status() + return status == DatabaseStatus.STOPPED + + def delete(self) -> None: + """ + Deletes the Postgres database server. + + It is up to the caller to make sure stop() is called first. + """ + shutil.rmtree(self.db_path) + + def get_status(self) -> DatabaseStatus: + """ + Returns the status of a Postgres database given a database path. + """ + if not self.db_path.exists(): + return DatabaseStatus.DOES_NOT_EXIST + + if not self.db_path.is_dir(): + return DatabaseStatus.INVALID_DIRECTORY + + if not any(self.db_path.iterdir()): + return DatabaseStatus.DIRECTORY_EMPTY + + status_args = [self.pg_ctl, "status", "-D", str(self.db_path)] + result = self._run(status_args) + output = result.stdout.strip() + + if "pg_ctl: server is running" in output: + return DatabaseStatus.RUNNING + + if "pg_ctl: no server running" in output: + return DatabaseStatus.STOPPED + + print(output, file=sys.stderr) + return DatabaseStatus.UNKNOWN