feat: comprehensive test infrastructure improvements
- Fix unit test imports: add env setup in conftest.py before module imports - Add 24 new auth router tests (RBAC, preferences, password validation) - Add 16 new tests for litellm and chat_summary routers - Apply black formatting and ruff linting across codebase - Add pre-commit hooks configuration (black, ruff, file checks) - Increase CI coverage threshold from 40% to 50% Test Results: - 206 tests passing (91 unit + 115 integration) - Coverage: 58.79% on core modules - auth.py: 57% → 85%, litellm.py: 23% → 87%, chat_summary.py: 41% → 100% - auth_service: 96.51%, config: 100%, rbac: 93.48%
This commit is contained in:
@@ -1,15 +1,18 @@
|
||||
"""
|
||||
Agent Executor Service - Executes agent tasks and manages their lifecycle
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
from db import opc_db
|
||||
from services.agents.base_agent import BaseAgent
|
||||
from services import email_service
|
||||
import httpx
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from db import opc_db
|
||||
from services import email_service
|
||||
from services.agents.base_agent import BaseAgent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -22,7 +25,7 @@ class AgentExecutor:
|
||||
"""Manages agent execution lifecycle"""
|
||||
|
||||
def __init__(self):
|
||||
self.agents: Dict[str, BaseAgent] = {}
|
||||
self.agents: dict[str, BaseAgent] = {}
|
||||
self.running = False
|
||||
|
||||
async def initialize(self):
|
||||
@@ -35,7 +38,7 @@ class AgentExecutor:
|
||||
name=agent_data["name"],
|
||||
role=agent_data["role"],
|
||||
system_prompt=agent_data["system_prompt"],
|
||||
config=agent_data["config"]
|
||||
config=agent_data["config"],
|
||||
)
|
||||
self.agents[agent_data["id"]] = agent
|
||||
logger.info(f"Loaded agent: {agent.name} ({agent.agent_id})")
|
||||
@@ -54,6 +57,7 @@ class AgentExecutor:
|
||||
print(f"[Executor] Error in executor loop: {e}")
|
||||
logger.error(f"Error in executor loop: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
await asyncio.sleep(5) # Check every 5 seconds
|
||||
@@ -74,9 +78,11 @@ class AgentExecutor:
|
||||
|
||||
for execution in executions:
|
||||
try:
|
||||
print(f"[Executor] Processing execution {execution['id']} for agent {execution['agent_id']} (status: {execution['status']})")
|
||||
print(
|
||||
f"[Executor] Processing execution {execution['id']} for agent {execution['agent_id']} (status: {execution['status']})"
|
||||
)
|
||||
|
||||
if execution['status'] == 'approved':
|
||||
if execution["status"] == "approved":
|
||||
# Resume approved execution
|
||||
await self._resume_approved_execution(execution)
|
||||
else:
|
||||
@@ -87,7 +93,7 @@ class AgentExecutor:
|
||||
logger.error(f"Failed to execute agent {execution['agent_id']}: {e}")
|
||||
await self._mark_execution_failed(execution["id"], str(e))
|
||||
|
||||
async def _execute_agent(self, execution: Dict[str, Any]):
|
||||
async def _execute_agent(self, execution: dict[str, Any]):
|
||||
"""Execute a single agent task"""
|
||||
agent_id = execution["agent_id"]
|
||||
task_id = execution["task_id"]
|
||||
@@ -127,7 +133,7 @@ class AgentExecutor:
|
||||
# Send completion notification
|
||||
await self._send_completion_notification(agent, task, result)
|
||||
|
||||
async def _resume_approved_execution(self, execution: Dict[str, Any]):
|
||||
async def _resume_approved_execution(self, execution: dict[str, Any]):
|
||||
"""Resume an approved execution and execute its actions"""
|
||||
agent_id = execution["agent_id"]
|
||||
task_id = execution["task_id"]
|
||||
@@ -160,12 +166,9 @@ class AgentExecutor:
|
||||
# Send completion notification
|
||||
await self._send_completion_notification(agent, task, result)
|
||||
|
||||
async def _build_context(self, task: Dict[str, Any]) -> Dict[str, Any]:
|
||||
async def _build_context(self, task: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Build context for agent execution"""
|
||||
context = {
|
||||
"task": task,
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
context = {"task": task, "timestamp": datetime.utcnow().isoformat()}
|
||||
|
||||
# Get related tasks from same project
|
||||
if task.get("project_id"):
|
||||
@@ -178,7 +181,7 @@ class AgentExecutor:
|
||||
|
||||
return context
|
||||
|
||||
async def _execute_actions(self, task: Dict[str, Any], actions: list):
|
||||
async def _execute_actions(self, task: dict[str, Any], actions: list):
|
||||
"""Execute proposed actions"""
|
||||
for action in actions:
|
||||
action_type = action.get("type")
|
||||
@@ -203,7 +206,7 @@ class AgentExecutor:
|
||||
logger.error(f"Failed to execute action {action_type}: {e}")
|
||||
raise
|
||||
|
||||
async def _action_create_subtask(self, parent_task: Dict[str, Any], action: Dict[str, Any]):
|
||||
async def _action_create_subtask(self, parent_task: dict[str, Any], action: dict[str, Any]):
|
||||
"""Create a subtask"""
|
||||
await opc_db.create_task(
|
||||
title=action.get("title", "Subtask"),
|
||||
@@ -212,48 +215,52 @@ class AgentExecutor:
|
||||
priority=parent_task.get("priority", "medium"),
|
||||
project_id=parent_task.get("project_id"),
|
||||
tags=parent_task.get("tags", []),
|
||||
actor="agent"
|
||||
actor="agent",
|
||||
)
|
||||
logger.info(f"Created subtask: {action.get('title')}")
|
||||
|
||||
async def _action_update_status(self, task: Dict[str, Any], action: Dict[str, Any]):
|
||||
async def _action_update_status(self, task: dict[str, Any], action: dict[str, Any]):
|
||||
"""Update task status"""
|
||||
new_status = action.get("status")
|
||||
if new_status:
|
||||
await opc_db.update_task(
|
||||
task_id=task["id"],
|
||||
updates={"status": new_status},
|
||||
actor="agent"
|
||||
)
|
||||
await opc_db.update_task(task_id=task["id"], updates={"status": new_status}, actor="agent")
|
||||
logger.info(f"Updated task {task['id']} status to {new_status}")
|
||||
|
||||
async def _action_send_notification(self, action: Dict[str, Any]):
|
||||
async def _action_send_notification(self, action: dict[str, Any]):
|
||||
"""Send notification"""
|
||||
message = action.get("message", "")
|
||||
if message and TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID:
|
||||
await self._send_telegram(message)
|
||||
|
||||
async def _action_add_comment(self, task: Dict[str, Any], action: Dict[str, Any]):
|
||||
async def _action_add_comment(self, task: dict[str, Any], action: dict[str, Any]):
|
||||
"""Add comment to task (stored in history)"""
|
||||
comment = action.get("comment", "")
|
||||
if comment:
|
||||
# Store as task history
|
||||
pool = await opc_db.get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute("""
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO task_history (task_id, action, actor, actor_type, new_value)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
""", task["id"], "comment", "agent", "agent", comment)
|
||||
""",
|
||||
task["id"],
|
||||
"comment",
|
||||
"agent",
|
||||
"agent",
|
||||
comment,
|
||||
)
|
||||
|
||||
async def _update_execution_status(self, execution_id: int, status: str):
|
||||
"""Update execution status"""
|
||||
await opc_db.update_execution_status(execution_id, status)
|
||||
|
||||
async def _mark_execution_completed(self, execution_id: int, result: Dict[str, Any]):
|
||||
async def _mark_execution_completed(self, execution_id: int, result: dict[str, Any]):
|
||||
"""Mark execution as completed"""
|
||||
pool = await opc_db.get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute("""
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE agent_executions
|
||||
SET status = $1,
|
||||
output_result = $2,
|
||||
@@ -261,36 +268,46 @@ class AgentExecutor:
|
||||
actions_executed = $3,
|
||||
completed_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $4
|
||||
""", "completed",
|
||||
""",
|
||||
"completed",
|
||||
opc_db.json.dumps(result),
|
||||
opc_db.json.dumps(result.get("actions", [])),
|
||||
execution_id)
|
||||
execution_id,
|
||||
)
|
||||
|
||||
async def _mark_execution_failed(self, execution_id: int, error: str):
|
||||
"""Mark execution as failed"""
|
||||
pool = await opc_db.get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute("""
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE agent_executions
|
||||
SET status = $1, error_message = $2, completed_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $3
|
||||
""", "failed", error, execution_id)
|
||||
""",
|
||||
"failed",
|
||||
error,
|
||||
execution_id,
|
||||
)
|
||||
|
||||
async def _mark_pending_approval(self, execution_id: int, result: Dict[str, Any]):
|
||||
async def _mark_pending_approval(self, execution_id: int, result: dict[str, Any]):
|
||||
"""Mark execution as pending approval"""
|
||||
pool = await opc_db.get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute("""
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE agent_executions
|
||||
SET status = $1,
|
||||
output_result = $2,
|
||||
actions_proposed = $3,
|
||||
requires_approval = TRUE
|
||||
WHERE id = $4
|
||||
""", "pending_approval",
|
||||
""",
|
||||
"pending_approval",
|
||||
opc_db.json.dumps(result),
|
||||
opc_db.json.dumps(result.get("actions", [])),
|
||||
execution_id)
|
||||
execution_id,
|
||||
)
|
||||
|
||||
async def _send_telegram(self, message: str):
|
||||
"""Send Telegram notification"""
|
||||
@@ -299,16 +316,12 @@ class AgentExecutor:
|
||||
async with httpx.AsyncClient(proxy=proxy_url, timeout=10.0) as client:
|
||||
await client.post(
|
||||
f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
|
||||
data={
|
||||
"chat_id": TELEGRAM_CHAT_ID,
|
||||
"text": message,
|
||||
"parse_mode": "Markdown"
|
||||
}
|
||||
data={"chat_id": TELEGRAM_CHAT_ID, "text": message, "parse_mode": "Markdown"},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send Telegram notification: {e}")
|
||||
|
||||
async def _send_approval_notification(self, agent: BaseAgent, task: Dict[str, Any], result: Dict[str, Any]):
|
||||
async def _send_approval_notification(self, agent: BaseAgent, task: dict[str, Any], result: dict[str, Any]):
|
||||
"""Send notification for approval request"""
|
||||
message = f"""🤖 *Agent Approval Required*
|
||||
|
||||
@@ -331,11 +344,11 @@ Proposed Actions:
|
||||
await email_service.send_agent_approval_email(
|
||||
agent_name=agent.name,
|
||||
task=task,
|
||||
reasoning=result.get('reasoning', 'No reasoning provided'),
|
||||
actions=result.get('actions', [])
|
||||
reasoning=result.get("reasoning", "No reasoning provided"),
|
||||
actions=result.get("actions", []),
|
||||
)
|
||||
|
||||
async def _send_completion_notification(self, agent: BaseAgent, task: Dict[str, Any], result: Dict[str, Any]):
|
||||
async def _send_completion_notification(self, agent: BaseAgent, task: dict[str, Any], result: dict[str, Any]):
|
||||
"""Send notification for completed execution"""
|
||||
message = f"""✅ *Agent Task Completed*
|
||||
|
||||
@@ -349,14 +362,12 @@ Actions Executed: {len(result.get('actions', []))}
|
||||
|
||||
# Send Email
|
||||
await email_service.send_agent_completion_email(
|
||||
agent_name=agent.name,
|
||||
task=task,
|
||||
actions_count=len(result.get('actions', []))
|
||||
agent_name=agent.name, task=task, actions_count=len(result.get("actions", []))
|
||||
)
|
||||
|
||||
|
||||
# Global executor instance
|
||||
_executor: Optional[AgentExecutor] = None
|
||||
_executor: AgentExecutor | None = None
|
||||
|
||||
|
||||
async def get_executor() -> AgentExecutor:
|
||||
@@ -382,5 +393,6 @@ async def start_executor():
|
||||
except Exception as e:
|
||||
print(f"ERROR in start_executor: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
raise
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
"""
|
||||
Base Agent Class - Foundation for all OPC agents
|
||||
"""
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
import json
|
||||
import httpx
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class BaseAgent:
|
||||
"""Base class for all OPC agents"""
|
||||
|
||||
def __init__(self, agent_id: str, name: str, role: str, system_prompt: str, config: Dict[str, Any]):
|
||||
def __init__(self, agent_id: str, name: str, role: str, system_prompt: str, config: dict[str, Any]):
|
||||
self.agent_id = agent_id
|
||||
self.name = name
|
||||
self.role = role
|
||||
@@ -20,7 +21,7 @@ class BaseAgent:
|
||||
self.litellm_url = os.getenv("LITELLM_URL", "http://litellm:4005")
|
||||
self.litellm_api_key = os.getenv("LITELLM_API_KEY", "")
|
||||
|
||||
async def execute(self, task: Dict[str, Any], context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
async def execute(self, task: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Execute agent on a task
|
||||
Returns: {
|
||||
@@ -40,7 +41,7 @@ class BaseAgent:
|
||||
|
||||
return result
|
||||
|
||||
def _build_prompt(self, task: Dict[str, Any], context: Dict[str, Any]) -> str:
|
||||
def _build_prompt(self, task: dict[str, Any], context: dict[str, Any]) -> str:
|
||||
"""Build the prompt for the LLM"""
|
||||
prompt = f"""You are {self.name}, a {self.role} agent.
|
||||
|
||||
@@ -77,6 +78,7 @@ Only propose actions you can actually execute. Be specific and actionable."""
|
||||
async def _call_llm(self, prompt: str) -> str:
|
||||
"""Call LiteLLM proxy"""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
model = self.config.get("model", "claude-sonnet-4-6")
|
||||
@@ -96,11 +98,11 @@ Only propose actions you can actually execute. Be specific and actionable."""
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": self.system_prompt},
|
||||
{"role": "user", "content": prompt}
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
"temperature": temperature,
|
||||
"max_tokens": 2000
|
||||
}
|
||||
"max_tokens": 2000,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
@@ -118,7 +120,7 @@ Only propose actions you can actually execute. Be specific and actionable."""
|
||||
logger.error(f"LiteLLM call failed: {e}")
|
||||
raise Exception(f"LLM call failed: {str(e)}")
|
||||
|
||||
def _parse_response(self, response: str) -> Dict[str, Any]:
|
||||
def _parse_response(self, response: str) -> dict[str, Any]:
|
||||
"""Parse LLM response and extract actions"""
|
||||
try:
|
||||
# Try to extract JSON from response
|
||||
@@ -148,16 +150,11 @@ Only propose actions you can actually execute. Be specific and actionable."""
|
||||
# Fallback: return error action
|
||||
return {
|
||||
"reasoning": f"Failed to parse response: {str(e)}",
|
||||
"actions": [
|
||||
{
|
||||
"type": "error",
|
||||
"message": f"Agent response parsing failed: {str(e)}"
|
||||
}
|
||||
],
|
||||
"requires_approval": False
|
||||
"actions": [{"type": "error", "message": f"Agent response parsing failed: {str(e)}"}],
|
||||
"requires_approval": False,
|
||||
}
|
||||
|
||||
def get_capabilities(self) -> List[str]:
|
||||
def get_capabilities(self) -> list[str]:
|
||||
"""Get agent capabilities"""
|
||||
return self.config.get("capabilities", [])
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"""
|
||||
Email notification service for OPC
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
import logging
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -90,7 +91,9 @@ async def send_agent_approval_email(agent_name: str, task: dict, reasoning: str,
|
||||
"""Send email for agent approval request"""
|
||||
subject = f"Agent Approval Required: {agent_name}"
|
||||
|
||||
actions_text = "\n".join([f"- {action.get('type')}: {action.get('title', action.get('message', 'N/A'))}" for action in actions])
|
||||
actions_text = "\n".join(
|
||||
[f"- {action.get('type')}: {action.get('title', action.get('message', 'N/A'))}" for action in actions]
|
||||
)
|
||||
|
||||
body = f"""Agent approval required:
|
||||
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
"""
|
||||
PDF Invoice Generation Service
|
||||
"""
|
||||
from reportlab.lib.pagesizes import letter
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||
from reportlab.lib.units import inch
|
||||
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer
|
||||
from reportlab.lib.enums import TA_RIGHT, TA_CENTER
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Any
|
||||
|
||||
import io
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.pagesizes import letter
|
||||
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
||||
from reportlab.lib.units import inch
|
||||
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
|
||||
|
||||
|
||||
def generate_invoice_pdf(
|
||||
invoice_number: str,
|
||||
client: Dict[str, Any],
|
||||
time_entries: List[Dict[str, Any]],
|
||||
client: dict[str, Any],
|
||||
time_entries: list[dict[str, Any]],
|
||||
hourly_rate: float = 100.0,
|
||||
company_name: str = "Your Company",
|
||||
company_address: str = "",
|
||||
company_email: str = "",
|
||||
company_phone: str = ""
|
||||
company_phone: str = "",
|
||||
) -> bytes:
|
||||
"""
|
||||
Generate PDF invoice from time entries
|
||||
@@ -34,18 +35,18 @@ def generate_invoice_pdf(
|
||||
|
||||
# Custom styles
|
||||
title_style = ParagraphStyle(
|
||||
'CustomTitle',
|
||||
parent=styles['Heading1'],
|
||||
"CustomTitle",
|
||||
parent=styles["Heading1"],
|
||||
fontSize=24,
|
||||
textColor=colors.HexColor('#1F2937'),
|
||||
textColor=colors.HexColor("#1F2937"),
|
||||
spaceAfter=30,
|
||||
)
|
||||
|
||||
header_style = ParagraphStyle(
|
||||
'Header',
|
||||
parent=styles['Normal'],
|
||||
"Header",
|
||||
parent=styles["Normal"],
|
||||
fontSize=10,
|
||||
textColor=colors.HexColor('#6B7280'),
|
||||
textColor=colors.HexColor("#6B7280"),
|
||||
)
|
||||
|
||||
# Company Header
|
||||
@@ -61,10 +62,10 @@ def generate_invoice_pdf(
|
||||
|
||||
# Invoice Title
|
||||
invoice_title = ParagraphStyle(
|
||||
'InvoiceTitle',
|
||||
parent=styles['Heading2'],
|
||||
"InvoiceTitle",
|
||||
parent=styles["Heading2"],
|
||||
fontSize=18,
|
||||
textColor=colors.HexColor('#4F46E5'),
|
||||
textColor=colors.HexColor("#4F46E5"),
|
||||
)
|
||||
elements.append(Paragraph("INVOICE", invoice_title))
|
||||
elements.append(Spacer(1, 0.2 * inch))
|
||||
@@ -74,96 +75,93 @@ def generate_invoice_pdf(
|
||||
invoice_data = [
|
||||
["Invoice Number:", invoice_number],
|
||||
["Invoice Date:", invoice_date],
|
||||
["Client:", client.get('name', 'N/A')],
|
||||
["Client:", client.get("name", "N/A")],
|
||||
]
|
||||
|
||||
if client.get('company'):
|
||||
invoice_data.append(["Company:", client['company']])
|
||||
if client.get('email'):
|
||||
invoice_data.append(["Email:", client['email']])
|
||||
if client.get("company"):
|
||||
invoice_data.append(["Company:", client["company"]])
|
||||
if client.get("email"):
|
||||
invoice_data.append(["Email:", client["email"]])
|
||||
|
||||
invoice_table = Table(invoice_data, colWidths=[2*inch, 4*inch])
|
||||
invoice_table.setStyle(TableStyle([
|
||||
('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
|
||||
('FONTNAME', (1, 0), (1, -1), 'Helvetica'),
|
||||
('FONTSIZE', (0, 0), (-1, -1), 10),
|
||||
('TEXTCOLOR', (0, 0), (0, -1), colors.HexColor('#374151')),
|
||||
('TEXTCOLOR', (1, 0), (1, -1), colors.HexColor('#1F2937')),
|
||||
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
||||
('BOTTOMPADDING', (0, 0), (-1, -1), 8),
|
||||
]))
|
||||
invoice_table = Table(invoice_data, colWidths=[2 * inch, 4 * inch])
|
||||
invoice_table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("FONTNAME", (0, 0), (0, -1), "Helvetica-Bold"),
|
||||
("FONTNAME", (1, 0), (1, -1), "Helvetica"),
|
||||
("FONTSIZE", (0, 0), (-1, -1), 10),
|
||||
("TEXTCOLOR", (0, 0), (0, -1), colors.HexColor("#374151")),
|
||||
("TEXTCOLOR", (1, 0), (1, -1), colors.HexColor("#1F2937")),
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
|
||||
]
|
||||
)
|
||||
)
|
||||
elements.append(invoice_table)
|
||||
elements.append(Spacer(1, 0.4 * inch))
|
||||
|
||||
# Time Entries Table
|
||||
table_data = [
|
||||
["Date", "Description", "Hours", "Rate", "Amount"]
|
||||
]
|
||||
table_data = [["Date", "Description", "Hours", "Rate", "Amount"]]
|
||||
|
||||
total_hours = 0
|
||||
total_amount = 0
|
||||
|
||||
for entry in time_entries:
|
||||
hours = entry['duration_minutes'] / 60
|
||||
rate = entry.get('hourly_rate', hourly_rate)
|
||||
hours = entry["duration_minutes"] / 60
|
||||
rate = entry.get("hourly_rate", hourly_rate)
|
||||
amount = hours * rate
|
||||
|
||||
total_hours += hours
|
||||
total_amount += amount
|
||||
|
||||
date_str = entry['started_at'].strftime("%Y-%m-%d") if entry.get('started_at') else "N/A"
|
||||
desc = entry.get('description', 'Work performed')
|
||||
date_str = entry["started_at"].strftime("%Y-%m-%d") if entry.get("started_at") else "N/A"
|
||||
desc = entry.get("description", "Work performed")
|
||||
|
||||
table_data.append([
|
||||
date_str,
|
||||
desc[:50] + "..." if len(desc) > 50 else desc,
|
||||
f"{hours:.2f}",
|
||||
f"${rate:.2f}",
|
||||
f"${amount:.2f}"
|
||||
])
|
||||
table_data.append(
|
||||
[date_str, desc[:50] + "..." if len(desc) > 50 else desc, f"{hours:.2f}", f"${rate:.2f}", f"${amount:.2f}"]
|
||||
)
|
||||
|
||||
# Add totals row
|
||||
table_data.append([
|
||||
"", "Total", f"{total_hours:.2f}", "", f"${total_amount:.2f}"
|
||||
])
|
||||
table_data.append(["", "Total", f"{total_hours:.2f}", "", f"${total_amount:.2f}"])
|
||||
|
||||
# Create table
|
||||
time_table = Table(table_data, colWidths=[1.2*inch, 2.8*inch, 0.8*inch, 0.8*inch, 1*inch])
|
||||
time_table.setStyle(TableStyle([
|
||||
# Header row
|
||||
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#4F46E5')),
|
||||
('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
|
||||
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
||||
('FONTSIZE', (0, 0), (-1, 0), 11),
|
||||
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
|
||||
|
||||
# Data rows
|
||||
('FONTNAME', (0, 1), (-1, -2), 'Helvetica'),
|
||||
('FONTSIZE', (0, 1), (-1, -2), 9),
|
||||
('ROWBACKGROUNDS', (0, 1), (-1, -2), [colors.white, colors.HexColor('#F9FAFB')]),
|
||||
('GRID', (0, 0), (-1, -2), 0.5, colors.HexColor('#E5E7EB')),
|
||||
|
||||
# Total row
|
||||
('BACKGROUND', (0, -1), (-1, -1), colors.HexColor('#F3F4F6')),
|
||||
('FONTNAME', (0, -1), (-1, -1), 'Helvetica-Bold'),
|
||||
('FONTSIZE', (0, -1), (-1, -1), 11),
|
||||
('TOPPADDING', (0, -1), (-1, -1), 12),
|
||||
('BOTTOMPADDING', (0, -1), (-1, -1), 12),
|
||||
|
||||
# Alignment
|
||||
('ALIGN', (2, 0), (-1, -1), 'RIGHT'),
|
||||
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
||||
]))
|
||||
time_table = Table(table_data, colWidths=[1.2 * inch, 2.8 * inch, 0.8 * inch, 0.8 * inch, 1 * inch])
|
||||
time_table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
# Header row
|
||||
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#4F46E5")),
|
||||
("TEXTCOLOR", (0, 0), (-1, 0), colors.whitesmoke),
|
||||
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
||||
("FONTSIZE", (0, 0), (-1, 0), 11),
|
||||
("BOTTOMPADDING", (0, 0), (-1, 0), 12),
|
||||
# Data rows
|
||||
("FONTNAME", (0, 1), (-1, -2), "Helvetica"),
|
||||
("FONTSIZE", (0, 1), (-1, -2), 9),
|
||||
("ROWBACKGROUNDS", (0, 1), (-1, -2), [colors.white, colors.HexColor("#F9FAFB")]),
|
||||
("GRID", (0, 0), (-1, -2), 0.5, colors.HexColor("#E5E7EB")),
|
||||
# Total row
|
||||
("BACKGROUND", (0, -1), (-1, -1), colors.HexColor("#F3F4F6")),
|
||||
("FONTNAME", (0, -1), (-1, -1), "Helvetica-Bold"),
|
||||
("FONTSIZE", (0, -1), (-1, -1), 11),
|
||||
("TOPPADDING", (0, -1), (-1, -1), 12),
|
||||
("BOTTOMPADDING", (0, -1), (-1, -1), 12),
|
||||
# Alignment
|
||||
("ALIGN", (2, 0), (-1, -1), "RIGHT"),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
elements.append(time_table)
|
||||
elements.append(Spacer(1, 0.5 * inch))
|
||||
|
||||
# Payment Terms
|
||||
terms_style = ParagraphStyle(
|
||||
'Terms',
|
||||
parent=styles['Normal'],
|
||||
"Terms",
|
||||
parent=styles["Normal"],
|
||||
fontSize=9,
|
||||
textColor=colors.HexColor('#6B7280'),
|
||||
textColor=colors.HexColor("#6B7280"),
|
||||
)
|
||||
elements.append(Paragraph("<b>Payment Terms:</b> Due within 30 days", terms_style))
|
||||
elements.append(Spacer(1, 0.1 * inch))
|
||||
@@ -179,10 +177,7 @@ def generate_invoice_pdf(
|
||||
|
||||
|
||||
def generate_invoice_from_project(
|
||||
project_id: int,
|
||||
client: Dict[str, Any],
|
||||
time_entries: List[Dict[str, Any]],
|
||||
invoice_number: str = None
|
||||
project_id: int, client: dict[str, Any], time_entries: list[dict[str, Any]], invoice_number: str = None
|
||||
) -> bytes:
|
||||
"""Generate invoice for a project"""
|
||||
if not invoice_number:
|
||||
@@ -195,5 +190,5 @@ def generate_invoice_from_project(
|
||||
company_name="Your OPC",
|
||||
company_address="123 Business St, City, State 12345",
|
||||
company_email="billing@youropc.com",
|
||||
company_phone="+1 (555) 123-4567"
|
||||
company_phone="+1 (555) 123-4567",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user