From 8950ddfa2520ae7f296f84906ea5ed9a4abec674 Mon Sep 17 00:00:00 2001 From: Yevhen Okolielov Date: Fri, 16 Jan 2026 16:18:35 +0200 Subject: [PATCH 1/2] inventory management --- .env | 5 ++ .gitignore | 4 ++ Dockerfile | 13 ++++ REAMDE.md | 164 ++++++++++++++++++++++++++++++++++++++++++ app/__init__.py | 0 app/cli.py | 129 +++++++++++++++++++++++++++++++++ app/database.py | 37 ++++++++++ app/main.py | 96 +++++++++++++++++++++++++ app/models.py | 28 ++++++++ db/migration/init.sql | 7 ++ docker-compose.yml | 50 +++++++++++++ pytest.ini | 2 + requirements.txt | 16 +++++ tests/test_main.py | 155 +++++++++++++++++++++++++++++++++++++++ 14 files changed, 706 insertions(+) create mode 100644 .env create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 REAMDE.md create mode 100644 app/__init__.py create mode 100644 app/cli.py create mode 100644 app/database.py create mode 100644 app/main.py create mode 100644 app/models.py create mode 100644 db/migration/init.sql create mode 100644 docker-compose.yml create mode 100644 pytest.ini create mode 100644 requirements.txt create mode 100644 tests/test_main.py diff --git a/.env b/.env new file mode 100644 index 0000000..f6fd913 --- /dev/null +++ b/.env @@ -0,0 +1,5 @@ +POSTGRES_USER=admin +POSTGRES_PASSWORD=changeMe +POSTGRES_DB=inventory +POSTGRES_PORT=5432 +POSTGRES_HOST=localhost diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0439bd3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.venv +.pyenv +.idea +.pytest_cache diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d7790a8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.9-slim + +WORKDIR /inventory + +COPY requirements.txt . + +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/REAMDE.md b/REAMDE.md new file mode 100644 index 0000000..051bbfe --- /dev/null +++ b/REAMDE.md @@ -0,0 +1,164 @@ +# Inventory Management System + +Inventory Management CRUD application built with **FastAPI**, **Asyncpg (Raw SQL)**, and **PostgreSQL**. It includes a REST API, a CLI tool, and a Dockerized database environment. + +## Quick Start + +### 1. Prerequisites +* Docker & Docker Compose +* Python 3.9+ + +### 2. Start the Database +The project uses a Dockerized PostgreSQL instance. +```bash +# Start the database container +docker compose up --build -d + +# Verify it is healthy +docker compose ps +``` +#### API URL: http://127.0.0.1:8000 + +#### Swagger UI: http://127.0.0.1:8000/docs + +Database Port: 5432 (Exposed locally for debugging) + +Default credentials: Defined in .env + +Init: Schema is automatically applied from init.sql. + +## Usage Examples + +Below are full examples of how to interact with the API using standard `curl` commands or the included Python CLI tool. + +### 1. Create a Server +```bash +python3 app/cli.py create web-01 192.168.1.10 --state active +``` +```bash +curl -X POST http://127.0.0.1:8000/servers \ + -H "Content-Type: application/json" \ + -d '{"hostname": "web-01", "ip_address": "192.168.1.10", "state": "active"}' +``` +### 2. List All Servers +```bash +python3 app/cli.py list +``` +```bash +curl -s http://127.0.0.1:8000/servers +``` +### 3. Get Single Server Details +```bash +python3 app/cli.py get 1 +``` +```bash +curl -s http://127.0.0.1:8000/servers/1 +``` +### 4. Update a Server +```bash +python3 app/cli.py update 1 --ip 10.0.0.50 --state offline +``` +```bash +curl -X PUT http://127.0.0.1:8000/servers/1 \ + -H "Content-Type: application/json" \ + -d '{"ip_address": "10.0.0.50", "state": "offline"}' +``` +### 5. Delete a Server +```bash +python3 app/cli.py delete 1 +``` +```bash +curl -X DELETE http://127.0.0.1:8000/servers/1 +``` + +## CLI Specification + +The command-line interface (`app/cli.py`) interacts directly with the running API. + +**Usage:** `python3 app/cli.py [COMMAND] [ARGS]` + +| Command | Arguments | Description | Example | +| :--- | :--- | :--- | :--- | +| `list` | None | Lists all servers in a formatted table. | `python3 app/cli.py list` | +| `get` | `ID` | Retrieves details of a single server by ID. | `python3 app/cli.py get 1` | +| `create` | `HOSTNAME` `IP` `[--state]` | Creates a new server. Default state is `active`. | `python3 app/cli.py create web-01 10.0.0.5 --state offline` | +| `update` | `ID` `[--hostname]` `[--ip]` `[--state]` | Updates specific fields of an existing server. | `python3 app/cli.py update 1 --state retired` | +| `delete` | `ID` | Deletes a server permanently. | `python3 app/cli.py delete 1` | + +--- + +## API Specification + +The API follows RESTful standards. All request and response bodies are JSON. + +**Base URL:** `http://127.0.0.1:8000` + +### Endpoints + +#### 1. List Servers +* **Method:** `GET` +* **Endpoint:** `/servers` +* **Description:** Retrieve a list of all servers. +* **Response:** `200 OK` (Array of Server Objects) + +#### 2. Create Server +* **Method:** `POST` +* **Endpoint:** `/servers` +* **Body:** + ```json + { + "hostname": "string (unique)", + "ip_address": "string (IPv4/IPv6)", + "state": "active | offline | retired (optional, default: active)" + } + ``` +* **Response:** `201 Created` + +#### 3. Get Server +* **Method:** `GET` +* **Endpoint:** `/servers/{id}` +* **Description:** Retrieve a single server by its numeric ID. +* **Response:** `200 OK` or `404 Not Found` + +#### 4. Update Server +* **Method:** `PUT` +* **Endpoint:** `/servers/{id}` +* **Body:** (Partial updates allowed) + ```json + { + "hostname": "new-name", + "state": "retired" + } + ``` +* **Response:** `200 OK` + +#### 5. Delete Server +* **Method:** `DELETE` +* **Endpoint:** `/servers/{id}` +* **Description:** Delete a server by ID. +* **Response:** `204 No Content` + +### Validation Rules +* **Hostname:** Must be non-empty and unique across the database. +* **IP Address:** Must be a valid standard IPv4 or IPv6 address. +* **State:** Strictly limited to `active`, `offline`, or `retired`. + +## Testing +The project includes a pytest suite that mocks the database connection, ensuring logic is tested without requiring a running DB. +```bash +# Run all tests +python3 -m pytest tests/test_main.py -v +``` + +## Start the API Server locally +(http://127.0.0.1:8080) +```bash + +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt + +# Run the API +uvicorn app.main:app --reload --port 8080 +``` + diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/cli.py b/app/cli.py new file mode 100644 index 0000000..5948531 --- /dev/null +++ b/app/cli.py @@ -0,0 +1,129 @@ +import typer +import requests +import json +from typing import Optional +from rich.console import Console +from rich.table import Table + +app = typer.Typer() +console = Console() + +API_URL = "http://127.0.0.1:8000" + +@app.command() +def list(): + """List all servers""" + try: + response = requests.get(f"{API_URL}/servers") + response.raise_for_status() + servers = response.json() + + table = Table(title="Inventory Servers") + table.add_column("ID", style="cyan") + table.add_column("Hostname", style="green") + table.add_column("IP Address", style="magenta") + table.add_column("State", style="yellow") + + for s in servers: + # Handle cases where state might be missing + state_val = s.get("state", "unknown") + table.add_row(str(s["id"]), s["hostname"], s["ip_address"], state_val) + + console.print(table) + except Exception as e: + console.print(f"[bold red]Error:[/bold red] {e}") + +@app.command() +def get(server_id: int): + """Get details of a single server by ID""" + try: + response = requests.get(f"{API_URL}/servers/{server_id}") + + if response.status_code == 200: + server = response.json() + + table = Table(title=f"Server Details (ID: {server_id})") + table.add_column("Field", style="cyan", justify="right") + table.add_column("Value", style="green") + + table.add_row("ID", str(server["id"])) + table.add_row("Hostname", server["hostname"]) + table.add_row("IP Address", server["ip_address"]) + table.add_row("State", server.get("state", "active")) + + console.print(table) + + elif response.status_code == 404: + console.print(f"[bold red]Error:[/bold red] Server with ID {server_id} not found.") + else: + console.print(f"[bold red]Failed:[/bold red] {response.text}") + + except Exception as e: + console.print(f"[bold red]Error:[/bold red] {e}") + +@app.command() +def create( + hostname: str, + ip: str, + state: str = typer.Option("active", help="active, offline, or retired") +): + """Create a new server""" + payload = {"hostname": hostname, "ip_address": ip, "state": state} + try: + response = requests.post(f"{API_URL}/servers", json=payload) + if response.status_code == 201: + console.print(f"[bold green]Success![/bold green] Created server: {response.json()}") + else: + console.print(f"[bold red]Failed:[/bold red] {response.text}") + except Exception as e: + console.print(f"[bold red]Error:[/bold red] {e}") + +@app.command() +def update( + server_id: int, + hostname: Optional[str] = typer.Option(None, help="New hostname"), + ip: Optional[str] = typer.Option(None, help="New IP address"), + state: Optional[str] = typer.Option(None, help="active, offline, or retired") +): + """Update an existing server. Use --hostname or --ip or --state to specify changes.""" + + # Build payload with only the fields that were provided + payload = {} + if hostname: + payload["hostname"] = hostname + if ip: + payload["ip_address"] = ip + if state: + payload["state"] = state + + if not payload: + console.print("[bold yellow]Warning:[/bold yellow] No fields provided to update.") + return + + try: + response = requests.put(f"{API_URL}/servers/{server_id}", json=payload) + + if response.status_code == 200: + console.print(f"[bold green]Success![/bold green] Updated server: {response.json()}") + elif response.status_code == 404: + console.print(f"[bold red]Error:[/bold red] Server with ID {server_id} not found.") + else: + console.print(f"[bold red]Failed:[/bold red] {response.text}") + + except Exception as e: + console.print(f"[bold red]Error:[/bold red] {e}") + +@app.command() +def delete(server_id: int): + """Delete a server by ID""" + try: + response = requests.delete(f"{API_URL}/servers/{server_id}") + if response.status_code == 204: + console.print(f"[bold green]Success![/bold green] Server {server_id} deleted.") + else: + console.print(f"[bold red]Failed:[/bold red] {response.text}") + except Exception as e: + console.print(f"[bold red]Error:[/bold red] {e}") + +if __name__ == "__main__": + app() diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..a315435 --- /dev/null +++ b/app/database.py @@ -0,0 +1,37 @@ +import os +import asyncpg +from dotenv import load_dotenv + +# 1. Load .env file explicitly so uvicorn can find variables locally +load_dotenv() + +# 2. Get DB config with defaults for local development +DB_USER = os.getenv("POSTGRES_USER") +DB_PASS = os.getenv("POSTGRES_PASSWORD") +DB_NAME = os.getenv("POSTGRES_DB") +DB_PORT = os.getenv("POSTGRES_PORT") +DB_HOST = os.getenv("POSTGRES_HOST") + +DATABASE_URL = f"postgresql://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}" + +class Database: + def __init__(self): + self.pool = None + + async def connect(self): + print(f"Connecting to {DATABASE_URL}...") # Debug print + self.pool = await asyncpg.create_pool(dsn=DATABASE_URL) + print("Database connection pool created.") + + async def disconnect(self): + if self.pool: + await self.pool.close() + print("Database connection pool closed.") + +db = Database() + +async def get_db_conn(): + if not db.pool: + raise RuntimeError("Database not initialized") + async with db.pool.acquire() as conn: + yield conn \ No newline at end of file diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..d95523e --- /dev/null +++ b/app/main.py @@ -0,0 +1,96 @@ +from fastapi import FastAPI, HTTPException, Depends, status +from typing import List +import asyncpg +from app.models import ServerCreate, ServerResponse, ServerUpdate +from app.database import db, get_db_conn +from contextlib import asynccontextmanager + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup + await db.connect() + yield + # Shutdown + await db.disconnect() + +app = FastAPI(title="Inventory API", lifespan=lifespan) + +# --- Routes --- + +@app.post("/servers", response_model=ServerResponse, status_code=status.HTTP_201_CREATED) +async def create_server(server: ServerCreate, conn: asyncpg.Connection = Depends(get_db_conn)): + try: + query = """ + INSERT INTO servers (hostname, ip_address, state) + VALUES ($1, $2, $3) + RETURNING id, hostname, ip_address, state; \ + """ + # Convert IP to string for storage; asyncpg handles the rest + row = await conn.fetchrow(query, server.hostname, str(server.ip_address), server.state.value) + return dict(row) + except asyncpg.UniqueViolationError: + raise HTTPException(status_code=409, detail="Hostname already exists") + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@app.get("/servers", response_model=List[ServerResponse]) +async def list_servers(conn: asyncpg.Connection = Depends(get_db_conn)): + query = "SELECT id, hostname, ip_address, state FROM servers;" + rows = await conn.fetch(query) + return [dict(row) for row in rows] + +@app.get("/servers/{server_id}", response_model=ServerResponse) +async def get_server(server_id: int, conn: asyncpg.Connection = Depends(get_db_conn)): + query = "SELECT id, hostname, ip_address, state FROM servers WHERE id = $1;" + row = await conn.fetchrow(query, server_id) + + if not row: + raise HTTPException(status_code=404, detail="Server not found") + return dict(row) + +@app.put("/servers/{server_id}", response_model=ServerResponse) +async def update_server(server_id: int, server: ServerUpdate, conn: asyncpg.Connection = Depends(get_db_conn)): + update_fields = [] + values = [] + idx = 1 + + if server.hostname: + update_fields.append(f"hostname = ${idx}") + values.append(server.hostname) + idx += 1 + if server.ip_address: + update_fields.append(f"ip_address = ${idx}") + values.append(str(server.ip_address)) + idx += 1 + if server.state: + update_fields.append(f"state = ${idx}") + values.append(server.state.value) + idx += 1 + + if not update_fields: + raise HTTPException(status_code=400, detail="No fields provided for update") + + values.append(server_id) + query = f""" + UPDATE servers + SET {', '.join(update_fields)} + WHERE id = ${idx} + RETURNING id, hostname, ip_address, state; + """ + + try: + row = await conn.fetchrow(query, *values) + if not row: + raise HTTPException(status_code=404, detail="Server not found") + return dict(row) + except asyncpg.UniqueViolationError: + raise HTTPException(status_code=409, detail="Hostname already exists") + +@app.delete("/servers/{server_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_server(server_id: int, conn: asyncpg.Connection = Depends(get_db_conn)): + query = "DELETE FROM servers WHERE id = $1 RETURNING id;" + row = await conn.fetchrow(query, server_id) + + if not row: + raise HTTPException(status_code=404, detail="Server not found") + return diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..56dfb69 --- /dev/null +++ b/app/models.py @@ -0,0 +1,28 @@ +from pydantic import BaseModel, IPvAnyAddress, Field, ConfigDict +from typing import Optional +from enum import Enum + +# 1. Define the Allowed States +class ServerState(str, Enum): + ACTIVE = "active" + OFFLINE = "offline" + RETIRED = "retired" + +class ServerBase(BaseModel): + hostname: str = Field(..., min_length=1, description="Unique server hostname") + ip_address: IPvAnyAddress = Field(..., description="Valid IPv4 or IPv6 address") + state: ServerState = Field(default=ServerState.ACTIVE) + +class ServerCreate(ServerBase): + pass + +class ServerUpdate(BaseModel): + hostname: Optional[str] = None + ip_address: Optional[IPvAnyAddress] = None + state: Optional[ServerState] = None + +class ServerResponse(ServerBase): + id: int + + # Config to allow Pydantic to read from asyncpg Record objects (like dicts) + model_config = ConfigDict(from_attributes=True) \ No newline at end of file diff --git a/db/migration/init.sql b/db/migration/init.sql new file mode 100644 index 0000000..bd7be72 --- /dev/null +++ b/db/migration/init.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS servers ( + id SERIAL PRIMARY KEY, + hostname VARCHAR(255) NOT NULL UNIQUE, + ip_address INET NOT NULL, + state VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (state IN ('active', 'offline', 'retired')), + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f1f6c0d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,50 @@ +services: + # Database Service (PostgreSQL) + db: + image: postgres:15-alpine + container_name: inventory_db + restart: always + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_HOST: db + POSTGRES_PORT: ${POSTGRES_PORT} + volumes: + - postgres_data:/var/lib/postgresql/data + - ./db/migration/init.sql:/docker-entrypoint-initdb.d/init.sql:ro + networks: + - inventory_net + ports: + - "${POSTGRES_PORT}:5432" # Exposed for local debugging/connection + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 5 + + app: + build: . + container_name: inventory_app + restart: always + depends_on: + db: + condition: service_healthy + ports: + - "8000:8000" # Expose API to host + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_PORT: ${POSTGRES_PORT} + POSTGRES_HOST: db + networks: + - inventory_net + +volumes: + postgres_data: + driver: local + +networks: + inventory_net: + driver: bridge diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..a635c5c --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +pythonpath = . diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..75073f5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,16 @@ +# --- API & Database Dependencies --- +fastapi==0.109.0 +uvicorn[standard]==0.27.0 +pydantic==2.6.0 +asyncpg==0.29.0 +python-dotenv==1.0.1 + +# --- CLI Tool Dependencies --- +typer[all]==0.9.0 +requests==2.31.0 +rich==13.7.0 + +# --- Testing --- +pytest==8.0.0 +httpx==0.26.0 +pytest-asyncio==0.23.5 \ No newline at end of file diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..a7ab68d --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,155 @@ +import pytest +from fastapi.testclient import TestClient +from unittest.mock import AsyncMock, MagicMock +from app.main import app, get_db_conn +import asyncpg + +# Initialize the TestClient +client = TestClient(app) + +# --- Fixtures --- + +@pytest.fixture +def mock_db_conn(): + """ + Creates a mock asyncpg connection. + We override the dependency so the app uses this mock + instead of trying to connect to a real database. + """ + mock_conn = AsyncMock() + + # Override the dependency in the FastAPI app + app.dependency_overrides[get_db_conn] = lambda: mock_conn + + yield mock_conn + + # Clean up after test + app.dependency_overrides = {} + +# --- Test Cases --- + +def test_read_servers_empty(mock_db_conn): + """Test listing servers when DB is empty""" + # Setup Mock: fetch returns an empty list + mock_db_conn.fetch.return_value = [] + + response = client.get("/servers") + + assert response.status_code == 200 + assert response.json() == [] + # Verify the SQL query was executed + mock_db_conn.fetch.assert_called_once() + + +def test_create_server_success(mock_db_conn): + """Test successfully creating a server""" + payload = {"hostname": "web-01", "ip_address": "192.168.1.10"} + + # Setup Mock: fetchrow returns the created record (as a dict) + mock_return = {"id": 1, "hostname": "web-01", "ip_address": "192.168.1.10"} + mock_db_conn.fetchrow.return_value = mock_return + + response = client.post("/servers", json=payload) + + assert response.status_code == 201 + data = response.json() + assert data["hostname"] == "web-01" + assert data["id"] == 1 + + # Verify raw SQL logic + mock_db_conn.fetchrow.assert_called_once() + args = mock_db_conn.fetchrow.call_args[0] + assert "INSERT INTO servers" in args[0] + assert args[1] == "web-01" + + +def test_create_server_invalid_ip(mock_db_conn): + """Test Pydantic validation for bad IPs (No DB mock needed really)""" + payload = {"hostname": "web-01", "ip_address": "not-an-ip"} + + response = client.post("/servers", json=payload) + + assert response.status_code == 422 + # Ensure we didn't even try to call the DB + mock_db_conn.fetchrow.assert_not_called() + + +def test_get_server_found(mock_db_conn): + """Test getting a single server""" + mock_return = {"id": 1, "hostname": "db-01", "ip_address": "10.0.0.1"} + mock_db_conn.fetchrow.return_value = mock_return + + response = client.get("/servers/1") + + assert response.status_code == 200 + assert response.json()["hostname"] == "db-01" + + +def test_get_server_not_found(mock_db_conn): + """Test getting a non-existent server""" + mock_db_conn.fetchrow.return_value = None + + response = client.get("/servers/999") + + assert response.status_code == 404 + assert response.json()["detail"] == "Server not found" + + +def test_update_server_success(mock_db_conn): + """Test updating a server""" + payload = {"ip_address": "10.0.0.99"} + mock_return = {"id": 1, "hostname": "web-01", "ip_address": "10.0.0.99"} + mock_db_conn.fetchrow.return_value = mock_return + + response = client.put("/servers/1", json=payload) + + assert response.status_code == 200 + assert response.json()["ip_address"] == "10.0.0.99" + + +def test_create_server_invalid_state(mock_db_conn): + """Test validation for invalid state values""" + payload = { + "hostname": "web-01", + "ip_address": "192.168.1.10", + "state": "broken" # Invalid value + } + + response = client.post("/servers", json=payload) + + assert response.status_code == 422 + assert "Input should be 'active', 'offline' or 'retired'" in response.text + + +def test_create_server_duplicate_hostname(mock_db_conn): + """ + Test that the API returns 409 Conflict when a + unique constraint violation occurs in the database. + """ + # 1. Prepare the payload + payload = {"hostname": "web-01", "ip_address": "192.168.1.10"} + + # 2. Setup the Mock to raise the specific asyncpg error + # This simulates exactly what happens when Postgres hits a UNIQUE constraint + mock_db_conn.fetchrow.side_effect = asyncpg.UniqueViolationError() + + # 3. Send the request + response = client.post("/servers", json=payload) + + # 4. Verify the response + assert response.status_code == 409 + assert response.json()["detail"] == "Hostname already exists" + + +def test_delete_server_success(mock_db_conn): + """Test deleting a server""" + # Mock returning the deleted ID (simulating RETURNING id) + mock_db_conn.fetchrow.return_value = {"id": 1} + + response = client.delete("/servers/1") + + assert response.status_code == 204 + + # Verify the SQL contained DELETE + args = mock_db_conn.fetchrow.call_args[0] + assert "DELETE FROM servers" in args[0] From 60d8ea3cde148a4ff69386b544f227c76c08d5ff Mon Sep 17 00:00:00 2001 From: Yevhen Okolielov Date: Sun, 25 Jan 2026 13:07:15 +0200 Subject: [PATCH 2/2] README.md --- README.md | 179 +++++++++++++++++++++++++++++++++++++++++++++++------- REAMDE.md | 164 ------------------------------------------------- 2 files changed, 156 insertions(+), 187 deletions(-) delete mode 100644 REAMDE.md diff --git a/README.md b/README.md index 3145d38..051bbfe 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,164 @@ -# Instructions +# Inventory Management System -You are developing an inventory management software solution for a cloud services company that provisions servers in multiple data centers. You must build a CRUD app for tracking the state of all the servers. +Inventory Management CRUD application built with **FastAPI**, **Asyncpg (Raw SQL)**, and **PostgreSQL**. It includes a REST API, a CLI tool, and a Dockerized database environment. -Deliverables: -- PR to https://github.com/Mathpix/hiring-challenge-devops-python that includes: -- API code -- CLI code -- pytest test suite -- Working Docker Compose stack +## Quick Start -Short API.md on how to run everything, also a short API and CLI spec +### 1. Prerequisites +* Docker & Docker Compose +* Python 3.9+ -Required endpoints: -- POST /servers → create a server -- GET /servers → list all servers -- GET /servers/{id} → get one server -- PUT /servers/{id} → update server -- DELETE /servers/{id} → delete server +### 2. Start the Database +The project uses a Dockerized PostgreSQL instance. +```bash +# Start the database container +docker compose up --build -d -Requirements: -- Use FastAPI or Flask -- Store data in PostgreSQL -- Use raw SQL +# Verify it is healthy +docker compose ps +``` +#### API URL: http://127.0.0.1:8000 -Validate that: -- hostname is unique -- IP address looks like an IP +#### Swagger UI: http://127.0.0.1:8000/docs -State is one of: active, offline, retired +Database Port: 5432 (Exposed locally for debugging) + +Default credentials: Defined in .env + +Init: Schema is automatically applied from init.sql. + +## Usage Examples + +Below are full examples of how to interact with the API using standard `curl` commands or the included Python CLI tool. + +### 1. Create a Server +```bash +python3 app/cli.py create web-01 192.168.1.10 --state active +``` +```bash +curl -X POST http://127.0.0.1:8000/servers \ + -H "Content-Type: application/json" \ + -d '{"hostname": "web-01", "ip_address": "192.168.1.10", "state": "active"}' +``` +### 2. List All Servers +```bash +python3 app/cli.py list +``` +```bash +curl -s http://127.0.0.1:8000/servers +``` +### 3. Get Single Server Details +```bash +python3 app/cli.py get 1 +``` +```bash +curl -s http://127.0.0.1:8000/servers/1 +``` +### 4. Update a Server +```bash +python3 app/cli.py update 1 --ip 10.0.0.50 --state offline +``` +```bash +curl -X PUT http://127.0.0.1:8000/servers/1 \ + -H "Content-Type: application/json" \ + -d '{"ip_address": "10.0.0.50", "state": "offline"}' +``` +### 5. Delete a Server +```bash +python3 app/cli.py delete 1 +``` +```bash +curl -X DELETE http://127.0.0.1:8000/servers/1 +``` + +## CLI Specification + +The command-line interface (`app/cli.py`) interacts directly with the running API. + +**Usage:** `python3 app/cli.py [COMMAND] [ARGS]` + +| Command | Arguments | Description | Example | +| :--- | :--- | :--- | :--- | +| `list` | None | Lists all servers in a formatted table. | `python3 app/cli.py list` | +| `get` | `ID` | Retrieves details of a single server by ID. | `python3 app/cli.py get 1` | +| `create` | `HOSTNAME` `IP` `[--state]` | Creates a new server. Default state is `active`. | `python3 app/cli.py create web-01 10.0.0.5 --state offline` | +| `update` | `ID` `[--hostname]` `[--ip]` `[--state]` | Updates specific fields of an existing server. | `python3 app/cli.py update 1 --state retired` | +| `delete` | `ID` | Deletes a server permanently. | `python3 app/cli.py delete 1` | + +--- + +## API Specification + +The API follows RESTful standards. All request and response bodies are JSON. + +**Base URL:** `http://127.0.0.1:8000` + +### Endpoints + +#### 1. List Servers +* **Method:** `GET` +* **Endpoint:** `/servers` +* **Description:** Retrieve a list of all servers. +* **Response:** `200 OK` (Array of Server Objects) + +#### 2. Create Server +* **Method:** `POST` +* **Endpoint:** `/servers` +* **Body:** + ```json + { + "hostname": "string (unique)", + "ip_address": "string (IPv4/IPv6)", + "state": "active | offline | retired (optional, default: active)" + } + ``` +* **Response:** `201 Created` + +#### 3. Get Server +* **Method:** `GET` +* **Endpoint:** `/servers/{id}` +* **Description:** Retrieve a single server by its numeric ID. +* **Response:** `200 OK` or `404 Not Found` + +#### 4. Update Server +* **Method:** `PUT` +* **Endpoint:** `/servers/{id}` +* **Body:** (Partial updates allowed) + ```json + { + "hostname": "new-name", + "state": "retired" + } + ``` +* **Response:** `200 OK` + +#### 5. Delete Server +* **Method:** `DELETE` +* **Endpoint:** `/servers/{id}` +* **Description:** Delete a server by ID. +* **Response:** `204 No Content` + +### Validation Rules +* **Hostname:** Must be non-empty and unique across the database. +* **IP Address:** Must be a valid standard IPv4 or IPv6 address. +* **State:** Strictly limited to `active`, `offline`, or `retired`. + +## Testing +The project includes a pytest suite that mocks the database connection, ensuring logic is tested without requiring a running DB. +```bash +# Run all tests +python3 -m pytest tests/test_main.py -v +``` + +## Start the API Server locally +(http://127.0.0.1:8080) +```bash + +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt + +# Run the API +uvicorn app.main:app --reload --port 8080 +``` diff --git a/REAMDE.md b/REAMDE.md deleted file mode 100644 index 051bbfe..0000000 --- a/REAMDE.md +++ /dev/null @@ -1,164 +0,0 @@ -# Inventory Management System - -Inventory Management CRUD application built with **FastAPI**, **Asyncpg (Raw SQL)**, and **PostgreSQL**. It includes a REST API, a CLI tool, and a Dockerized database environment. - -## Quick Start - -### 1. Prerequisites -* Docker & Docker Compose -* Python 3.9+ - -### 2. Start the Database -The project uses a Dockerized PostgreSQL instance. -```bash -# Start the database container -docker compose up --build -d - -# Verify it is healthy -docker compose ps -``` -#### API URL: http://127.0.0.1:8000 - -#### Swagger UI: http://127.0.0.1:8000/docs - -Database Port: 5432 (Exposed locally for debugging) - -Default credentials: Defined in .env - -Init: Schema is automatically applied from init.sql. - -## Usage Examples - -Below are full examples of how to interact with the API using standard `curl` commands or the included Python CLI tool. - -### 1. Create a Server -```bash -python3 app/cli.py create web-01 192.168.1.10 --state active -``` -```bash -curl -X POST http://127.0.0.1:8000/servers \ - -H "Content-Type: application/json" \ - -d '{"hostname": "web-01", "ip_address": "192.168.1.10", "state": "active"}' -``` -### 2. List All Servers -```bash -python3 app/cli.py list -``` -```bash -curl -s http://127.0.0.1:8000/servers -``` -### 3. Get Single Server Details -```bash -python3 app/cli.py get 1 -``` -```bash -curl -s http://127.0.0.1:8000/servers/1 -``` -### 4. Update a Server -```bash -python3 app/cli.py update 1 --ip 10.0.0.50 --state offline -``` -```bash -curl -X PUT http://127.0.0.1:8000/servers/1 \ - -H "Content-Type: application/json" \ - -d '{"ip_address": "10.0.0.50", "state": "offline"}' -``` -### 5. Delete a Server -```bash -python3 app/cli.py delete 1 -``` -```bash -curl -X DELETE http://127.0.0.1:8000/servers/1 -``` - -## CLI Specification - -The command-line interface (`app/cli.py`) interacts directly with the running API. - -**Usage:** `python3 app/cli.py [COMMAND] [ARGS]` - -| Command | Arguments | Description | Example | -| :--- | :--- | :--- | :--- | -| `list` | None | Lists all servers in a formatted table. | `python3 app/cli.py list` | -| `get` | `ID` | Retrieves details of a single server by ID. | `python3 app/cli.py get 1` | -| `create` | `HOSTNAME` `IP` `[--state]` | Creates a new server. Default state is `active`. | `python3 app/cli.py create web-01 10.0.0.5 --state offline` | -| `update` | `ID` `[--hostname]` `[--ip]` `[--state]` | Updates specific fields of an existing server. | `python3 app/cli.py update 1 --state retired` | -| `delete` | `ID` | Deletes a server permanently. | `python3 app/cli.py delete 1` | - ---- - -## API Specification - -The API follows RESTful standards. All request and response bodies are JSON. - -**Base URL:** `http://127.0.0.1:8000` - -### Endpoints - -#### 1. List Servers -* **Method:** `GET` -* **Endpoint:** `/servers` -* **Description:** Retrieve a list of all servers. -* **Response:** `200 OK` (Array of Server Objects) - -#### 2. Create Server -* **Method:** `POST` -* **Endpoint:** `/servers` -* **Body:** - ```json - { - "hostname": "string (unique)", - "ip_address": "string (IPv4/IPv6)", - "state": "active | offline | retired (optional, default: active)" - } - ``` -* **Response:** `201 Created` - -#### 3. Get Server -* **Method:** `GET` -* **Endpoint:** `/servers/{id}` -* **Description:** Retrieve a single server by its numeric ID. -* **Response:** `200 OK` or `404 Not Found` - -#### 4. Update Server -* **Method:** `PUT` -* **Endpoint:** `/servers/{id}` -* **Body:** (Partial updates allowed) - ```json - { - "hostname": "new-name", - "state": "retired" - } - ``` -* **Response:** `200 OK` - -#### 5. Delete Server -* **Method:** `DELETE` -* **Endpoint:** `/servers/{id}` -* **Description:** Delete a server by ID. -* **Response:** `204 No Content` - -### Validation Rules -* **Hostname:** Must be non-empty and unique across the database. -* **IP Address:** Must be a valid standard IPv4 or IPv6 address. -* **State:** Strictly limited to `active`, `offline`, or `retired`. - -## Testing -The project includes a pytest suite that mocks the database connection, ensuring logic is tested without requiring a running DB. -```bash -# Run all tests -python3 -m pytest tests/test_main.py -v -``` - -## Start the API Server locally -(http://127.0.0.1:8080) -```bash - -python3 -m venv .venv -source .venv/bin/activate -pip install -r requirements.txt - -# Run the API -uvicorn app.main:app --reload --port 8080 -``` -