Merge dev to main: Security improvements and comprehensive test infrastructure #39
@@ -6,7 +6,7 @@ from fastapi.responses import JSONResponse
|
|||||||
from slowapi import Limiter
|
from slowapi import Limiter
|
||||||
from slowapi.util import get_remote_address
|
from slowapi.util import get_remote_address
|
||||||
from slowapi.errors import RateLimitExceeded
|
from slowapi.errors import RateLimitExceeded
|
||||||
from routers import docker_router, gitea, files, terminal, system, auth, chat_summary, security, passkey, totp, litellm, cc_connect, info_engine, opc_tasks, opc_agents, opc_ws
|
from routers import docker_router, gitea, files, terminal, system, auth, chat_summary, security, passkey, totp, litellm, cc_connect, info_engine, opc_tasks, opc_agents, opc_ws, opc_projects
|
||||||
import auth as auth_module
|
import auth as auth_module
|
||||||
import config
|
import config
|
||||||
from rbac import require_page
|
from rbac import require_page
|
||||||
@@ -199,6 +199,8 @@ app.include_router(opc_tasks.router, prefix="/api/opc",
|
|||||||
dependencies=[Depends(_inject_user), Depends(require_page("opc"))])
|
dependencies=[Depends(_inject_user), Depends(require_page("opc"))])
|
||||||
app.include_router(opc_agents.router, prefix="/api/opc",
|
app.include_router(opc_agents.router, prefix="/api/opc",
|
||||||
dependencies=[Depends(_inject_user), Depends(require_page("opc"))])
|
dependencies=[Depends(_inject_user), Depends(require_page("opc"))])
|
||||||
|
app.include_router(opc_projects.router, prefix="/api/opc",
|
||||||
|
dependencies=[Depends(_inject_user), Depends(require_page("opc"))])
|
||||||
|
|
||||||
# WebSocket endpoint (auth handled inside handler via auth cookie; query token fallback temporary)
|
# WebSocket endpoint (auth handled inside handler via auth cookie; query token fallback temporary)
|
||||||
app.add_api_websocket_route("/ws/terminal", terminal.ws_endpoint)
|
app.add_api_websocket_route("/ws/terminal", terminal.ws_endpoint)
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
"""
|
||||||
|
OPC Projects and Invoicing Router
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||||
|
from typing import Optional
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from db import opc_db
|
||||||
|
from rbac import User, _inject_user
|
||||||
|
from services import pdf_service
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
client_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceGenerate(BaseModel):
|
||||||
|
project_id: int
|
||||||
|
client_id: int
|
||||||
|
invoice_number: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/projects")
|
||||||
|
async def list_projects(
|
||||||
|
user: User = Depends(_inject_user)
|
||||||
|
):
|
||||||
|
"""List all projects"""
|
||||||
|
pool = await opc_db.get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch("SELECT * FROM projects ORDER BY created_at DESC")
|
||||||
|
return {"items": [dict(row) for row in rows]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/projects")
|
||||||
|
async def create_project(
|
||||||
|
project: ProjectCreate,
|
||||||
|
user: User = Depends(_inject_user)
|
||||||
|
):
|
||||||
|
"""Create a new project"""
|
||||||
|
pool = await opc_db.get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow("""
|
||||||
|
INSERT INTO projects (name, description, client_id)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING *
|
||||||
|
""", project.name, project.description, project.client_id)
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/clients")
|
||||||
|
async def list_clients(
|
||||||
|
user: User = Depends(_inject_user)
|
||||||
|
):
|
||||||
|
"""List all clients"""
|
||||||
|
pool = await opc_db.get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch("SELECT * FROM clients ORDER BY name")
|
||||||
|
return {"items": [dict(row) for row in rows]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/clients")
|
||||||
|
async def create_client(
|
||||||
|
name: str,
|
||||||
|
email: Optional[str] = None,
|
||||||
|
company: Optional[str] = None,
|
||||||
|
user: User = Depends(_inject_user)
|
||||||
|
):
|
||||||
|
"""Create a new client"""
|
||||||
|
pool = await opc_db.get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow("""
|
||||||
|
INSERT INTO clients (name, email, company)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING *
|
||||||
|
""", name, email, company)
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/invoices/generate")
|
||||||
|
async def generate_invoice(
|
||||||
|
invoice: InvoiceGenerate,
|
||||||
|
user: User = Depends(_inject_user)
|
||||||
|
):
|
||||||
|
"""Generate PDF invoice for a project"""
|
||||||
|
pool = await opc_db.get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
# Get client
|
||||||
|
client_row = await conn.fetchrow("SELECT * FROM clients WHERE id = $1", invoice.client_id)
|
||||||
|
if not client_row:
|
||||||
|
raise HTTPException(status_code=404, detail="Client not found")
|
||||||
|
client = dict(client_row)
|
||||||
|
|
||||||
|
# Get time entries for project
|
||||||
|
time_rows = await conn.fetch("""
|
||||||
|
SELECT * FROM time_entries
|
||||||
|
WHERE project_id = $1 AND billable = TRUE
|
||||||
|
ORDER BY started_at
|
||||||
|
""", invoice.project_id)
|
||||||
|
|
||||||
|
if not time_rows:
|
||||||
|
raise HTTPException(status_code=400, detail="No billable time entries found")
|
||||||
|
|
||||||
|
time_entries = [dict(row) for row in time_rows]
|
||||||
|
|
||||||
|
# Generate PDF
|
||||||
|
pdf_bytes = pdf_service.generate_invoice_from_project(
|
||||||
|
project_id=invoice.project_id,
|
||||||
|
client=client,
|
||||||
|
time_entries=time_entries,
|
||||||
|
invoice_number=invoice.invoice_number
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return PDF
|
||||||
|
filename = f"invoice-{invoice.invoice_number or datetime.now().strftime('%Y%m%d')}.pdf"
|
||||||
|
return Response(
|
||||||
|
content=pdf_bytes,
|
||||||
|
media_type="application/pdf",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f"attachment; filename={filename}"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/projects/{project_id}/time")
|
||||||
|
async def get_project_time(
|
||||||
|
project_id: int,
|
||||||
|
user: User = Depends(_inject_user)
|
||||||
|
):
|
||||||
|
"""Get time entries for a project"""
|
||||||
|
entries = await opc_db.get_time_entries(project_id=project_id)
|
||||||
|
total_minutes = sum(e["duration_minutes"] for e in entries)
|
||||||
|
billable_minutes = sum(e["duration_minutes"] for e in entries if e.get("billable"))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"entries": entries,
|
||||||
|
"total_minutes": total_minutes,
|
||||||
|
"total_hours": round(total_minutes / 60, 2),
|
||||||
|
"billable_minutes": billable_minutes,
|
||||||
|
"billable_hours": round(billable_minutes / 60, 2)
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ from typing import Dict, Any, Optional
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from db import opc_db
|
from db import opc_db
|
||||||
from services.agents.base_agent import BaseAgent
|
from services.agents.base_agent import BaseAgent
|
||||||
|
from services import email_service
|
||||||
import httpx
|
import httpx
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@@ -273,8 +274,17 @@ Proposed Actions:
|
|||||||
|
|
||||||
message += "\nPlease review and approve in the OPC dashboard."
|
message += "\nPlease review and approve in the OPC dashboard."
|
||||||
|
|
||||||
|
# Send Telegram
|
||||||
await self._send_telegram(message)
|
await self._send_telegram(message)
|
||||||
|
|
||||||
|
# Send Email
|
||||||
|
await email_service.send_agent_approval_email(
|
||||||
|
agent_name=agent.name,
|
||||||
|
task=task,
|
||||||
|
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"""
|
"""Send notification for completed execution"""
|
||||||
message = f"""✅ *Agent Task Completed*
|
message = f"""✅ *Agent Task Completed*
|
||||||
@@ -284,8 +294,16 @@ Task: {task['title']}
|
|||||||
|
|
||||||
Actions Executed: {len(result.get('actions', []))}
|
Actions Executed: {len(result.get('actions', []))}
|
||||||
"""
|
"""
|
||||||
|
# Send Telegram
|
||||||
await self._send_telegram(message)
|
await self._send_telegram(message)
|
||||||
|
|
||||||
|
# Send Email
|
||||||
|
await email_service.send_agent_completion_email(
|
||||||
|
agent_name=agent.name,
|
||||||
|
task=task,
|
||||||
|
actions_count=len(result.get('actions', []))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Global executor instance
|
# Global executor instance
|
||||||
_executor: Optional[AgentExecutor] = None
|
_executor: Optional[AgentExecutor] = None
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"""
|
||||||
|
Email notification service for OPC
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import smtplib
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Email configuration from environment
|
||||||
|
SMTP_HOST = os.getenv("SMTP_HOST", "smtp.gmail.com")
|
||||||
|
SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
|
||||||
|
SMTP_USER = os.getenv("SMTP_USER", "")
|
||||||
|
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
|
||||||
|
SMTP_FROM = os.getenv("SMTP_FROM", SMTP_USER)
|
||||||
|
SMTP_TO = os.getenv("SMTP_TO", "")
|
||||||
|
|
||||||
|
|
||||||
|
async def send_email(subject: str, body: str, html_body: str = None):
|
||||||
|
"""Send email notification"""
|
||||||
|
if not SMTP_USER or not SMTP_PASSWORD or not SMTP_TO:
|
||||||
|
logger.warning("Email not configured, skipping notification")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create message
|
||||||
|
msg = MIMEMultipart("alternative")
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg["From"] = SMTP_FROM
|
||||||
|
msg["To"] = SMTP_TO
|
||||||
|
|
||||||
|
# Add plain text part
|
||||||
|
msg.attach(MIMEText(body, "plain"))
|
||||||
|
|
||||||
|
# Add HTML part if provided
|
||||||
|
if html_body:
|
||||||
|
msg.attach(MIMEText(html_body, "html"))
|
||||||
|
|
||||||
|
# Send email
|
||||||
|
with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:
|
||||||
|
server.starttls()
|
||||||
|
server.login(SMTP_USER, SMTP_PASSWORD)
|
||||||
|
server.send_message(msg)
|
||||||
|
|
||||||
|
logger.info(f"Email sent: {subject}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send email: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def send_task_assigned_email(task: dict, assigned_to: str, assigned_type: str):
|
||||||
|
"""Send email when task is assigned"""
|
||||||
|
subject = f"Task Assigned: {task['title']}"
|
||||||
|
|
||||||
|
body = f"""You have been assigned a new task:
|
||||||
|
|
||||||
|
Title: {task['title']}
|
||||||
|
Priority: {task['priority']}
|
||||||
|
Status: {task['status']}
|
||||||
|
|
||||||
|
Description:
|
||||||
|
{task.get('description', 'No description')}
|
||||||
|
|
||||||
|
View task: https://nas.jimmygan.com/?page=opc
|
||||||
|
"""
|
||||||
|
|
||||||
|
html_body = f"""
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h2>Task Assigned</h2>
|
||||||
|
<p>You have been assigned a new task:</p>
|
||||||
|
<table style="border-collapse: collapse; margin: 20px 0;">
|
||||||
|
<tr><td style="padding: 8px; font-weight: bold;">Title:</td><td style="padding: 8px;">{task['title']}</td></tr>
|
||||||
|
<tr><td style="padding: 8px; font-weight: bold;">Priority:</td><td style="padding: 8px;">{task['priority']}</td></tr>
|
||||||
|
<tr><td style="padding: 8px; font-weight: bold;">Status:</td><td style="padding: 8px;">{task['status']}</td></tr>
|
||||||
|
</table>
|
||||||
|
<p><strong>Description:</strong></p>
|
||||||
|
<p>{task.get('description', 'No description')}</p>
|
||||||
|
<p><a href="https://nas.jimmygan.com/?page=opc" style="background-color: #4F46E5; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block;">View Task</a></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
await send_email(subject, body, html_body)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_agent_approval_email(agent_name: str, task: dict, reasoning: str, actions: list):
|
||||||
|
"""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])
|
||||||
|
|
||||||
|
body = f"""Agent approval required:
|
||||||
|
|
||||||
|
Agent: {agent_name}
|
||||||
|
Task: {task['title']}
|
||||||
|
|
||||||
|
Reasoning:
|
||||||
|
{reasoning}
|
||||||
|
|
||||||
|
Proposed Actions:
|
||||||
|
{actions_text}
|
||||||
|
|
||||||
|
Please review and approve in the OPC dashboard:
|
||||||
|
https://nas.jimmygan.com/?page=opc
|
||||||
|
"""
|
||||||
|
|
||||||
|
html_body = f"""
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h2>🤖 Agent Approval Required</h2>
|
||||||
|
<p><strong>Agent:</strong> {agent_name}</p>
|
||||||
|
<p><strong>Task:</strong> {task['title']}</p>
|
||||||
|
<h3>Reasoning:</h3>
|
||||||
|
<p>{reasoning}</p>
|
||||||
|
<h3>Proposed Actions:</h3>
|
||||||
|
<ul>
|
||||||
|
{"".join([f"<li><strong>{action.get('type')}</strong>: {action.get('title', action.get('message', 'N/A'))}</li>" for action in actions])}
|
||||||
|
</ul>
|
||||||
|
<p><a href="https://nas.jimmygan.com/?page=opc" style="background-color: #4F46E5; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block;">Review & Approve</a></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
await send_email(subject, body, html_body)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_agent_completion_email(agent_name: str, task: dict, actions_count: int):
|
||||||
|
"""Send email when agent completes task"""
|
||||||
|
subject = f"Agent Task Completed: {agent_name}"
|
||||||
|
|
||||||
|
body = f"""Agent has completed a task:
|
||||||
|
|
||||||
|
Agent: {agent_name}
|
||||||
|
Task: {task['title']}
|
||||||
|
Actions Executed: {actions_count}
|
||||||
|
|
||||||
|
View details: https://nas.jimmygan.com/?page=opc
|
||||||
|
"""
|
||||||
|
|
||||||
|
html_body = f"""
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h2>✅ Agent Task Completed</h2>
|
||||||
|
<p><strong>Agent:</strong> {agent_name}</p>
|
||||||
|
<p><strong>Task:</strong> {task['title']}</p>
|
||||||
|
<p><strong>Actions Executed:</strong> {actions_count}</p>
|
||||||
|
<p><a href="https://nas.jimmygan.com/?page=opc" style="background-color: #10B981; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block;">View Details</a></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
await send_email(subject, body, html_body)
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def generate_invoice_pdf(
|
||||||
|
invoice_number: str,
|
||||||
|
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 = ""
|
||||||
|
) -> bytes:
|
||||||
|
"""
|
||||||
|
Generate PDF invoice from time entries
|
||||||
|
|
||||||
|
Returns: PDF bytes
|
||||||
|
"""
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
doc = SimpleDocTemplate(buffer, pagesize=letter)
|
||||||
|
elements = []
|
||||||
|
styles = getSampleStyleSheet()
|
||||||
|
|
||||||
|
# Custom styles
|
||||||
|
title_style = ParagraphStyle(
|
||||||
|
'CustomTitle',
|
||||||
|
parent=styles['Heading1'],
|
||||||
|
fontSize=24,
|
||||||
|
textColor=colors.HexColor('#1F2937'),
|
||||||
|
spaceAfter=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
header_style = ParagraphStyle(
|
||||||
|
'Header',
|
||||||
|
parent=styles['Normal'],
|
||||||
|
fontSize=10,
|
||||||
|
textColor=colors.HexColor('#6B7280'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Company Header
|
||||||
|
elements.append(Paragraph(company_name, title_style))
|
||||||
|
if company_address:
|
||||||
|
elements.append(Paragraph(company_address, header_style))
|
||||||
|
if company_email:
|
||||||
|
elements.append(Paragraph(f"Email: {company_email}", header_style))
|
||||||
|
if company_phone:
|
||||||
|
elements.append(Paragraph(f"Phone: {company_phone}", header_style))
|
||||||
|
|
||||||
|
elements.append(Spacer(1, 0.3 * inch))
|
||||||
|
|
||||||
|
# Invoice Title
|
||||||
|
invoice_title = ParagraphStyle(
|
||||||
|
'InvoiceTitle',
|
||||||
|
parent=styles['Heading2'],
|
||||||
|
fontSize=18,
|
||||||
|
textColor=colors.HexColor('#4F46E5'),
|
||||||
|
)
|
||||||
|
elements.append(Paragraph("INVOICE", invoice_title))
|
||||||
|
elements.append(Spacer(1, 0.2 * inch))
|
||||||
|
|
||||||
|
# Invoice Details
|
||||||
|
invoice_date = datetime.now().strftime("%B %d, %Y")
|
||||||
|
invoice_data = [
|
||||||
|
["Invoice Number:", invoice_number],
|
||||||
|
["Invoice Date:", invoice_date],
|
||||||
|
["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']])
|
||||||
|
|
||||||
|
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"]
|
||||||
|
]
|
||||||
|
|
||||||
|
total_hours = 0
|
||||||
|
total_amount = 0
|
||||||
|
|
||||||
|
for entry in time_entries:
|
||||||
|
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')
|
||||||
|
|
||||||
|
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}"
|
||||||
|
])
|
||||||
|
|
||||||
|
# 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'),
|
||||||
|
]))
|
||||||
|
|
||||||
|
elements.append(time_table)
|
||||||
|
elements.append(Spacer(1, 0.5 * inch))
|
||||||
|
|
||||||
|
# Payment Terms
|
||||||
|
terms_style = ParagraphStyle(
|
||||||
|
'Terms',
|
||||||
|
parent=styles['Normal'],
|
||||||
|
fontSize=9,
|
||||||
|
textColor=colors.HexColor('#6B7280'),
|
||||||
|
)
|
||||||
|
elements.append(Paragraph("<b>Payment Terms:</b> Due within 30 days", terms_style))
|
||||||
|
elements.append(Spacer(1, 0.1 * inch))
|
||||||
|
elements.append(Paragraph("Thank you for your business!", terms_style))
|
||||||
|
|
||||||
|
# Build PDF
|
||||||
|
doc.build(elements)
|
||||||
|
|
||||||
|
pdf_bytes = buffer.getvalue()
|
||||||
|
buffer.close()
|
||||||
|
|
||||||
|
return pdf_bytes
|
||||||
|
|
||||||
|
|
||||||
|
def generate_invoice_from_project(
|
||||||
|
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:
|
||||||
|
invoice_number = f"INV-{datetime.now().strftime('%Y%m%d')}-{project_id}"
|
||||||
|
|
||||||
|
return generate_invoice_pdf(
|
||||||
|
invoice_number=invoice_number,
|
||||||
|
client=client,
|
||||||
|
time_entries=time_entries,
|
||||||
|
company_name="Your OPC",
|
||||||
|
company_address="123 Business St, City, State 12345",
|
||||||
|
company_email="billing@youropc.com",
|
||||||
|
company_phone="+1 (555) 123-4567"
|
||||||
|
)
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { getAgents, getExecutions } from "../../lib/opc-api.js";
|
||||||
|
|
||||||
|
let agents = $state([]);
|
||||||
|
let executions = $state([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let selectedAgent = $state(null);
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const [agentsRes, executionsRes] = await Promise.all([
|
||||||
|
getAgents(),
|
||||||
|
getExecutions({ limit: 20 })
|
||||||
|
]);
|
||||||
|
agents = agentsRes.items || [];
|
||||||
|
executions = executionsRes.items || [];
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load agent data:", e);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAgentIcon(agentId) {
|
||||||
|
const icons = {
|
||||||
|
pm: "📋",
|
||||||
|
cto: "💻",
|
||||||
|
coo: "⚙️",
|
||||||
|
ceo: "🎯",
|
||||||
|
marketing: "📢",
|
||||||
|
social_media: "📱"
|
||||||
|
};
|
||||||
|
return icons[agentId] || "🤖";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusColor(status) {
|
||||||
|
const colors = {
|
||||||
|
pending: "bg-slate-100 text-slate-700",
|
||||||
|
running: "bg-blue-100 text-blue-700",
|
||||||
|
completed: "bg-emerald-100 text-emerald-700",
|
||||||
|
failed: "bg-rose-100 text-rose-700",
|
||||||
|
pending_approval: "bg-amber-100 text-amber-700"
|
||||||
|
};
|
||||||
|
return colors[status] || colors.pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAgentExecutions(agentId) {
|
||||||
|
return executions.filter(e => e.agent_id === agentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(timestamp) {
|
||||||
|
if (!timestamp) return "N/A";
|
||||||
|
return new Date(timestamp).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
loadData();
|
||||||
|
// Refresh every 10 seconds
|
||||||
|
const interval = setInterval(loadData, 10000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<h2 class="text-xl font-semibold text-surface-900 dark:text-surface-100">
|
||||||
|
AI Agents
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="text-surface-600 dark:text-surface-400">Loading agents...</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Agent Cards -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{#each agents as agent}
|
||||||
|
{@const agentExecs = getAgentExecutions(agent.id)}
|
||||||
|
{@const runningExecs = agentExecs.filter(e => e.status === "running")}
|
||||||
|
{@const completedExecs = agentExecs.filter(e => e.status === "completed")}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onclick={() => selectedAgent = selectedAgent?.id === agent.id ? null : agent}
|
||||||
|
class="bg-white dark:bg-surface-800 rounded-lg p-4 border-2 transition-all text-left hover:shadow-lg {selectedAgent?.id === agent.id ? 'border-primary-500' : 'border-surface-200 dark:border-surface-700'}"
|
||||||
|
>
|
||||||
|
<!-- Agent Header -->
|
||||||
|
<div class="flex items-start justify-between mb-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-3xl">{getAgentIcon(agent.id)}</span>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-semibold text-surface-900 dark:text-surface-100">
|
||||||
|
{agent.name}
|
||||||
|
</h3>
|
||||||
|
<p class="text-xs text-surface-600 dark:text-surface-400">
|
||||||
|
{agent.role}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if runningExecs.length > 0}
|
||||||
|
<span class="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400">
|
||||||
|
<span class="animate-pulse">●</span>
|
||||||
|
Working
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="text-xs text-surface-500">Idle</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Agent Stats -->
|
||||||
|
<div class="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div class="bg-surface-50 dark:bg-surface-900 rounded p-2">
|
||||||
|
<div class="text-xs text-surface-600 dark:text-surface-400">Total Tasks</div>
|
||||||
|
<div class="text-lg font-semibold text-surface-900 dark:text-surface-100">
|
||||||
|
{agentExecs.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-surface-50 dark:bg-surface-900 rounded p-2">
|
||||||
|
<div class="text-xs text-surface-600 dark:text-surface-400">Completed</div>
|
||||||
|
<div class="text-lg font-semibold text-emerald-600 dark:text-emerald-400">
|
||||||
|
{completedExecs.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Capabilities -->
|
||||||
|
<div class="mt-3">
|
||||||
|
<div class="text-xs text-surface-600 dark:text-surface-400 mb-1">Capabilities:</div>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{#each agent.capabilities.slice(0, 3) as capability}
|
||||||
|
<span class="text-xs px-2 py-0.5 rounded bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300">
|
||||||
|
{capability.replace(/_/g, ' ')}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
{#if agent.capabilities.length > 3}
|
||||||
|
<span class="text-xs text-surface-500">+{agent.capabilities.length - 3}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Execution Log -->
|
||||||
|
<div class="bg-white dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700">
|
||||||
|
<div class="p-4 border-b border-surface-200 dark:border-surface-700">
|
||||||
|
<h3 class="font-semibold text-surface-900 dark:text-surface-100">
|
||||||
|
{selectedAgent ? `${selectedAgent.name} Execution Log` : "Recent Executions"}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divide-y divide-surface-200 dark:divide-surface-700 max-h-96 overflow-y-auto">
|
||||||
|
{#each (selectedAgent ? getAgentExecutions(selectedAgent.id) : executions) as execution}
|
||||||
|
<div class="p-4 hover:bg-surface-50 dark:hover:bg-surface-900/50">
|
||||||
|
<div class="flex items-start justify-between mb-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xl">{getAgentIcon(execution.agent_id)}</span>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-surface-900 dark:text-surface-100">
|
||||||
|
Task #{execution.task_id}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-surface-600 dark:text-surface-400">
|
||||||
|
{formatTimestamp(execution.started_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs px-2 py-1 rounded {getStatusColor(execution.status)}">
|
||||||
|
{execution.status.replace(/_/g, ' ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if execution.output_result?.reasoning}
|
||||||
|
<p class="text-sm text-surface-700 dark:text-surface-300 mb-2">
|
||||||
|
{execution.output_result.reasoning}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if execution.actions_proposed && execution.actions_proposed.length > 0}
|
||||||
|
<div class="text-xs text-surface-600 dark:text-surface-400">
|
||||||
|
Actions: {execution.actions_proposed.length}
|
||||||
|
{#each execution.actions_proposed.slice(0, 2) as action}
|
||||||
|
<span class="ml-2 px-1.5 py-0.5 rounded bg-surface-100 dark:bg-surface-700">
|
||||||
|
{action.type}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if execution.error_message}
|
||||||
|
<div class="mt-2 text-xs text-rose-600 dark:text-rose-400">
|
||||||
|
Error: {execution.error_message}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="p-8 text-center text-surface-500">
|
||||||
|
No executions yet
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { onMount, onDestroy } from "svelte";
|
import { onMount, onDestroy } from "svelte";
|
||||||
import KanbanBoard from "../components/opc/KanbanBoard.svelte";
|
import KanbanBoard from "../components/opc/KanbanBoard.svelte";
|
||||||
import TaskModal from "../components/opc/TaskModal.svelte";
|
import TaskModal from "../components/opc/TaskModal.svelte";
|
||||||
|
import AgentPanel from "../components/opc/AgentPanel.svelte";
|
||||||
import { getTasks, getAgents } from "../lib/opc-api.js";
|
import { getTasks, getAgents } from "../lib/opc-api.js";
|
||||||
import * as opcWs from "../lib/opc-ws.js";
|
import * as opcWs from "../lib/opc-ws.js";
|
||||||
|
|
||||||
@@ -10,6 +11,7 @@
|
|||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let showTaskModal = $state(false);
|
let showTaskModal = $state(false);
|
||||||
let editingTask = $state(null);
|
let editingTask = $state(null);
|
||||||
|
let showAgentPanel = $state(false);
|
||||||
let unsubscribe = null;
|
let unsubscribe = null;
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
@@ -112,6 +114,14 @@
|
|||||||
Manage your tasks with AI agents
|
Manage your tasks with AI agents
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
onclick={() => showAgentPanel = !showAgentPanel}
|
||||||
|
class="px-4 py-2 bg-surface-200 dark:bg-surface-700 text-surface-900 dark:text-surface-100 rounded-lg hover:bg-surface-300 dark:hover:bg-surface-600 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span>🤖</span>
|
||||||
|
<span>{showAgentPanel ? "Hide" : "Show"} Agents</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={handleCreateTask}
|
onclick={handleCreateTask}
|
||||||
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 flex items-center gap-2"
|
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 flex items-center gap-2"
|
||||||
@@ -120,6 +130,7 @@
|
|||||||
<span>New Task</span>
|
<span>New Task</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Stats -->
|
<!-- Stats -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
@@ -166,6 +177,13 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Agent Panel -->
|
||||||
|
{#if showAgentPanel}
|
||||||
|
<div class="mt-6">
|
||||||
|
<AgentPanel />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Task Modal -->
|
<!-- Task Modal -->
|
||||||
|
|||||||
Reference in New Issue
Block a user