Skip to content

Commit 567ce08

Browse files
committed
Add User Functionality
1 parent 2bdcc01 commit 567ce08

7 files changed

Lines changed: 299 additions & 42 deletions

File tree

AGENTS.md

Lines changed: 30 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,38 @@
11
# Repository Guidelines
22

3-
## Project Structure & Modules
4-
- `aegis/`: CLI source (Typer app). Entry point: `aegis.__main__:app` (script: `aegis`).
5-
- `aegis/templates/`: Cookiecutter project templates used by the CLI.
6-
- `tests/`: Pytest suite (CLI and template validation).
7-
- `docs/` + `mkdocs.yml`: MkDocs documentation sources and config.
8-
- `Makefile`: Local dev commands for lint, typecheck, tests, docs.
9-
- `pyproject.toml`: Dependencies and tooling config (ruff, mypy, pytest).
3+
## Project Structure & Module Organization
4+
- `aegis/`: Typer CLI source; entrypoint `aegis.__main__:app`.
5+
- `aegis/templates/`: Cookiecutter templates consumed by CLI generators.
6+
- `tests/`: Pytest suite, includes CLI and template regression checks.
7+
- `docs/` + `mkdocs.yml`: MkDocs content and configuration.
8+
- `Makefile`, `pyproject.toml`: central automation and tooling settings.
109

11-
## Build, Test, and Development
12-
- Install: `uv sync --all-extras` (or `make install`) — sets up dev + docs extras.
13-
- Lint: `make lint` — ruff checks; Auto-fix/format: `make fix` or `make format`.
14-
- Type check: `make typecheck` — mypy (strict).
15-
- Tests: `make test` — runs pytest; all checks: `make check`.
16-
- Docs: `make docs-serve` (localhost:8001) or `make docs-build`.
17-
- CLI smoke test: `make cli-test`.
10+
## Build, Test, and Development Commands
11+
- `uv sync --all-extras` or `make install`: provision dev + docs dependencies with uv.
12+
- `make lint`: run ruff lint; use `make fix` / `make format` for autofix.
13+
- `make typecheck`: strict mypy pass.
14+
- `make test`: pytest all suites; `make cli-test` for smoke CLI invocation.
15+
- `make check`: aggregate lint, typecheck, tests; `make docs-serve` for live docs.
1816

19-
## Coding Style & Naming
20-
- Formatter/Lint: ruff (PEP 8-ish). Line length 88, double quotes, spaces for indent.
21-
- Imports: ruff-isort rules; keep sections clean and sorted.
22-
- Typing: Python 3.11, mypy strict; prefer precise types and return annotations.
23-
- Naming: `snake_case` for modules/functions, `CapWords` for classes, `SCREAMING_SNAKE_CASE` for constants.
24-
- Pre-commit: `pre-commit install` then commit; hooks run ruff+format and mypy.
17+
## Coding Style & Naming Conventions
18+
- Python 3.11, strict typing; prefer precise annotations.
19+
- Formatting via ruff; 88 char line length, double quotes, spaces for indent.
20+
- Imports sorted by ruff-isort; maintain logical sections (stdlib, third-party, local).
21+
- Naming: functions/modules snake_case, classes CapWords, constants SCREAMING_SNAKE_CASE.
2522

2623
## Testing Guidelines
27-
- Framework: pytest (asyncio auto). Test paths under `tests/`.
28-
- Markers: `@pytest.mark.slow`, `@pytest.mark.integration` available.
29-
- Quick run: `uv run pytest -q -m "not slow"`; full matrix: `make test-stacks` or `make test-stacks-build`.
30-
- Template tests live in `tests/cli/` and validate generated projects and Docker configs.
24+
- Tests live under `tests/`; name files `test_*.py` and follow pytest fixtures.
25+
- Use pytest markers `slow` / `integration` to segment longer suites.
26+
- Template changes require `make test-template`; quick loop `uv run pytest -q -m "not slow"`.
27+
- Maintain coverage of generated projects when updating `aegis/templates/`.
3128

