Skip to content

Commit 88eee9e

Browse files
authored
Merge pull request #791 from seapagan/bugfix/cli-database-initialization-crash
Fix CLI crash when database not initialized
2 parents c144a82 + 45211bf commit 88eee9e

9 files changed

Lines changed: 249 additions & 4 deletions

File tree

TODO.md

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,6 @@
3939
- Admin users CAN delete themselves, but this should not be allowed. They should
4040
be able to delete other users, but not themselves. Or should they?? Need to
4141
think about this.
42-
- the `api-admin user` 'list', 'search' and prob others crash if the database is
43-
not initialized. This should be handled gracefully, and the CLI should
44-
prompt the user to initialize the database if it is not already done.
4542

4643
## Auth
4744

app/commands/db.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from sqlalchemy.exc import SQLAlchemyError
1818

1919
from app.database.db import async_session
20+
from app.database.helpers import is_database_initialized
2021
from app.managers.user import ErrorMessages, UserManager
2122
from app.models.enums import RoleType
2223

@@ -266,6 +267,14 @@ async def _populate_db(num_regular_users: int, num_admins: int) -> None:
266267

267268
try:
268269
async with async_session() as session:
270+
# Check if database is initialized
271+
if not await is_database_initialized(session):
272+
rprint(
273+
"\n[red]-> ERROR: Database has not been initialized.\n"
274+
"[yellow]Please run [bold]'api-admin db init'[/bold] "
275+
"to initialize the database first.\n"
276+
)
277+
raise typer.Exit(1)
269278
# Create admin users
270279
admin_count = 0
271280
for _ in range(num_admins):
@@ -385,6 +394,16 @@ def _validate_csv_file(csv_file: Path) -> list[dict[str, str]]:
385394
async def _seed_users_from_csv(csv_file: Path) -> None:
386395
"""Import users from a CSV file into the database."""
387396
try:
397+
# Check if database is initialized first
398+
async with async_session() as check_session:
399+
if not await is_database_initialized(check_session):
400+
rprint(
401+
"\n[red]-> ERROR: Database has not been initialized.\n"
402+
"[yellow]Please run [bold]'api-admin db init'[/bold] "
403+
"to initialize the database first.\n"
404+
)
405+
raise typer.Exit(1) # noqa: TRY301
406+
388407
# Validate the CSV file and get rows
389408
rows = _validate_csv_file(csv_file)
390409

app/commands/user.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from sqlalchemy.exc import SQLAlchemyError
1414

1515
from app.database.db import async_session
16+
from app.database.helpers import is_database_initialized
1617
from app.managers.user import UserManager
1718
from app.models.enums import RoleType
1819
from app.models.user import User
@@ -21,10 +22,30 @@
2122
if TYPE_CHECKING: # pragma: no cover
2223
from collections.abc import Sequence
2324

25+
from sqlalchemy.ext.asyncio import AsyncSession
26+
2427

2528
app = typer.Typer(no_args_is_help=True)
2629

2730

31+
async def check_db_initialized(session: AsyncSession) -> None:
32+
"""Check if database is initialized and exit if not.
33+
34+
Args:
35+
session: The database session to check
36+
37+
Raises:
38+
typer.Exit: If database is not initialized
39+
"""
40+
if not await is_database_initialized(session):
41+
rprint(
42+
"\n[red]-> ERROR: Database has not been initialized.\n"
43+
"[yellow]Please run [bold]'api-admin db init'[/bold] "
44+
"to initialize the database first.\n"
45+
)
46+
raise typer.Exit(1)
47+
48+
2849
def show_table(title: str, user_list: Sequence[User]) -> None:
2950
"""Show User data in a tabulated format."""
3051
console = Console()
@@ -111,6 +132,7 @@ async def _create_user(user_data: dict[str, str | RoleType]) -> None:
111132
"""Async function to create a new user."""
112133
try:
113134
async with async_session() as session:
135+
await check_db_initialized(session)
114136
await UserManager.register(user_data, session)
115137
await session.commit()
116138

@@ -151,6 +173,7 @@ async def _list_users() -> Sequence[User]:
151173
"""Async function to list all users in the database."""
152174
try:
153175
async with async_session() as session:
176+
await check_db_initialized(session)
154177
user_list = await UserManager.get_all_users(session)
155178

