diff --git a/README.md b/README.md index 681ffd4..c0b4d4c 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,13 @@ This is a FastAPI application for generating and managing invoices. It allows yo - `POST /api/v1/invoices`: Create a new invoice - `GET /api/v1/invoices`: List all invoices (with pagination) + - Query parameters: + - `skip`: Number of records to skip (default: 0) + - `limit`: Maximum number of records to return (default: 100) + - `fields`: Comma-separated list of fields to include in the response (e.g., "id,invoice_number,total_amount") - `GET /api/v1/invoices/{invoice_id}`: Get a specific invoice by ID + - Query parameters: + - `fields`: Comma-separated list of fields to include in the response (e.g., "id,invoice_number,total_amount") - `POST /api/v1/invoices/find`: Find an invoice by invoice number - `PATCH /api/v1/invoices/{invoice_id}`: Update an invoice - `PATCH /api/v1/invoices/{invoice_id}/status`: Update invoice status @@ -127,4 +133,51 @@ Invoices are automatically assigned a unique invoice number with the format: - `INV-YYYYMM-XXXXXX`, where: - `INV` is a fixed prefix - `YYYYMM` is the year and month (e.g., 202307 for July 2023) - - `XXXXXX` is a random alphanumeric string \ No newline at end of file + - `XXXXXX` is a random alphanumeric string + +## Field Filtering + +The API supports field filtering for GET operations. This allows clients to request only the specific fields they need, which can improve performance and reduce bandwidth usage. + +### How to Use Field Filtering: + +1. Add a `fields` query parameter to GET requests +2. Specify a comma-separated list of field names to include in the response + +### Example: + +To get only the ID, invoice number, and total amount of invoices: +``` +GET /api/v1/invoices?fields=id,invoice_number,total_amount +``` + +This would return: +```json +[ + { + "id": 1, + "invoice_number": "INV-202307-A1B2C3", + "total_amount": 100.0 + }, + { + "id": 2, + "invoice_number": "INV-202307-D4E5F6", + "total_amount": 200.0 + } +] +``` + +### Available Fields: + +Invoices have the following fields that can be filtered: +- `id` +- `invoice_number` +- `date_created` +- `due_date` +- `total_amount` +- `status` +- `customer_name` +- `customer_email` +- `customer_address` +- `notes` +- `items` (this includes related invoice items) \ No newline at end of file diff --git a/app/api/routes/invoices.py b/app/api/routes/invoices.py index fe109a9..6f1c10f 100644 --- a/app/api/routes/invoices.py +++ b/app/api/routes/invoices.py @@ -1,7 +1,8 @@ from datetime import datetime -from typing import List +from typing import Optional from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.responses import JSONResponse from sqlalchemy.orm import Session from app.core.database import get_db @@ -13,6 +14,7 @@ from app.schemas.invoice import ( InvoiceSearchQuery, InvoiceStatusUpdate, InvoiceUpdate, + invoice_db_filterable, ) router = APIRouter() @@ -68,23 +70,47 @@ def create_invoice(invoice_data: InvoiceCreate, db: Session = Depends(get_db)): return db_invoice -@router.get("/", response_model=List[InvoiceDB]) +@router.get("/") def get_invoices( skip: int = 0, limit: int = 100, + fields: Optional[str] = None, db: Session = Depends(get_db) ): """ Retrieve a list of invoices. + + Parameters: + - skip: Number of records to skip + - limit: Maximum number of records to return + - fields: Comma-separated list of fields to include in the response (e.g., "id,invoice_number,total_amount") + If not provided, all fields will be returned. """ invoices = db.query(Invoice).offset(skip).limit(limit).all() + + # If fields parameter is provided, filter the response + if fields: + # Process the response to include only the specified fields + filtered_response = invoice_db_filterable.process_response(invoices, fields) + return JSONResponse(content=filtered_response) + + # Otherwise, return all fields return invoices -@router.get("/{invoice_id}", response_model=InvoiceDB) -def get_invoice(invoice_id: int, db: Session = Depends(get_db)): +@router.get("/{invoice_id}") +def get_invoice( + invoice_id: int, + fields: Optional[str] = None, + db: Session = Depends(get_db) +): """ Retrieve a specific invoice by ID. + + Parameters: + - invoice_id: ID of the invoice to retrieve + - fields: Comma-separated list of fields to include in the response (e.g., "id,invoice_number,total_amount") + If not provided, all fields will be returned. """ invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first() if invoice is None: @@ -92,6 +118,14 @@ def get_invoice(invoice_id: int, db: Session = Depends(get_db)): status_code=status.HTTP_404_NOT_FOUND, detail=f"Invoice with ID {invoice_id} not found", ) + + # If fields parameter is provided, filter the response + if fields: + # Process the response to include only the specified fields + filtered_response = invoice_db_filterable.process_response(invoice, fields) + return JSONResponse(content=filtered_response) + + # Otherwise, return all fields return invoice diff --git a/app/core/utils.py b/app/core/utils.py index 7a1fb2e..f24f28b 100644 --- a/app/core/utils.py +++ b/app/core/utils.py @@ -1,6 +1,7 @@ import datetime import random import string +from typing import Dict, Any, List, Set, Union def generate_invoice_number(prefix="INV", length=6): @@ -46,4 +47,52 @@ def calculate_total_amount(items): item_amount = item.quantity * item.unit_price total += item_amount - return total \ No newline at end of file + return total + + +def filter_fields(data: Union[Dict[str, Any], List[Dict[str, Any]]], fields: str = None) -> Union[Dict[str, Any], List[Dict[str, Any]]]: + """ + Filter dictionary or list of dictionaries to only include specified fields. + + Args: + data (Union[Dict[str, Any], List[Dict[str, Any]]]): The data to filter + fields (str, optional): Comma-separated string of fields to include. + If None, returns original data. + + Returns: + Union[Dict[str, Any], List[Dict[str, Any]]]: Filtered data + + Example: + >>> data = {"id": 1, "name": "John", "email": "john@example.com"} + >>> filter_fields(data, "id,name") + {"id": 1, "name": "John"} + """ + if fields is None: + return data + + # Parse the fields parameter + fields_set: Set[str] = {field.strip() for field in fields.split(",")} if fields else set() + + if not fields_set: + return data + + # Handle list of dictionaries + if isinstance(data, list): + return [_filter_dict(item, fields_set) for item in data] + + # Handle single dictionary + return _filter_dict(data, fields_set) + + +def _filter_dict(data: Dict[str, Any], fields_set: Set[str]) -> Dict[str, Any]: + """ + Filter a dictionary to only include specified fields. + + Args: + data (Dict[str, Any]): Dictionary to filter + fields_set (Set[str]): Set of fields to include + + Returns: + Dict[str, Any]: Filtered dictionary + """ + return {k: v for k, v in data.items() if k in fields_set} \ No newline at end of file diff --git a/app/schemas/invoice.py b/app/schemas/invoice.py index 27931d6..92e4ba7 100644 --- a/app/schemas/invoice.py +++ b/app/schemas/invoice.py @@ -1,7 +1,80 @@ from datetime import datetime -from typing import List, Optional +from typing import Any, Dict, List, Optional, Type, TypeVar, Generic, Union -from pydantic import BaseModel, Field, validator +from fastapi.encoders import jsonable_encoder +from pydantic import BaseModel, Field, validator, create_model + + +T = TypeVar('T', bound=BaseModel) + + +class FilterableModel(Generic[T]): + """ + Wrapper class for filtering fields in Pydantic models. + """ + def __init__(self, model_class: Type[T]): + self.model_class = model_class + + def filter_model(self, fields: str = None) -> Type[BaseModel]: + """ + Create a new model with only the specified fields from the original model. + If fields is None, returns the original model. + + Args: + fields (str, optional): Comma-separated string of fields to include. + + Returns: + Type[BaseModel]: A new model class with only the specified fields. + """ + if fields is None: + return self.model_class + + # Parse the fields + field_set = {field.strip() for field in fields.split(",")} if fields else set() + + if not field_set: + return self.model_class + + # Get fields from the original model + original_fields = self.model_class.__annotations__ + original_field_defaults = { + field_name: (field_type, self.model_class.__fields__[field_name].default) + for field_name, field_type in original_fields.items() + if field_name in field_set and field_name in self.model_class.__fields__ + } + + # Create a new model with only the specified fields + filtered_model = create_model( + f"{self.model_class.__name__}Filtered", + **original_field_defaults + ) + + # Copy the Config from the original model if it exists + if hasattr(self.model_class, "Config"): + filtered_model.Config = type("Config", (), dict(vars(self.model_class.Config))) + + return filtered_model + + def process_response(self, obj: Union[T, List[T]], fields: str = None) -> Union[Dict[str, Any], List[Dict[str, Any]]]: + """ + Process the response to include only the specified fields. + + Args: + obj (Union[T, List[T]]): The object(s) to process + fields (str, optional): Comma-separated string of fields to include. + + Returns: + Union[Dict[str, Any], List[Dict[str, Any]]]: The filtered response + """ + if fields is None: + return jsonable_encoder(obj) + + filtered_model = self.filter_model(fields) + + if isinstance(obj, list): + return [jsonable_encoder(filtered_model.parse_obj(item)) for item in obj] + else: + return jsonable_encoder(filtered_model.parse_obj(obj)) class InvoiceItemBase(BaseModel): @@ -62,6 +135,11 @@ class InvoiceDB(InvoiceBase): from_attributes = True +# Create filterable versions of our models +invoice_db_filterable = FilterableModel(InvoiceDB) +invoice_item_db_filterable = FilterableModel(InvoiceItemDB) + + class InvoiceSearchQuery(BaseModel): invoice_number: str