32-
## Commit & Pull Requests
33-
- Style: Prefer Conventional Commits (e.g., `feat:`, `fix:`, `docs:`, `ci:`). Imperative, present tense.
34-
- Scope examples: `feat(cli): add components list`.
35-
- PRs: include description, rationale, linked issues, and screenshots or CLI output for UX/docs changes.
36-
- Before pushing: run `make check`. For template changes, run `make test-template`.
37-
38-
## Security & Configuration
39-
- Secrets: never commit `.env`; use `.env.example` as reference.
40-
- Supply chain: `uv run pip-audit` for dependency advisories.
41-
- Containers/Redis helpers: see `make redis-*` targets for local experiments.
29+
## Commit & Pull Request Guidelines
30+
- Commit messages follow Conventional Commits, e.g., `feat(cli): add stack scaffold`.
31+
- Before pushing, run `make check` and attach relevant CLI/doc output in PRs.
32+
- PR descriptions should state rationale, link issues, and note template impacts or migration steps.
33+
- Install pre-commit hooks (`pre-commit install`) to enforce lint/type gates locally.
4234

35+
## Security & Configuration Tips
36+
- Never commit secrets; keep `.env` out of VCS and update `.env.example`.
37+
- Review dependencies with `uv run pip-audit` when bumping packages.
38+
- Leverage `make redis-*` targets for local container helpers without polluting system services.

aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/Makefile

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -177,21 +177,21 @@ docs-build: ## Build static documentation
177177

178178
migrate: ## Apply database migrations
179179
@echo "🗃️ Applying database migrations..."
180-
@docker compose exec webserver alembic upgrade head
180+
@docker compose exec webserver uv run alembic -c alembic/alembic.ini upgrade head
181181

182182
migrate-check: ## Check migration status
183183
@echo "🔍 Checking migration status..."
184-
@docker compose exec webserver alembic current
184+
@docker compose exec webserver uv run alembic -c alembic/alembic.ini current
185185

186186
migrate-history: ## Show migration history
187187
@echo "📜 Migration history:"
188-
@docker compose exec webserver alembic history --verbose
188+
@docker compose exec webserver uv run alembic -c alembic/alembic.ini history --verbose
189189

190190
migrate-reset: ## Reset database (WARNING: destructive)
191191
@echo "⚠️ This will destroy all data in the database!"
192192
@read -p "Are you sure? Type 'yes' to continue: " confirm && [ "$$confirm" = "yes" ] || exit 1
193-
@docker compose exec webserver alembic downgrade base
194-
@docker compose exec webserver alembic upgrade head
193+
@docker compose exec webserver uv run alembic -c alembic/alembic.ini downgrade base
194+
@docker compose exec webserver uv run alembic -c alembic/alembic.ini upgrade head
195195
@echo "✅ Database reset complete"
196196