156179
except SQLAlchemyError as exc:
@@ -180,6 +203,7 @@ async def _show_user() -> User:
180203
"""Async function to show details for a single user."""
181204
try:
182205
async with async_session() as session:
206+
await check_db_initialized(session)
183207
user = await UserManager.get_user_by_id(user_id, session)
184208
except HTTPException as exc:
185209
rprint(
@@ -208,6 +232,7 @@ async def _verify_user(user_id: int) -> User | None:
208232
"""Async function to verify a user by id."""
209233
try:
210234
async with async_session() as session:
235+
await check_db_initialized(session)
211236
user = await session.get(User, user_id)
212237
if user:
213238
user.verified = True
@@ -249,6 +274,7 @@ async def _ban_user(user_id: int, *, unban: bool | None) -> User | None:
249274
"""Async function to ban or unban a user."""
250275
try:
251276
async with async_session() as session:
277+
await check_db_initialized(session)
252278
user = await session.get(User, user_id)
253279
if user:
254280
user.banned = not unban
@@ -296,6 +322,7 @@ async def _toggle_admin(
296322
"""Async function to toggle admin status for a user."""
297323
try:
298324
async with async_session() as session:
325+
await check_db_initialized(session)
299326
user = await session.get(User, user_id)
300327
if user:
301328
user.role = RoleType.user if remove else RoleType.admin
@@ -333,6 +360,7 @@ async def _delete_user(user_id: int) -> User | None:
333360
"""Async function to delete a user."""
334361
try:
335362
async with async_session() as session:
363+
await check_db_initialized(session)
336364
user = await session.get(User, user_id)
337365
if user:
338366
await session.delete(user)
@@ -389,6 +417,7 @@ async def _search_users() -> list[User]:
389417
"""Async function to search for users."""
390418
try:
391419
async with async_session() as session:
420+
await check_db_initialized(session)
392421
query = await UserManager.search_users(
393422
search_term, field_enum, exact_match=exact
394423
)

app/database/helpers.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
from uuid import UUID
66

77
from passlib.context import CryptContext
8-
from sqlalchemy import insert, select, update
8+
from sqlalchemy import insert, select, text, update
9+
from sqlalchemy.exc import SQLAlchemyError
910
from sqlalchemy.ext.asyncio import AsyncSession
1011

1112
from app.models.api_key import ApiKey
@@ -58,6 +59,28 @@ def verify_password(password: str, hashed_password: str) -> bool:
5859
raise ValueError(error_invalid) from exc
5960

6061

62+
async def is_database_initialized(session: AsyncSession) -> bool:
63+
"""Check if the database has been initialized with Alembic migrations.
64+
65+
This checks for the existence of the alembic_version table, which is
66+
created when Alembic runs migrations.
67+
68+
Args:
69+
session: The database session
70+
71+
Returns:
72+
bool: True if database is initialized, False otherwise
73+
"""
74+
try:
75+
# Try to query the alembic_version table
76+
await session.execute(text("SELECT version_num FROM alembic_version"))
77+
except SQLAlchemyError:
78+
# Table doesn't exist or other database error
79+
return False
80+
else:
81+
return True
82+
83+
6184
async def get_user_by_id_(user_id: int, session: AsyncSession) -> User | None:
6285
"""Return a user by ID."""
6386
return await session.get(User, user_id)

tests/cli/test_cli_create_user.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ class TestCLI:
2626

2727
patch_register_user = "app.commands.user.UserManager.register"
2828
patch_async_session = "app.commands.user.async_session"
29+
patch_is_db_initialized = "app.commands.user.is_database_initialized"
30+
31+
@pytest.fixture(autouse=True)
32+
def _mock_db_initialized(self, mocker) -> None:
33+
"""Mock is_database_initialized to return True for all tests."""
34+
mocker.patch(self.patch_is_db_initialized, return_value=True)
2935

3036
def test_create_user_success(
3137
self, runner: CliRunner, mocker, fake_user_data

tests/cli/test_cli_db_command.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,24 @@ def test_populate_command_custom_count(self, mocker) -> None:
298298
# Verify _populate_db was called with correct arguments
299299
populate_mock.assert_called_once_with(7, 2)
300300

301+
def test_populate_database_not_initialized(self, mocker) -> None:
302+
"""Test populate command shows error when database not initialized."""
303+
# Mock is_database_initialized to return False
304+
mocker.patch(
305+
"app.commands.db.is_database_initialized", return_value=False
306+
)
307+
308+
# Mock the async_session
309+
session_mock = mocker.AsyncMock()
310+
session_mock.__aenter__.return_value = session_mock
311+
mocker.patch("app.commands.db.async_session", return_value=session_mock)
312+
313+
result = CliRunner().invoke(app, ["db", "populate", "--count", "5"])
314+
315+
assert result.exit_code == 1
316+
assert "Database has not been initialized" in result.output
317+
assert "api-admin db init" in result.output
318+
301319

302320
@pytest.mark.asyncio
303321
class TestPopulateDB:

tests/cli/test_cli_db_seed.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,30 @@ def test_seed_missing_default_file(self) -> None:
172172
assert "Error: File" in result.output
173173
assert "does not exist" in result.output
174174

175+
def test_seed_database_not_initialized(self, mocker, tmp_path) -> None:
176+
"""Test seed command shows error when database not initialized."""
177+
# Create a dummy CSV file
178+
dummy_file = tmp_path / "users.seed"
179+
dummy_file.touch()
180+
181+
# Mock is_database_initialized to return False
182+
mocker.patch(
183+
"app.commands.db.is_database_initialized", return_value=False
184+
)
185+
186+
# Mock the async_session
187+
session_mock = mocker.AsyncMock()
188+
session_mock.__aenter__.return_value = session_mock
189+
mocker.patch("app.commands.db.async_session", return_value=session_mock)
190+
191+
result = CliRunner().invoke(
192+
app, ["db", "seed", str(dummy_file), "--force"]
193+
)
194+
195+
assert result.exit_code == 1
196+
assert "Database has not been initialized" in result.output
197+
assert "api-admin db init" in result.output
198+
175199

176200
class TestValidateCsvFile:
177201
"""Test the _validate_csv_file function."""

tests/cli/test_cli_user_command.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,12 @@ class TestCLI:
8282
patch_get_all_users = "app.commands.user.UserManager.get_all_users"
8383
patch_get_user_by_id = "app.commands.user.UserManager.get_user_by_id"
8484
patch_async_session = "app.commands.user.async_session"
85+
patch_is_db_initialized = "app.commands.user.is_database_initialized"
86+
87+
@pytest.fixture(autouse=True)
88+
def _mock_db_initialized(self, mocker) -> None:
89+
"""Mock is_database_initialized to return True for all tests."""
90+
mocker.patch(self.patch_is_db_initialized, return_value=True)
8591

8692
def test_no_command_should_give_help(self, runner: CliRunner) -> None:
8793
"""Test that running with no command should give help."""
@@ -812,3 +818,65 @@ def test_search_users_db_error(self, runner: CliRunner, mocker) -> None:
812818
# Check error message
813819
assert "ERROR searching Users" in result.output
814820
assert "Database connection failed" in result.output
821+
822+
# ------------------------------------------------------------------------ #
823+
# test database initialization check #
824+
# ------------------------------------------------------------------------ #
825+
@pytest.mark.parametrize(
826+
"command_args",
827+
[
828+
["list"],
829+
["show", "1"],
830+
["search", "test"],
831+
["verify", "1"],
832+
["ban", "1"],
833+
["admin", "1"],
834+
["delete", "1"],
835+
],
836+
ids=[
837+
"list",
838+
"show",
839+
"search",
840+
"verify",
841+
"ban",
842+
"admin",
843+
"delete",
844+
],
845+
)
846+
def test_user_commands_database_not_initialized(
847+
self, runner: CliRunner, mocker, command_args
848+
) -> None:
849+
"""Test user commands show error when database not initialized."""
850+
# Override the autouse fixture by patching with False
851+
mocker.patch(self.patch_is_db_initialized, return_value=False)
852+
853+
result = runner.invoke(app, ["user", *command_args])
854+
assert result.exit_code == 1
855+
assert "Database has not been initialized" in result.output
856+
assert "api-admin db init" in result.output
857+
858+
def test_create_user_database_not_initialized(
859+
self, runner: CliRunner, mocker, faker
860+
) -> None:
861+
"""Test create command shows error when database not initialized."""
862+
# Override the autouse fixture by patching with False
863+
mocker.patch(self.patch_is_db_initialized, return_value=False)
864+
865+
result = runner.invoke(
866+
app,
867+
[
868+
"user",
869+
"create",
870+
"--email",
871+
faker.email(),
872+
"--first_name",
873+
faker.first_name(),
874+
"--last_name",
875+
faker.last_name(),
876+
"--password",
877+
faker.password(),
878+
],
879+
)
880+
assert result.exit_code == 1
881+
assert "Database has not been initialized" in result.output
882+
assert "api-admin db init" in result.output

0 commit comments

Comments
 (0)