Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 51 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,14 @@ The goal of this project is to provide a Python-based starter API, which comes p
## Table of Contents

1. [Running the Project Locally](#running-the-project-locally)
2. [Running with Docker](#running-with-docker)
3. [Running Unit Tests](#running-unit-tests)
4. [Running Code Quality Checks](#running-code-quality-checks)
5. [Running Code Formatting](#running-code-formatting)
6. [Publishing Updated Docs](#publishing-updated-docs)
7. [Contributing](#contributing)
8. [Next Steps](#next-steps)
2. [Initializing PostgreSQL Database](#initializing-postgresql-database)
3. [Running with Docker](#running-with-docker)
4. [Running Unit Tests](#running-unit-tests)
5. [Running Code Quality Checks](#running-code-quality-checks)
6. [Running Code Formatting](#running-code-formatting)
7. [Publishing Updated Docs](#publishing-updated-docs)
8. [Contributing](#contributing)
9. [Next Steps](#next-steps)

## Running the Project Locally

Expand Down Expand Up @@ -49,7 +50,8 @@ pip install -e ".[dev]"
```
API_PREFIX=[SOME_ROUTE] # Ex: '/api'
DATABASE_URL=[SOME_URL] # Ex: 'postgresql://username:password@localhost:5432/database_name'
OIDC_CONFIG_URL=[SOME_URL] # Ex: 'https://token.actions.githubusercontent.com/.well-known/openid-configuration'
OIDC_CONFIG_URL=[SOME_URL] # Ex: 'https://keycloak.auth.metrostar.cloud/auth/realms/dev/.well-known/openid-configuration'
LOG_LEVEL=[LOG_LEVEL] # Ex: 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL' (Default: 'INFO')
```

5. To start the app, run the following:
Expand All @@ -60,6 +62,47 @@ uvicorn app.main:app --reload --host=0.0.0.0 --port=5000

6. Access the swagger docs by navigating to: `http://0.0.0.0:5000/docs`

## Initializing PostgreSQL Database

If you're using PostgreSQL instead of SQLite, you can use the provided initialization script to set up your database:

1. Ensure your `.env` file contains a PostgreSQL DATABASE_URL:

```
DATABASE_URL=postgresql://username:password@localhost:5432/database_name
```

2. Run the initialization script using either method:

**Using the shell script:**

```sh
./scripts/init_db.sh
```

**Or using Python directly:**

```sh
python scripts/init_postgres.py
```

3. To seed initial data along with the schema (optional):

```sh
./scripts/init_db.sh --seed
```

**Script Options:**

- `--seed`: Seed initial data after running migrations
- `--skip-create`: Skip database creation (only run migrations)

The script will:

- Create the database if it doesn't exist
- Run all Alembic migrations to set up the schema
- Optionally seed initial data

## Running with Docker

1. To build the image, run the following:
Expand Down Expand Up @@ -136,7 +179,6 @@ The following provides a short list of tasks which are potential next steps for

- [ ] Add/Update existing endpoints with more applicable entities and/or columns
- [ ] Update applicable endpoints to require JWT
- [ ] Add Admin endpoints to support password reset
- [ ] Replace default database with external database (Ex. Postgres)
- [ ] Deploy to cloud infrastructure
- [ ] Automate doc publishing process
10 changes: 8 additions & 2 deletions app/admin/router.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from typing import Annotated

from fastapi import APIRouter, Depends
from fastapi.security import HTTPBearer
from starlette import status

from app.auth import validate_jwt
Expand All @@ -15,8 +14,15 @@

@router.get(
"/current-user",
dependencies=[Depends(HTTPBearer())],
status_code=status.HTTP_200_OK,
)
async def get_current_user(current_user: Annotated[dict, Depends(validate_jwt)]):
"""Get the current authenticated user information.

Args:
current_user: Validated JWT payload containing user information.

Returns:
dict: User information from the JWT token.
"""
return {"user": current_user}
6 changes: 6 additions & 0 deletions app/applicants/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@


class DBApplicant(Base):
"""SQLAlchemy model for applicant data.

Represents an applicant in the database with personal information,
contact details, and address information.
"""

__tablename__ = "applicants"

id = Column(Integer, primary_key=True, index=True)
Expand Down
44 changes: 44 additions & 0 deletions app/applicants/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,30 @@

@router.get("/", status_code=status.HTTP_200_OK, response_model=ApplicantListResponse)
async def get_applicants(db: db_session, page_number: int = 0, page_size: int = 100):
"""Retrieve a paginated list of all applicants.

Args:
db: Database session.
page_number: Page number for pagination (default: 0).
page_size: Number of items per page (default: 100).

Returns:
ApplicantListResponse: Paginated list of applicants.
"""
return service.get_items(db, page_number, page_size)


@router.post("/", status_code=status.HTTP_201_CREATED, response_model=ApplicantResponse)
async def create_applicant(applicant: ApplicantCreate, db: db_session):
"""Create a new applicant.

Args:
applicant: Applicant data to create.
db: Database session.

Returns:
ApplicantResponse: The created applicant.
"""
db_applicant = service.create_item(db, applicant)
return db_applicant

Expand All @@ -39,6 +58,15 @@ async def create_applicant(applicant: ApplicantCreate, db: db_session):
"/{applicant_id}", status_code=status.HTTP_200_OK, response_model=ApplicantResponse
)
async def get_applicant(applicant_id: int, db: db_session):
"""Retrieve a single applicant by ID.

Args:
applicant_id: ID of the applicant to retrieve.
db: Database session.

Returns:
ApplicantResponse: The requested applicant.
"""
return service.get_item(db, applicant_id)


Expand All @@ -48,10 +76,26 @@ async def get_applicant(applicant_id: int, db: db_session):
async def update_applicant(
applicant_id: int, applicant: ApplicantUpdate, db: db_session
):
"""Update an existing applicant.

Args:
applicant_id: ID of the applicant to update.
applicant: Updated applicant data.
db: Database session.

Returns:
ApplicantResponse: The updated applicant.
"""
db_applicant = service.update_item(db, applicant_id, applicant)
return db_applicant


@router.delete("/{applicant_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_applicant(applicant_id: int, db: db_session):
"""Delete an applicant.

Args:
applicant_id: ID of the applicant to delete.
db: Database session.
"""
service.delete_item(db, applicant_id)
22 changes: 21 additions & 1 deletion app/applicants/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@

# Pydantic Models
class ApplicantBase(BaseModel):
"""Base Pydantic model for applicant data.

Contains common fields shared across create, update, and response models.
"""

first_name: str = Field(..., min_length=1, max_length=50)
last_name: str = Field(..., min_length=1, max_length=50)
middle_name: str | None = Field(None, min_length=1, max_length=50)
Expand All @@ -22,10 +27,15 @@ class ApplicantBase(BaseModel):


class ApplicantCreate(ApplicantBase):
pass
"""Pydantic model for creating a new applicant."""


class ApplicantUpdate(BaseModel):
"""Pydantic model for updating an existing applicant.

All fields are optional to support partial updates.
"""

first_name: str | None = None
last_name: str | None = None
middle_name: str | None = None
Expand All @@ -43,13 +53,23 @@ class ApplicantUpdate(BaseModel):


class ApplicantResponse(ApplicantBase):
"""Pydantic model for applicant API responses.

Includes database-generated fields like id, created_at, and updated_at.
"""

model_config = ConfigDict(from_attributes=True)
id: int
created_at: datetime
updated_at: datetime


class ApplicantListResponse(BaseModel):
"""Pydantic model for paginated list of applicants.

Contains pagination metadata along with the list of applicants.
"""

items: list[ApplicantResponse]
item_count: int = 0
page_count: int = 0
Expand Down
80 changes: 76 additions & 4 deletions app/applicants/services.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,30 @@
import logging

from fastapi import HTTPException
from sqlalchemy.orm import Session

from app.applicants.models import DBApplicant
from app.applicants.schemas import ApplicantCreate, ApplicantUpdate
from app.utils import get_next_page, get_page_count, get_prev_page

logger = logging.getLogger(__name__)


def get_items(db: Session, page_number: int, page_size: int):
"""Retrieve a paginated list of applicants.

Args:
db: Database session.
page_number: Current page number (0-indexed).
page_size: Number of items per page.

Returns:
dict: Paginated response containing applicants and pagination metadata.
"""
logger.debug("Fetching applicants - page: %s, size: %s", page_number, page_size)
item_count = db.query(DBApplicant).count()
items = db.query(DBApplicant).limit(page_size).offset(page_number * page_size).all()
logger.info("Retrieved %s applicants (total: %s)", len(items), item_count)

return {
"items": items,
Expand All @@ -20,42 +36,98 @@ def get_items(db: Session, page_number: int, page_size: int):


def create_item(db: Session, applicant: ApplicantCreate):
"""Create a new applicant in the database.

Args:
db: Database session.
applicant: Applicant data to create.

Returns:
DBApplicant: The created applicant record.
"""
logger.debug("Creating new applicant with email: %s", applicant.email)
db_applicant = DBApplicant(**applicant.model_dump())
db.add(db_applicant)
db.commit()
db.refresh(db_applicant)
logger.info("Created applicant with id: %s", db_applicant.id)

return db_applicant


def get_item(db: Session, applicant_id: int):
return db.query(DBApplicant).where(DBApplicant.id == applicant_id).first()
"""Retrieve a single applicant by ID.

Args:
db: Database session.
applicant_id: ID of the applicant to retrieve.

Returns:
DBApplicant | None: The applicant record if found, None otherwise.
"""
logger.debug("Fetching applicant with id: %s", applicant_id)
applicant = db.query(DBApplicant).where(DBApplicant.id == applicant_id).first()
if applicant:
logger.info("Retrieved applicant with id: %s", applicant_id)
else:
logger.warning("Applicant not found with id: %s", applicant_id)
return applicant


def update_item(db: Session, id: int, applicant: ApplicantUpdate):
"""Update an existing applicant.

Args:
db: Database session.
id: ID of the applicant to update.
applicant: Updated applicant data.

Returns:
DBApplicant: The updated applicant record.

Raises:
HTTPException: If applicant is not found (404).
"""
logger.debug("Updating applicant with id: %s", id)
db_applicant = db.query(DBApplicant).filter(DBApplicant.id == id).first()
if db_applicant is None:
logger.warning("Applicant not found for update with id: %s", id)
raise HTTPException(status_code=404, detail="Applicant not found")

# Only update fields that are provided (not None)
# Update only the fields that are explicitly set in the request
update_data = applicant.model_dump(exclude_unset=True)
for field, value in update_data.items():
if value is not None:
setattr(db_applicant, field, value)
setattr(db_applicant, field, value)

db.add(db_applicant)
db.commit()
db.refresh(db_applicant)
logger.info("Updated applicant with id: %s", id)

return db_applicant


def delete_item(db: Session, id: int):
"""Delete an applicant from the database.

Args:
db: Database session.
id: ID of the applicant to delete.

Returns:
None

Raises:
HTTPException: If applicant is not found (404).
"""
logger.debug("Deleting applicant with id: %s", id)
db_applicant = db.query(DBApplicant).filter(DBApplicant.id == id).first()
if db_applicant is None:
logger.warning("Applicant not found for deletion with id: %s", id)
raise HTTPException(status_code=404, detail="Applicant not found")

db.query(DBApplicant).filter(DBApplicant.id == id).delete()
db.commit()
logger.info("Deleted applicant with id: %s", id)

return None
Loading