Skip to content
Open
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
6 changes: 6 additions & 0 deletions .env.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# .env.test
DB_HOST=localhost
DB_PORT=5432
DB_NAME=inventory_test
DB_USER=postgres
DB_PASSWORD=postgres
31 changes: 31 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]

# Virtual environments
.venv/
venv/
env/

# IDE / Editor
.vscode/
.idea/

# Testing / coverage
.pytest_cache/
.mypy_cache/
.coverage
coverage.xml

# Packaging / build
build/
dist/
*.egg-info/

# Environment / local config
# .env
# .env.*

# OS files
.DS_Store

23 changes: 23 additions & 0 deletions API-SPEC.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# HTTP API Spec

Base URL: `http://localhost:8000`

## Server object
- Fields: `id`, `hostname`, `ip_address`, `datacenter`, `state`, `created_at`, `updated_at`
- Allowed states: `active`, `offline`, `retired`
- Validation: hostname must be unique; IP must be valid IPv4/IPv6; state must be allowed.

## Endpoints
- `POST /servers` — create
- Body: `{ "hostname": str, "ip_address": str, "datacenter": str, "state": str }`
- Responses: `201` with server; `409` duplicate hostname; `422` validation.
- `GET /servers` — list all
- Responses: `200` with `[]` when empty.
- `GET /servers/{id}` — fetch one
- Responses: `200` with server; `404` if missing.
- `PUT /servers/{id}` — update (partial allowed)
- Body: any subset of `hostname`, `ip_address`, `datacenter`, `state`
- Responses: `200` updated server; `404` if missing; `409` on hostname conflict; `422` on validation.
- `DELETE /servers/{id}` — delete
- Responses: `200` `{ "detail": "Server deleted" }`; `404` if missing.

65 changes: 65 additions & 0 deletions API.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Inventory Guide

How to run the stack locally and where to find detailed specs. Also how to test the stack using CLI

## Run the stack
- **Prereqs:** Docker + Docker Compose, Python 3.12, pip.
- **Start everything (API + Postgres):** `docker-compose up --build`. API
listens on `http://localhost:8000`.
- create python virtual environment for CLI
- `python -m venv .venv && source .venv/bin/activate`
- `pip install -r requirements.txt`
- **Default DB env vars:** `DB_HOST=localhost`, `DB_PORT=5432`, `DB_NAME=inventory`, `DB_USER=postgres`, `DB_PASSWORD=postgres`.
- **API docs:** `http://localhost:8000/docs` (Swagger) or `http://localhost:8000/redoc`.
- **Tests:** `pytest` (uses in-memory fakes; no real DB needed).

## Specs
- HTTP API spec: see `API-SPEC.md`.
- CLI spec: see `CLI-SPEC.md`.

## Example Testing Scenario Using CLI

### Prepare the environment
```bash
# open terminal in the root of the repo
docker compose up
# open a new terminal or a new tab in the root of the repo
python -m venv .venv && source .venv/bin/activate
# alternatively use this command python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
pytest tests/
```

```bash
# Test basic commands
./cli.sh create srv-1 10.0.0.1 us-east active
./cli.sh list
./cli.sh get 1
./cli.sh update 1 --state offline
./cli.sh update 1 --state retired
./cli.sh delete 1 --confirm

# Test Validation
# Hostname unique
./cli.sh create srv-dup 10.0.0.2 us-east active
./cli.sh create srv-dup 10.0.0.3 us-east active
# IP must look like IP
./cli.sh create bad-ip not-an-ip us-east active
./cli.sh create bad-ip 1111.1.1.1 us-east active
# State must be active/offline/retired:
./cli.sh create bad-state 10.0.0.4 us-east retiring
# Update validation
./cli.sh update 2 --ip-address not-an-ip
./cli.sh update 2 --ip-address 1111.1.1.1
./cli.sh update 2 --state retiring
# Update hostname uniqueness:
./cli.sh create srv-a 10.0.0.5 us-east active
./cli.sh create srv-b 10.0.0.6 us-east active
# Then try to duplicate:
./cli.sh update 4 --hostname srv-a
```


## Running Unit Tests

To run all unit tests run the command `pytest tests/`.
22 changes: 22 additions & 0 deletions CLI-SPEC.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# CLI Spec

For ease of use there is a cli.sh script that runs the python cli. It replaces `python -m cli.main` with `./cli.sh ...` e.g. `python -m cli.main create srv-1 10.0.0.1 us-east active` is replaced by `./cli.sh create srv-1 10.0.0.1 us-east active`

Entrypoint: `python -m cli.main ...`
Uses `API_BASE_URL` to target the API (default `http://localhost:8000`). Add `--help` to any command for usage.

- `create <hostname> <ip_address> <datacenter> <state>` — create a server; prints JSON.
- `list` — list all servers in a table.
- `get <id>` — show one server as JSON.
- `update <id> [--hostname ...] [--ip-address ...] [--datacenter ...] [--state ...]` — partial update; requires at least one option.
- `delete <id> [--confirm]` — delete; prompts unless `--confirm` set.

## Common workflows
- Happy-path CRUD (with API running):
- `python -m cli.main create srv-1 10.0.0.1 us-east active`
- `python -m cli.main list`
- `python -m cli.main get 1`
- `python -m cli.main update 1 --state offline`
- `python -m cli.main delete 1 --confirm`
- Override API base URL: `API_BASE_URL=https://api.example.com python -m cli.main list`

Empty file added app/__init__.py
Empty file.
Empty file added app/db/__init__.py
Empty file.
76 changes: 76 additions & 0 deletions app/db/connection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import os
from psycopg2.pool import SimpleConnectionPool
from psycopg2.extensions import connection as Connection