197197
{% endif %}
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
"""
2+
Authentication CLI commands.
3+
4+
Command-line interface for auth service management tasks.
5+
"""
6+
7+
import secrets
8+
import string
9+
from typing import TYPE_CHECKING
10+
11+
if TYPE_CHECKING:
12+
pass
13+
14+
import typer
15+
16+
from app.core.db import db_session
17+
from app.models.user import UserCreate
18+
from app.services.auth.user_service import UserService
19+
20+
app = typer.Typer(help="Authentication management commands")
21+
22+
23+
def generate_password(length: int = 12) -> str:
24+
"""Generate a secure random password."""
25+
alphabet = string.ascii_letters + string.digits + "!@#$%^&*"
26+
return "".join(secrets.choice(alphabet) for _ in range(length))
27+
28+
29+
def find_next_available_email(
30+
user_service: UserService, prefix: str = "test", domain: str = "example.com"
31+
) -> str:
32+
"""Find the next available email with auto-increment."""
33+
# Get existing emails with this prefix
34+
existing_emails = user_service.find_existing_emails_with_prefix(prefix, domain)
35+
36+
if not existing_emails:
37+
# No existing emails, start with prefix@domain
38+
return f"{prefix}@{domain}"
39+
40+
# Extract numbers from existing emails
41+
used_numbers = set()
42+
for email in existing_emails:
43+
# Extract the part before @
44+
local_part = email.split("@")[0]
45+
46+
# Check if it matches our pattern (prefix + optional number)
47+
if local_part == prefix:
48+
used_numbers.add(0) # Base email without number
49+
elif local_part.startswith(prefix):
50+
suffix = local_part[len(prefix):]
51+
if suffix.isdigit():
52+
used_numbers.add(int(suffix))
53+
54+
# Find the next available number
55+
counter = 0
56+
while counter in used_numbers:
57+
counter += 1
58+
59+
# Return the email with the next number
60+
if counter == 0:
61+
return f"{prefix}@{domain}"
62+
else:
63+
return f"{prefix}{counter}@{domain}"
64+
65+
66+
@app.command()
67+
def create_test_user(
68+
email: str | None = typer.Option(
69+
None,
70+
help=(
71+
"User email address (auto-increment: test@example.com, "
72+
"test1@example.com, etc.)"
73+
),
74+
),
75+
password: str | None = typer.Option(
76+
None, help="User password (generated if not provided)"
77+
),
78+
full_name: str | None = typer.Option(None, help="User full name"),
79+
prefix: str = typer.Option("test", help="Email prefix for auto-generated emails"),
80+
domain: str = typer.Option(
81+
"example.com", help="Email domain for auto-generated emails"
82+
),
83+
) -> None:
84+
"""Create a test user for development and testing."""
85+
86+
# Generate password if not provided
87+
if password is None:
88+
password = generate_password()
89+
generated_password = True
90+
else:
91+
generated_password = False
92+
93+
try:
94+
with db_session() as session:
95+
user_service = UserService(session)
96+
97+
# Auto-generate email if not provided
98+
if email is None:
99+
email = find_next_available_email(user_service, prefix, domain)
100+
typer.echo(f"📧 Auto-generated email: {email} (next in sequence)")
101+
102+
# Check if user already exists
103+
existing_user = user_service.get_user_by_email(email)
104+
if existing_user:
105+
typer.echo(f"❌ User with email '{email}' already exists", err=True)
106+
raise typer.Exit(1)
107+
108+
# Create user data
109+
user_data = UserCreate(
110+
email=email,
111+
password=password,
112+
full_name=full_name
113+
)
114+
115+
# Create the user
116+
user = user_service.create_user(user_data)
117+
118+
# Display success message
119+
typer.echo("✅ Test user created successfully!")
120+
typer.echo("=" * 50)
121+
typer.echo(f"📧 Email: {user.email}")
122+
typer.echo(f"🔑 Password: {password}")
123+
if user.full_name:
124+
typer.echo(f"👤 Name: {user.full_name}")
125+
typer.echo(f"🆔 User ID: {user.id}")
126+
typer.echo("=" * 50)
127+
128+
if generated_password:
129+
typer.echo("💡 Password was auto-generated. Save it for testing!")
130+
131+
typer.echo("🚀 Ready to test auth endpoints at http://localhost:8000/docs")
132+
133+
except Exception as e:
134+
typer.echo(f"❌ Failed to create test user: {str(e)}", err=True)
135+
raise typer.Exit(1)
136+
137+
138+
@app.command()
139+
def create_test_users(
140+
count: int = typer.Option(5, help="Number of test users to create"),
141+
prefix: str = typer.Option("test", help="Email prefix for generated users"),
142+
domain: str = typer.Option("example.com", help="Email domain for generated users"),
143+
password: str | None = typer.Option(
144+
None, help="Shared password (generated if not provided)"
145+
),
146+
) -> None:
147+
"""Create multiple test users for development and testing."""
148+
149+
# Generate password if not provided
150+
if password is None:
151+
password = generate_password()
152+
generated_password = True
153+
else:
154+
generated_password = False
155+
156+
if count <= 0:
157+
typer.echo("❌ Count must be greater than 0", err=True)
158+
raise typer.Exit(1)
159+
160+
try:
161+
with db_session() as session:
162+
user_service = UserService(session)
163+
created_users = []
164+
165+
typer.echo(f"🚀 Creating {count} test users with prefix '{prefix}'...")
166+
typer.echo("=" * 60)
167+
168+
for i in range(count):
169+
# Find next available email
170+
email = find_next_available_email(user_service, prefix, domain)
171+
172+
# Create user data
173+
user_data = UserCreate(
174+
email=email,
175+
password=password,
176+
full_name=f"Test User {email.split('@')[0].capitalize()}"
177+
)
178+
179+
# Create the user
180+
user = user_service.create_user(user_data)
181+
created_users.append(user)
182+
183+
typer.echo(f"✅ Created: {user.email} (ID: {user.id})")
184+
185+
# Display summary
186+
typer.echo("=" * 60)
187+
typer.echo(f"🎉 Successfully created {len(created_users)} test users!")
188+
typer.echo(f"🔑 Shared password: {password}")
189+
190+
if generated_password:
191+
typer.echo("💡 Password was auto-generated. Save it for testing!")
192+
193+
typer.echo("🚀 Ready to test auth endpoints at http://localhost:8000/docs")
194+
195+
except Exception as e:
196+
typer.echo(f"❌ Failed to create test users: {str(e)}", err=True)
197+
raise typer.Exit(1)
198+
199+
200+
@app.command()
201+
def list_users() -> None:
202+
"""List all users in the system."""
203+
try:
204+
with db_session() as session:
205+
user_service = UserService(session)
206+
users = user_service.list_users()
207+
208+
if not users:
209+
typer.echo("No users found.")
210+
return
211+
212+
typer.echo(f"Found {len(users)} user(s):")
213+
typer.echo("=" * 60)
214+
215+
for user in users:
216+
typer.echo(f"🆔 ID: {user.id}")
217+
typer.echo(f"📧 Email: {user.email}")
218+
if user.full_name:
219+
typer.echo(f"👤 Name: {user.full_name}")
220+
typer.echo(f"📅 Created: {user.created_at}")
221+
typer.echo("-" * 40)
222+
223+
except Exception as e:
224+
typer.echo(f"❌ Failed to list users: {str(e)}", err=True)
225+
raise typer.Exit(1)
226+
227+
228+
if __name__ == "__main__":
229+
app()

aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/cli/main.py.j2

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@ except ImportError:
3939
# Scheduler components not available, skip tasks commands
4040
pass
4141

42+
# Conditionally register auth command if auth service is available
43+
try:
44+
auth_module = importlib.import_module("app.cli.auth")
45+
app.add_typer(auth_module.app, name="auth")
46+
except ImportError:
47+
# Auth service not available, skip auth commands
48+
pass
49+
4250

4351
def main() -> None:
4452
"""Entry point for the CLI application."""
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# This file exists to ensure auth.py is only included when auth service is selected
2+
# The actual implementation is in auth.py.j2 which gets processed during template generation

aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/startup/database_init.py.j2

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,19 @@ async def startup_database_init() -> None:
4949
table_names = inspector.get_table_names()
5050

5151
if "alembic_version" in table_names:
52-
logger.info("✅ Database connectivity and auth schema verified (migrations applied)")
52+
logger.info(
53+
"✅ Database connectivity and auth schema verified "
54+
"(migrations applied)"
55+
)
5356
else:
54-
logger.warning("⚠️ alembic_version table missing - migrations may not be fully applied")
55-
logger.warning("💡 Run 'alembic upgrade head' to ensure all migrations are applied")
57+
logger.warning(
58+
"⚠️ alembic_version table missing - migrations may not be "
59+
"fully applied"
60+
)
61+
logger.warning(
62+
"💡 Run 'alembic upgrade head' to ensure all migrations "
63+
"are applied"
64+
)
5665

5766
except Exception as e:
5867
logger.warning(f"⚠️ Database check failed: {e}")

aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/services/auth/user_service.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,16 @@ def update_user(self, user_id: int, **updates) -> User | None:
6565
def deactivate_user(self, user_id: int) -> User | None:
6666
"""Deactivate a user account."""
6767
return self.update_user(user_id, is_active=False)
68+
69+
def list_users(self) -> list[User]:
70+
"""List all users in the system."""
71+
statement = select(User).order_by(User.created_at.desc())
72+
result = self.db.exec(statement)
73+
return list(result.all())
74+
75+
def find_existing_emails_with_prefix(self, prefix: str, domain: str) -> list[str]:
76+
"""Find existing emails that match the pattern prefix{number}@domain."""
77+
pattern = f"{prefix}%@{domain}"
78+
statement = select(User.email).where(User.email.like(pattern))
79+
result = self.db.exec(statement)
80+
return list(result.all())

0 commit comments

Comments
 (0)