diff --git a/config/database.py b/config/database.py new file mode 100644 index 0000000..ea8d379 --- /dev/null +++ b/config/database.py @@ -0,0 +1,51 @@ +""" +MongoDB Database Initialization Module + +This module is responsible for initializing the MongoDB database connection, +creating necessary indexes, and setting up the database for the Vehicle Allocation System. +""" + +from motor.motor_asyncio import AsyncIOMotorClient +from dotenv import load_dotenv +import os +import asyncio + +# Load environment variables from .env file +load_dotenv() + +# Retrieve the MongoDB credentials from the environment variables +username = os.getenv("MONGO_USERNAME") +password = os.getenv("MONGO_PASSWORD") +cluster = os.getenv("MONGO_CLUSTER_URL") + +# Create the MongoDB connection URI using the loaded credentials +uri = f"mongodb+srv://{username}:{password}@{ + cluster}/?retryWrites=true&w=majority&appName=Cluster0" + +# Initialize MongoDB client and access the database +client = AsyncIOMotorClient(uri) +db = client.vallocation_db +collection = db.vallocation_collection + +# Function to create indexes +async def create_indexes(): + """ + Create necessary indexes in the MongoDB collection. + + Indexes: + - Unique index on 'vehicle_id' and 'allocation_date' to prevent double booking + - Index on 'employee_id' for efficient querying + """ + await collection.create_index([("vehicle_id", 1), ("allocation_date", 1)], unique=True) + await collection.create_index([("employee_id", 1)]) + +# Function to initialize the database at startup +async def initialize_db(): + """ + Initialize the MongoDB database by creating necessary indexes. + """ + await create_indexes() + +# For running it directly (testing purposes) +# if __name__ == "__main__": +# asyncio.run(initialize_db()) diff --git a/main.py b/main.py index 9de2558..8c58229 100644 --- a/main.py +++ b/main.py @@ -1,31 +1,63 @@ -import os -from dotenv import load_dotenv -from pymongo.server_api import ServerApi -from pymongo.mongo_client import MongoClient +""" +Vehicle Allocation System API + +This API is designed to manage vehicle allocations for employees. +It includes CRUD operations and history reporting. +""" + from fastapi import FastAPI +from routers import route +from fastapi.middleware.cors import CORSMiddleware +import uvicorn + +app = FastAPI( + title="Vehicle Allocation System", + description="API for managing vehicle allocations for employees. " + "Includes CRUD operations and history reporting.", + version="1.0.0", +) + +# Include the router from route.py +app.include_router(route.router) + +""" +Set up CORS (for future feat. integration: frontend or external access) +""" +origins = [ + "http://localhost", + "http://localhost:8000", +] + +app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Accessing the root URL -app = FastAPI() +@app.get("/") +async def root(): + return {"message": "Welcome to the Vehicle Allocation API. For going to swagger, add '/docs' after http://127.0.0.1:8000/"} -# Load environment variables from .env file -load_dotenv() +# Health Check endpoint -# Retrieve the MongoDB credentials from the environment variables -username = os.getenv("MONGO_USERNAME") -password = os.getenv("MONGO_PASSWORD") -cluster = os.getenv("MONGO_CLUSTER_URL") -# Create the MongoDB connection URI using the loaded credentials -uri = f"mongodb+srv://{username}:{password}@{ - cluster}/?retryWrites=true&w=majority&appName=Cluster0" +@app.get("/health", tags=["Health"]) +async def health_check(): + return {"status": "healthy"} -# Create a new client and connect to the server -client = MongoClient(uri, server_api=ServerApi('1')) +# Main entry point when running the app directly +if __name__ == "__main__": + """ + Run the FastAPI application using the uvicorn server. -# Send a ping to confirm a successful connection -try: - client.admin.command('ping') - print("Pinged your deployment. You successfully connected to MongoDB!") -except Exception as e: - print(e) + Host: Binds the server to all available network interfaces. + Port: Runs the server on port 8000. + Reload: Automatically reloads the server when code changes are detected. + """ + uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) diff --git a/models/vallocation_model.py b/models/vallocation_model.py new file mode 100644 index 0000000..8bdad54 --- /dev/null +++ b/models/vallocation_model.py @@ -0,0 +1,41 @@ +""" +Pydantic models for the Vehicle Allocation System API. +""" + +from pydantic import BaseModel, Field +from datetime import date +from typing import Optional + + +class Vallocation(BaseModel): + """ + Pydantic model representing a vehicle allocation. + + Attributes: + employee_id (int): ID of the employee allocating the vehicle. + vehicle_id (int): ID of the allocated vehicle. + driver_id (int): ID of the driver assigned to the vehicle. + allocation_date (date): The date for which the vehicle is allocated. + status (str, optional): Status of the allocation (e.g., pending, confirmed, canceled). Defaults to "pending". + """ + + employee_id: int = Field(..., + description="ID of the employee allocating the vehicle") + vehicle_id: int = Field(..., description="ID of the allocated vehicle") + driver_id: int = Field(..., + description="ID of the driver assigned to the vehicle") + allocation_date: date = Field(..., + description="The date for which the vehicle is allocated") + status: Optional[str] = Field( + "pending", description="Status of the allocation (e.g., pending, confirmed, cancelled)") + + + class Config: + json_schema_extra = { + "example": { + "employee_id": 123, + "vehicle_id": 456, + "allocation_date": "2024-10-31", + "status": "pending" + } + } diff --git a/requirements.txt b/requirements.txt index c4f6e3e..6be9f99 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,40 @@ -fastapi[all] -pymongo[srv] -# uvicorn -# pymongo[srv] -# pydantic -# python-dotenv +annotated-types==0.7.0 +anyio==4.6.2.post1 +certifi==2024.8.30 +click==8.1.7 +colorama==0.4.6 +dnspython==2.7.0 +email_validator==2.2.0 +fastapi==0.115.3 +fastapi-cli==0.0.5 +h11==0.14.0 +httpcore==1.0.6 +httptools==0.6.4 +httpx==0.27.2 +idna==3.10 +itsdangerous==2.2.0 +Jinja2==3.1.4 +markdown-it-py==3.0.0 +MarkupSafe==3.0.2 +mdurl==0.1.2 +motor==3.6.0 +orjson==3.10.10 +pydantic==2.9.2 +pydantic-extra-types==2.9.0 +pydantic-settings==2.6.0 +pydantic_core==2.23.4 +Pygments==2.18.0 +pymongo==4.9.2 +python-dotenv==1.0.1 +python-multipart==0.0.12 +PyYAML==6.0.2 +rich==13.9.3 +shellingham==1.5.4 +sniffio==1.3.1 +starlette==0.41.0 +typer==0.12.5 +typing_extensions==4.12.2 +ujson==5.10.0 +uvicorn==0.32.0 +watchfiles==0.24.0 +websockets==13.1 diff --git a/routers/route.py b/routers/route.py new file mode 100644 index 0000000..b0449d0 --- /dev/null +++ b/routers/route.py @@ -0,0 +1,238 @@ +""" +FastAPI router for vehicle allocation management. + +This router provides APIs for creating, updating, deleting, and retrieving vehicle allocations. +It also includes helper functions for data validation and querying the MongoDB database. +""" + +from fastapi import APIRouter, HTTPException, Depends, Query +from typing import List, Optional, Dict, Any +from motor.motor_asyncio import AsyncIOMotorCollection +from datetime import date, datetime +from bson import ObjectId +from models.vallocation_model import Vallocation +from schemas.schemas import VallocationCreate, VallocationUpdate, VallocationResponse +from config.database import collection + +router = APIRouter() + +# Helper function to check if a vehicle is already allocated for a specific day +async def is_vehicle_allocated(vehicle_id: int, allocation_date: date, collection: AsyncIOMotorCollection) -> bool: + """ + Check if a vehicle is already allocated for a specific date. + + Args: + vehicle_id (int): The ID of the vehicle. + allocation_date (date): The allocation date to check. + collection (AsyncIOMotorCollection): The MongoDB collection. + + Returns: + bool: True if the vehicle is already allocated for the given date, False otherwise. + """ + existing_allocation = await collection.find_one({ + "vehicle_id": vehicle_id, + "allocation_date": allocation_date.isoformat() + }) + return existing_allocation is not None + +# Helper function to validate if allocation date is before today +def is_date_in_future(allocation_date: date) -> bool: + """ + Check if an allocation date is in the future. + + Args: + allocation_date (date): The allocation date to check. + + Returns: + bool: True if the allocation date is in the future, False otherwise. + """ + return allocation_date >= date.today() + +# Create an allocation +@router.post("/allocate/", response_model=VallocationResponse, summary="Create a new vehicle allocation") +async def create_allocation(allocation: VallocationCreate, collection: AsyncIOMotorCollection = Depends(lambda: collection)): + """ + Create a new vehicle allocation. + + Args: + allocation (VallocationCreate): The allocation data to create. + collection (AsyncIOMotorCollection): The MongoDB collection (provided as a dependency). + + Raises: + HTTPException: If the allocation date is not in the future or the vehicle is already allocated for the requested date. + + Returns: + VallocationResponse: The created allocation data. + """ + # Check if the allocation date is in the future + if not is_date_in_future(allocation.allocation_date): + raise HTTPException( + status_code=400, detail="Allocation date must be in the future." + ) + + # Check if the vehicle is already allocated for the requested date + if await is_vehicle_allocated(allocation.vehicle_id, allocation.allocation_date, collection): + raise HTTPException( + status_code=400, detail="Vehicle is already allocated for the requested date." + ) + + # Get pre-assigned driver for the vehicle (for simplicity, driver_id is set same as vehicle_id here) + driver_id = allocation.vehicle_id + + # Insert new allocation into MongoDB + new_allocation = { + "employee_id": allocation.employee_id, + "vehicle_id": allocation.vehicle_id, + "driver_id": driver_id, + "allocation_date": allocation.allocation_date.isoformat(), + "status": "pending", + } + result = await collection.insert_one(new_allocation) + + # Return the created allocation + created_allocation = await collection.find_one({"_id": result.inserted_id}) + return VallocationResponse(id=str(created_allocation["_id"]), **created_allocation) + +# Update an allocation +@router.put("/allocate/{allocation_id}", response_model=VallocationResponse, summary="Update an existing vehicle allocation") +async def update_allocation(allocation_id: str, allocation: VallocationUpdate, collection: AsyncIOMotorCollection = Depends(lambda: collection)): + """ + Update an existing vehicle allocation. + + Args: + allocation_id (str): The ID of the allocation to update. + allocation (VallocationUpdate): The updated allocation data. + collection (AsyncIOMotorCollection): The MongoDB collection (provided as a dependency). + + Raises: + HTTPException: If the allocation ID is invalid, the allocation does not exist, the allocation date is not in the future, + or the vehicle is already allocated for the new requested date. + + Returns: + VallocationResponse: The updated allocation data. + """ + # Check if the allocation exists + try: + existing_allocation = await collection.find_one({"_id": ObjectId(allocation_id)}) + except Exception: + raise HTTPException( + status_code=400, detail="Invalid allocation ID format.") + + if not existing_allocation: + raise HTTPException(status_code=404, detail="Allocation not found.") + + # Ensure the allocation date is in the future for modifications + existing_allocation_date = datetime.strptime( + existing_allocation["allocation_date"], "%Y-%m-%d").date() + if not is_date_in_future(existing_allocation_date): + raise HTTPException( + status_code=400, detail="Cannot update allocations that have already passed." + ) + + # If updating allocation date, ensure the vehicle is not already allocated for the new date + if allocation.allocation_date: + allocation_date_obj = allocation.allocation_date + if await is_vehicle_allocated(existing_allocation["vehicle_id"], allocation_date_obj, collection): + raise HTTPException( + status_code=400, detail="Vehicle is already allocated for the new requested date." + ) + + # Update the allocation fields + update_data = {k: v for k, v in allocation.dict( + exclude_unset=True).items()} + + # Handle date formatting if allocation_date is being updated + if 'allocation_date' in update_data: + update_data['allocation_date'] = allocation.allocation_date.isoformat() + + # Perform the update in MongoDB + await collection.update_one({"_id": ObjectId(allocation_id)}, {"$set": update_data}) + + # Return the updated allocation + updated_allocation = await collection.find_one({"_id": ObjectId(allocation_id)}) + + return VallocationResponse(id=str(updated_allocation["_id"]), **updated_allocation) + +# Delete an allocation +@router.delete("/allocate/{allocation_id}", summary="Delete an existing vehicle allocation") +async def delete_allocation(allocation_id: str, collection: AsyncIOMotorCollection = Depends(lambda: collection)): + """ + Delete an existing vehicle allocation. + + Args: + allocation_id (str): The ID of the allocation to delete. + collection (AsyncIOMotorCollection): The MongoDB collection (provided as a dependency). + + Raises: + HTTPException: If the allocation does not exist or the allocation date is not in the future. + + Returns: + dict: A success message. + """ + # Check if the allocation exists + existing_allocation = await collection.find_one({"_id": ObjectId(allocation_id)}) + if not existing_allocation: + raise HTTPException(status_code=404, detail="Allocation not found.") + + # Ensure the allocation date is in the future for deletions + existing_allocation_date = datetime.strptime( + existing_allocation["allocation_date"], "%Y-%m-%d").date() + if not is_date_in_future(existing_allocation_date): + raise HTTPException( + status_code=400, detail="Cannot delete allocations that have already passed." + ) + + # Delete the allocation + await collection.delete_one({"_id": ObjectId(allocation_id)}) + return {"detail": "Allocation deleted successfully."} + +# Get allocation history with pagination and filters +@router.get("/history/", response_model=Dict[str, Any], summary="Get allocation history with optional filters and pagination") +async def get_allocation_history( + employee_id: Optional[int] = None, + vehicle_id: Optional[int] = None, + driver_id: Optional[int] = None, + allocation_date: Optional[date] = None, + skip: int = Query(0, description="Number of records to skip"), + limit: int = Query(10, description="Max number of records to return"), + collection: AsyncIOMotorCollection = Depends(lambda: collection) +): + """ + Get allocation history with optional filters and pagination. + + Args: + employee_id (Optional[int], optional): Filter allocations by employee ID. + vehicle_id (Optional[int], optional): Filter allocations by vehicle ID. + driver_id (Optional[int], optional): Filter allocations by driver ID. + allocation_date (Optional[date], optional): Filter allocations by allocation date. + skip (int, optional): The number of records to skip. Defaults to 0. + limit (int, optional): The maximum number of records to return. Defaults to 10. + collection (AsyncIOMotorCollection): The MongoDB collection (provided as a dependency). + + Returns: + Dict[str, Any]: The filtered allocation history with pagination metadata. + """ + # Build the filter query + query = {} + if employee_id is not None: + query["employee_id"] = employee_id + if vehicle_id is not None: + query["vehicle_id"] = vehicle_id + if driver_id is not None: + query["driver_id"] = driver_id + if allocation_date is not None: + query["allocation_date"] = allocation_date.isoformat() + + # Query the database for matching allocations with skip and limit + allocations = await collection.find(query).skip(skip).limit(limit).to_list(length=limit) + + # Get total count of allocations + total_count = await collection.count_documents(query) + + # Return the filtered allocation history and pagination metadata + return { + "total": total_count, + "skip": skip, + "limit": limit, + "results": [VallocationResponse(id=str(allocation["_id"]), **allocation) for allocation in allocations] + } diff --git a/schemas/schemas.py b/schemas/schemas.py new file mode 100644 index 0000000..70ba3c1 --- /dev/null +++ b/schemas/schemas.py @@ -0,0 +1,105 @@ +""" +Pydantic models for creating, updating, and responding with vehicle allocation data. +""" + +from pydantic import BaseModel, Field +from datetime import date +from typing import Optional + + +class VallocationCreate(BaseModel): + """ + Pydantic model for creating a new vehicle allocation. + + Attributes: + employee_id (int): ID of the employee allocating the vehicle. + vehicle_id (int): ID of the allocated vehicle. + allocation_date (date): The date for which the vehicle is allocated. + """ + employee_id: int = Field(..., + description="ID of the employee allocating the vehicle") + vehicle_id: int = Field(..., description="ID of the allocated vehicle") + allocation_date: date = Field(..., + description="The date for which the vehicle is allocated") + + class Config: + """ + Pydantic configuration for the VallocationCreate model. + + Includes an example JSON schema for documentation purposes. + """ + json_schema_extra = { + "example": { + "employee_id": 101, + "vehicle_id": 12, + "allocation_date": "2024-11-01" + } + } + + +class VallocationUpdate(BaseModel): + """ + Pydantic model for updating an existing vehicle allocation. + + Attributes: + allocation_date (str, optional): Updated allocation date as a string in ISO format (e.g., "2023-06-15"). + status (str, optional): Status of the allocation (e.g., pending, confirmed, canceled). + """ + allocation_date: Optional[date] = Field( + None, description="Updated allocation date as a string in ISO format (e.g., '2023-06-15')") + status: Optional[str] = Field( + None, description="Status of the allocation (e.g., pending, confirmed, canceled)") + + class Config: + """ + Pydantic configuration for the VallocationUpdate model. + + Includes an example JSON schema for documentation purposes. + """ + json_schema_extra = { + "example": { + "allocation_date": "2024-11-02", + "status": "confirmed" + } + } + + +class VallocationResponse(BaseModel): + """ + Pydantic model for representing a vehicle allocation response. + + Attributes: + id (str): Unique ID of the allocation. + employee_id (int): ID of the employee who allocated the vehicle. + vehicle_id (int): ID of the allocated vehicle. + driver_id (int): ID of the driver assigned to the vehicle. + allocation_date (date): The date for which the vehicle is allocated. + status (str): Status of the allocation (e.g., pending, confirmed, canceled). + """ + id: str = Field(..., description="Unique ID of the allocation") + employee_id: int = Field(..., + description="ID of the employee who allocated the vehicle") + vehicle_id: int = Field(..., description="ID of the allocated vehicle") + driver_id: int = Field(..., + description="ID of the driver assigned to the vehicle") + allocation_date: date = Field(..., + description="The date for which the vehicle is allocated") + status: str = Field( + ..., description="Status of the allocation (e.g., pending, confirmed, canceled)") + + class Config: + """ + Pydantic configuration for the VallocationResponse model. + + Includes an example JSON schema for documentation purposes. + """ + json_schema_extra = { + "example": { + "id": "60c72b2f9b7e4e2d88d0f66b", + "employee_id": 101, + "vehicle_id": 12, + "driver_id": 45, + "allocation_date": "2024-11-01", + "status": "pending" + } + }