def get_database_url() -> str:
"""
Construct database URL from environment variables.
Returns PostgreSQL connection string.
"""
db_host = os.getenv("DB_HOST", "localhost")
db_port = os.getenv("DB_PORT", "5432")
db_name = os.getenv("DB_NAME", "inventory")
db_user = os.getenv("DB_USER", "postgres")
db_password = os.getenv("DB_PASSWORD", "postgres")

return f"postgresql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}"


def create_connection_pool() -> SimpleConnectionPool:
"""
Create and configure a database connection pool.
Returns a psycopg2 connection pool instance.
"""
database_url = get_database_url()

# Create a connection pool with min 1 and max 10 connections
pool = SimpleConnectionPool(
minconn=1,
maxconn=10,
dsn=database_url
)

return pool


def init_db() -> None:
"""
Initialize the database schema.
Creates the servers table if it doesn't exist.
Should be called on application startup.
"""
pool = create_connection_pool()
conn = pool.getconn()

try:
with conn.cursor() as cursor:
# Create servers table with all required fields and constraints
cursor.execute("""
CREATE TABLE IF NOT EXISTS servers (
id SERIAL PRIMARY KEY,
hostname VARCHAR(255) UNIQUE NOT NULL,
ip_address VARCHAR(45) NOT NULL,
datacenter VARCHAR(255) NOT NULL,
state VARCHAR(50) NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
)
""")

# Create an index on hostname for faster lookups
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_servers_hostname
ON servers(hostname)
""")

# Create an index on state for filtering
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_servers_state
ON servers(state)
""")

conn.commit()
finally:
pool.putconn(conn)
pool.closeall()
158 changes: 158 additions & 0 deletions app/db/queries.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
from typing import List, Optional, Any
from psycopg2.extensions import connection as Connection
from datetime import datetime


def row_to_dict(cursor, row) -> dict:
"""
Convert a database row to a dictionary using cursor description.
"""
if row is None:
return None

columns = [desc[0] for desc in cursor.description]
return dict(zip(columns, row))


def insert_server(conn: Connection, hostname: str, ip_address: str,
datacenter: str, state: str) -> int:
"""
Execute SQL INSERT to create a new server record.
Returns the ID of the newly created server.
"""
with conn.cursor() as cursor:
cursor.execute("""
INSERT INTO servers (hostname, ip_address, datacenter, state)
VALUES (%s, %s, %s, %s)
RETURNING id
""", (hostname, ip_address, datacenter, state))

server_id = cursor.fetchone()[0]
conn.commit()

return server_id


def select_all_servers(conn: Connection) -> List[dict]:
"""
Execute SQL SELECT to retrieve all server records.
Returns list of dictionaries representing server rows.
"""
# TODO: Add pagination support (LIMIT and OFFSET parameters)
with conn.cursor() as cursor:
cursor.execute("""
SELECT id, hostname, ip_address, datacenter, state,
created_at, updated_at
FROM servers
ORDER BY id ASC
""")

rows = cursor.fetchall()
return [row_to_dict(cursor, row) for row in rows]


def select_server_by_id(conn: Connection, server_id: int) -> Optional[dict]:
"""
Execute SQL SELECT to retrieve a single server by ID.
Returns dictionary representing the server row, or None if not found.
"""
with conn.cursor() as cursor:
cursor.execute("""
SELECT id, hostname, ip_address, datacenter, state,
created_at, updated_at
FROM servers
WHERE id = %s
""", (server_id,))

row = cursor.fetchone()

if row is None:
return None

return row_to_dict(cursor, row)


def select_server_by_hostname(conn: Connection, hostname: str) -> Optional[dict]:
"""
Execute SQL SELECT to retrieve a server by hostname.
Used for hostname uniqueness validation.
Returns dictionary representing the server row, or None if not found.
"""
with conn.cursor() as cursor:
cursor.execute("""
SELECT id, hostname, ip_address, datacenter, state,
created_at, updated_at
FROM servers
WHERE hostname = %s
""", (hostname,))

row = cursor.fetchone()

if row is None:
return None

return row_to_dict(cursor, row)


def update_server_by_id(conn: Connection, server_id: int, **fields) -> Optional[dict]:
"""
Execute SQL UPDATE to modify server fields.
Dynamically builds UPDATE statement based on provided fields.
Returns the updated server record, or None if server not found.
"""
if not fields:
# If no fields provided, just return the current record
return select_server_by_id(conn, server_id)

# Valid fields that can be updated
valid_fields = {'hostname', 'ip_address', 'datacenter', 'state'}

# Filter out invalid fields
update_fields = {k: v for k, v in fields.items() if k in valid_fields}

if not update_fields:
# If no valid fields to update, just return the current record
return select_server_by_id(conn, server_id)

# Always update the updated_at timestamp
update_fields['updated_at'] = datetime.now()

# Build the SET clause dynamically
set_clause = ', '.join([f"{field} = %s" for field in update_fields.keys()])
values = list(update_fields.values())
values.append(server_id) # Add server_id for WHERE clause

with conn.cursor() as cursor:
cursor.execute(f"""
UPDATE servers
SET {set_clause}
WHERE id = %s
RETURNING id, hostname, ip_address, datacenter, state,
created_at, updated_at
""", values)

row = cursor.fetchone()
conn.commit()

if row is None:
return None

return row_to_dict(cursor, row)


def delete_server_by_id(conn: Connection, server_id: int) -> bool:
"""
Execute SQL DELETE to remove a server record.
Returns True if server was deleted, False if not found.
"""
with conn.cursor() as cursor:
cursor.execute("""
DELETE FROM servers
WHERE id = %s
RETURNING id
""", (server_id,))

deleted_row = cursor.fetchone()
conn.commit()

return deleted_row is not None
Loading