feat: add appointments, campaigns, admin, storage, tests and various updates

- Add new routers: admin, appointments, campaigns
- Add storage service and logging config
- Add migrations directory and test suite with pytest config
- Add supabase_migration_features.sql
- Update models, dependencies, config, and existing routers
- Remove whatsapp_service (deleted)
- Update pyproject.toml and uv.lock dependencies

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
belviskhoremk
2026-04-03 09:11:58 +00:00
parent 9dccc83293
commit 92d4c2fc5e
51 changed files with 7076 additions and 515 deletions

View File

@@ -55,13 +55,6 @@ class Settings(BaseSettings):
# Supabase Storage
supabase_storage_url: str = ""
# WhatsApp Cloud API
whatsapp_access_token: str = ""
whatsapp_phone_number_id: str = ""
whatsapp_verify_token: str = "contexta_whatsapp_verify"
whatsapp_app_secret: str = ""
whatsapp_display_number: str = "" # E.164 without '+', e.g. "15551234567"
@property
def allowed_origins_list(self) -> List[str]:
return [o.strip() for o in self.allowed_origins.split(",")]
@@ -188,7 +181,7 @@ DEFAULT_MODELS = {
# ═══════════════════════════════════════════════════════════════════════════════
# PLAN LIMITS — Pricing: Starter $12/mo, Business $29/mo, Agency $79/mo
# PLAN LIMITS — Pricing: Starter $19/mo, Business $49/mo, Agency $99/mo
# ═══════════════════════════════════════════════════════════════════════════════
#
# Cost analysis (per 1M tokens approx):
@@ -207,9 +200,9 @@ DEFAULT_MODELS = {
# Fireworks models: ~$0.001-$0.004 per conversation
# GPT-4o: ~$0.015 per conversation
#
# Starter $12/mo, 1500 convos: max cost ~$6/mo (fireworks mix) → margin OK
# Business $29/mo, 5000 convos: max cost ~$15/mo (mixed models) → margin OK
# Agency $79/mo, 20000 convos: max cost ~$30/mo (fireworks) → healthy margin
# Starter $19/mo, 1500 convos: max cost ~$6/mo (fireworks mix) → margin OK
# Business $49/mo, 5000 convos: max cost ~$15/mo (mixed models) → margin OK
# Agency $99/mo, 20000 convos: max cost ~$30/mo (fireworks) → healthy margin
# ═══════════════════════════════════════════════════════════════════════════════
_ALL_FIREWORKS = [
@@ -236,45 +229,69 @@ PLAN_LIMITS = {
"conversations_limit": 100, # 100 real conversations/month
"code_export": False,
"analytics": False,
"gap_suggestions": False,
"channels": [], # no messaging channels
"url_sources": 0,
"leads_per_month": 0,
"inbox_replies": False, # read-only inbox
"leads_editing": False, # view-only leads
"show_branding": True, # cannot remove badge
"appointments": False,
"appointments_chatbots": 0,
"campaigns": False,
"campaigns_per_month": 0,
"max_campaign_recipients": 0,
},
# ── Starter $12/mo ───────────────────────────────────────────────────────
# For individuals and solo businesses going live.
# ── Starter $19/mo ───────────────────────────────────────────────────────
# For solo operators: live chat, leads, booking, and campaigns.
"starter": {
"max_chatbots": 999999,
"max_published": 1,
"max_published": 3,
"max_documents_per_chatbot": 10,
"max_document_size_mb": 10,
"models": _ALL_FIREWORKS,
"conversations_limit": 1500,
"code_export": False,
"analytics": True,
"gap_suggestions": False,
"channels": ["telegram"],
"url_sources": 5,
"leads_per_month": 500,
"show_branding": True, # badge stays
"inbox_replies": True,
"leads_editing": True,
"show_branding": True, # badge stays on Starter
"appointments": True,
"appointments_chatbots": 1, # booking on 1 chatbot
"campaigns": True,
"campaigns_per_month": 3,
"max_campaign_recipients": 500,
},
# ── Business $29/mo ──────────────────────────────────────────────────────
# For growing businesses that need more chatbots and WhatsApp reach.
# ── Business $49/mo ──────────────────────────────────────────────────────
# For growing businesses: premium AI, unlimited booking, full analytics.
"business": {
"max_chatbots": 999999,
"max_published": 3,
"max_published": 10,
"max_documents_per_chatbot": 50,
"max_document_size_mb": 50,
"models": _ALL_FIREWORKS + _ALL_PREMIUM,
"conversations_limit": 5000,
"code_export": False,
"analytics": True,
"channels": ["telegram", "whatsapp"],
"gap_suggestions": True,
"channels": ["telegram"],
"url_sources": 999999,
"leads_per_month": 999999,
"inbox_replies": True,
"leads_editing": True,
"show_branding": False, # can remove badge
"appointments": True,
"appointments_chatbots": 999999,
"campaigns": True,
"campaigns_per_month": 999999,
"max_campaign_recipients": 5000,
},
# ── Agency $79/mo ────────────────────────────────────────────────────────
# For agencies and large businesses managing many chatbots.
# ── Agency $99/mo ────────────────────────────────────────────────────────
# For agencies: unlimited everything, unlimited campaign recipients.
"agency": {
"max_chatbots": 999999,
"max_published": 999999,
@@ -284,10 +301,18 @@ PLAN_LIMITS = {
"conversations_limit": 20000,
"code_export": True,
"analytics": True,
"channels": ["telegram", "whatsapp"],
"gap_suggestions": True,
"channels": ["telegram"],
"url_sources": 999999,
"leads_per_month": 999999,
"inbox_replies": True,
"leads_editing": True,
"show_branding": False,
"appointments": True,
"appointments_chatbots": 999999,
"campaigns": True,
"campaigns_per_month": 999999,
"max_campaign_recipients": 999999,
},
# ── Enterprise (custom) ───────────────────────────────────────────────────
"enterprise": {
@@ -299,9 +324,17 @@ PLAN_LIMITS = {
"conversations_limit": 999999,
"code_export": True,
"analytics": True,
"channels": ["telegram", "whatsapp"],
"gap_suggestions": True,
"channels": ["telegram"],
"url_sources": 999999,
"leads_per_month": 999999,
"inbox_replies": True,
"leads_editing": True,
"show_branding": False,
"appointments": True,
"appointments_chatbots": 999999,
"campaigns": True,
"campaigns_per_month": 999999,
"max_campaign_recipients": 999999,
},
}

View File

@@ -29,7 +29,24 @@ async def get_current_user(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
)
return response.user
user = response.user
# Check for suspension
try:
profile = supabase.table("user_profiles").select("suspended_at").eq("user_id", user.id).execute()
if profile.data and profile.data[0].get("suspended_at"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account suspended. Please contact support.",
)
except HTTPException:
raise
except Exception:
pass # Don't block login if profile lookup fails
return user
except HTTPException:
raise
except Exception as e:
logger.error(f"Auth error: {e}")
raise HTTPException(
@@ -38,6 +55,29 @@ async def get_current_user(
)
async def get_admin_user(
current_user=Depends(get_current_user),
):
"""Require the current user to be an admin."""
supabase = get_supabase()
try:
profile = supabase.table("user_profiles").select("is_admin").eq("user_id", current_user.id).execute()
if not profile.data or not profile.data[0].get("is_admin"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required",
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Admin check failed: {e}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required",
)
return current_user
async def get_optional_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
):

34
app/logging_config.py Normal file
View File

@@ -0,0 +1,34 @@
import logging
import os
def configure_logging():
"""Configure structured JSON logging for the application."""
log_level = logging.DEBUG if os.getenv("APP_ENV", "development") == "development" else logging.INFO
try:
from pythonjsonlogger import jsonlogger
handler = logging.StreamHandler()
formatter = jsonlogger.JsonFormatter(
fmt="%(asctime)s %(levelname)s %(name)s %(message)s",
datefmt="%Y-%m-%dT%H:%M:%S",
rename_fields={"asctime": "timestamp", "levelname": "level", "name": "logger"},
)
handler.setFormatter(formatter)
except ImportError:
# Fallback to plain text if pythonjsonlogger not installed yet
handler = logging.StreamHandler()
handler.setFormatter(
logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
)
root_logger = logging.getLogger()
root_logger.handlers.clear()
root_logger.addHandler(handler)
root_logger.setLevel(log_level)
# Silence noisy third-party loggers
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)

View File

@@ -4,17 +4,18 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, Response
import logging
from app.logging_config import configure_logging
configure_logging() # Must be called before any logger is created
from app.config import settings
from app.routers import auth, chatbots, documents, chat, marketplace, billing, models, analytics, inbox, leads, upload
from app.routers.documents import router_url_sources
from app.routers.leads import leads_public_router
from app.routers.channels import router as channels_router, webhook_router as channels_webhook_router
from app.routers import admin as admin_router
from app.routers.appointments import router as appointments_router, public_router as appointments_public_router
from app.routers.campaigns import router as campaigns_router
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
@@ -62,13 +63,28 @@ app.include_router(router_url_sources, prefix="/api/v1")
app.include_router(leads_public_router, prefix="/api/v1")
app.include_router(channels_router, prefix="/api/v1")
app.include_router(channels_webhook_router, prefix="/api/v1")
app.include_router(appointments_router, prefix="/api/v1")
app.include_router(appointments_public_router, prefix="/api/v1")
app.include_router(campaigns_router, prefix="/api/v1")
app.include_router(admin_router.router, prefix="/api/v1")
# ── Widget ─────────────────────────────────────────────────────────────────────
@app.get("/widget.js")
@app.get("/widget.js", include_in_schema=False)
async def serve_widget():
from app.services.widget import generate_widget_js
return Response(generate_widget_js(settings.app_url), media_type="application/javascript")
return Response(
content=generate_widget_js(settings.app_url),
media_type="application/javascript",
headers={
# Allow any site to load this script tag cross-origin
"Access-Control-Allow-Origin": "*",
# Cache for 1 hour in browsers / CDN; revalidate when stale
"Cache-Control": "public, max-age=3600, stale-while-revalidate=86400",
# Prevent MIME sniffing
"X-Content-Type-Options": "nosniff",
},
)
# ── Health & Info ──────────────────────────────────────────────────────────────
@@ -87,6 +103,15 @@ async def health():
return {"status": "healthy", "environment": settings.app_env}
# ── Prometheus Metrics ──────────────────────────────────────────────────────────
try:
from prometheus_fastapi_instrumentator import Instrumentator
Instrumentator().instrument(app).expose(app, endpoint="/metrics", include_in_schema=False)
logger.info("Prometheus metrics enabled at /metrics")
except ImportError:
logger.info("prometheus-fastapi-instrumentator not installed, metrics endpoint disabled")
# ── Sentry ─────────────────────────────────────────────────────────────────────
if settings.sentry_dsn:
import sentry_sdk

View File

@@ -1,8 +1,9 @@
from pydantic import BaseModel, EmailStr, Field
from pydantic import BaseModel, EmailStr, Field, field_validator
from typing import Optional, List, Dict, Any
from datetime import datetime
from enum import Enum
import uuid
import re
# ─── Enums ────────────────────────────────────────────────────────────────────
@@ -59,6 +60,7 @@ class UserResponse(BaseModel):
email: str
company_name: Optional[str] = None
plan: str = "free"
is_admin: bool = False
created_at: Optional[datetime] = None
@@ -100,6 +102,39 @@ class ChatbotCreate(BaseModel):
description: Optional[str] = None
system_prompt: Optional[str] = None
model: str = "accounts/fireworks/models/kimi-k2-instruct-0905"
@field_validator("name", mode="before")
@classmethod
def sanitize_name(cls, v: Any) -> Any:
if v:
v = str(v).strip()
if len(v) > 100:
raise ValueError("Name must be 100 characters or less")
return v
@field_validator("system_prompt", mode="before")
@classmethod
def sanitize_system_prompt(cls, v: Any) -> Any:
if v:
v = re.sub(r"<script[^>]*>.*?</script>", "", str(v), flags=re.DOTALL | re.IGNORECASE)
v = re.sub(r"javascript:", "", v, flags=re.IGNORECASE)
if len(v) > 10000:
raise ValueError("System prompt must be 10000 characters or less")
return v
@field_validator("description", mode="before")
@classmethod
def sanitize_description(cls, v: Any) -> Any:
if v and len(str(v)) > 2000:
raise ValueError("Description must be 2000 characters or less")
return v
@field_validator("welcome_message", mode="before")
@classmethod
def sanitize_welcome_message(cls, v: Any) -> Any:
if v and len(str(v)) > 500:
raise ValueError("Welcome message must be 500 characters or less")
return v
temperature: float = Field(default=0.7, ge=0.0, le=2.0)
max_tokens: int = Field(default=1000, ge=100, le=8000)
primary_color: str = "#6366f1"
@@ -116,12 +151,46 @@ class ChatbotCreate(BaseModel):
handoff_message: str = "I'll connect you with our team. Please wait."
handoff_email: Optional[str] = None
handoff_keywords: List[str] = ["human", "agent", "speak to someone", "talk to a person", "real person"]
booking_enabled: bool = False
class ChatbotUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
system_prompt: Optional[str] = None
@field_validator("name", mode="before")
@classmethod
def sanitize_name(cls, v: Any) -> Any:
if v:
v = str(v).strip()
if len(v) > 100:
raise ValueError("Name must be 100 characters or less")
return v
@field_validator("system_prompt", mode="before")
@classmethod
def sanitize_system_prompt(cls, v: Any) -> Any:
if v:
v = re.sub(r"<script[^>]*>.*?</script>", "", str(v), flags=re.DOTALL | re.IGNORECASE)
v = re.sub(r"javascript:", "", v, flags=re.IGNORECASE)
if len(v) > 10000:
raise ValueError("System prompt must be 10000 characters or less")
return v
@field_validator("description", mode="before")
@classmethod
def sanitize_description(cls, v: Any) -> Any:
if v and len(str(v)) > 2000:
raise ValueError("Description must be 2000 characters or less")
return v
@field_validator("welcome_message", mode="before")
@classmethod
def sanitize_welcome_message(cls, v: Any) -> Any:
if v and len(str(v)) > 500:
raise ValueError("Welcome message must be 500 characters or less")
return v
model: Optional[str] = None
temperature: Optional[float] = None
max_tokens: Optional[int] = None
@@ -139,6 +208,7 @@ class ChatbotUpdate(BaseModel):
handoff_message: Optional[str] = None
handoff_email: Optional[str] = None
handoff_keywords: Optional[List[str]] = None
booking_enabled: Optional[bool] = None
class ChatbotResponse(BaseModel):
@@ -172,6 +242,7 @@ class ChatbotResponse(BaseModel):
handoff_message: str = "I'll connect you with our team. Please wait."
handoff_email: Optional[str] = None
handoff_keywords: List[str] = ["human", "agent", "speak to someone", "talk to a person", "real person"]
booking_enabled: bool = False
class ChatbotPublicResponse(BaseModel):
@@ -355,9 +426,16 @@ class LeadResponse(BaseModel):
name: Optional[str] = None
phone: Optional[str] = None
company: Optional[str] = None
status: str = "new"
notes: Optional[str] = None
created_at: Optional[datetime] = None
class LeadUpdate(BaseModel):
status: Optional[str] = None # new, contacted, qualified, closed, lost
notes: Optional[str] = None
# ─── URL Source Models ─────────────────────────────────────────────────────────
class UrlSourceCreate(BaseModel):
@@ -392,6 +470,8 @@ class InboxConversation(BaseModel):
language: str
message_count: int
first_message: Optional[str] = None
status: str = "open"
last_agent_reply_at: Optional[datetime] = None
created_at: Optional[datetime] = None
@@ -405,13 +485,148 @@ class InboxMessage(BaseModel):
created_at: Optional[datetime] = None
class ConversationStatusUpdate(BaseModel):
status: str # open, agent_handling, resolved
class AgentReplyCreate(BaseModel):
message: str = Field(min_length=1, max_length=4000)
# ─── Channel Models ────────────────────────────────────────────────────────────
class ChannelConnectionResponse(BaseModel):
id: str
channel: str
bot_username: Optional[str] = None
wa_keyword: Optional[str] = None
wa_link: Optional[str] = None
is_active: bool
created_at: Optional[datetime] = None
# ─── Admin Models ──────────────────────────────────────────────────────────────
class AdminUserListItem(BaseModel):
id: str
email: str
company_name: Optional[str] = None
plan: str = "free"
subscription_status: str = "active"
chatbot_count: int = 0
conversations_count: int = 0
is_suspended: bool = False
is_admin: bool = False
created_at: Optional[datetime] = None
class AdminUserDetail(AdminUserListItem):
website: Optional[str] = None
industry: Optional[str] = None
chatbots: List[Dict[str, Any]] = []
class AdminChangePlanRequest(BaseModel):
plan: str
reason: Optional[str] = None
class AdminSuspendRequest(BaseModel):
suspend: bool
reason: Optional[str] = None
class AdminStatsResponse(BaseModel):
total_users: int
total_chatbots: int
total_published_chatbots: int
total_conversations: int
total_messages: int
active_subscriptions: Dict[str, int]
class AdminChatbotListItem(BaseModel):
id: str
name: str
owner_email: Optional[str] = None
company_name: Optional[str] = None
is_published: bool = False
document_count: int = 0
conversation_count: int = 0
created_at: Optional[datetime] = None
class AdminSystemHealth(BaseModel):
db: str
qdrant: str
llm_providers: Dict[str, bool]
timestamp: datetime
class AdminConversationListItem(BaseModel):
id: str
chatbot_name: Optional[str] = None
session_id: Optional[str] = None
language: Optional[str] = None
message_count: int = 0
created_at: Optional[datetime] = None
first_message: Optional[str] = None
# ─── Appointment Models ────────────────────────────────────────────────────────
class BusinessHoursEntry(BaseModel):
day_of_week: int = Field(ge=0, le=6) # 0=Mon, 6=Sun
is_open: bool = True
open_time: str = "09:00" # HH:MM
close_time: str = "17:00"
slot_duration_minutes: int = Field(default=60, ge=15, le=480)
class BusinessHoursSave(BaseModel):
hours: List[BusinessHoursEntry]
class AppointmentCreate(BaseModel):
customer_name: str = Field(min_length=1, max_length=200)
customer_contact: str = Field(min_length=1, max_length=200)
service: Optional[str] = None
slot_start: datetime
notes: Optional[str] = None
conversation_id: Optional[str] = None
class AppointmentResponse(BaseModel):
id: str
chatbot_id: str
conversation_id: Optional[str] = None
customer_name: str
customer_contact: str
service: Optional[str] = None
slot_start: datetime
slot_end: datetime
status: str
notes: Optional[str] = None
created_at: Optional[datetime] = None
class AppointmentStatusUpdate(BaseModel):
status: str # pending, confirmed, cancelled, completed
# ─── Campaign Models ───────────────────────────────────────────────────────────
class CampaignCreate(BaseModel):
chatbot_id: str
title: str = Field(min_length=1, max_length=200)
message: str = Field(min_length=1, max_length=4000)
class CampaignResponse(BaseModel):
id: str
chatbot_id: str
title: str
message: str
status: str
recipients_count: int
sent_count: int
created_at: Optional[datetime] = None
sent_at: Optional[datetime] = None

555
app/routers/admin.py Normal file
View File

@@ -0,0 +1,555 @@
"""
Admin router — all endpoints require is_admin = TRUE in user_profiles.
Bootstrap: after running migration 001, set your admin user in Supabase:
UPDATE user_profiles SET is_admin = TRUE WHERE user_id = '<your-uuid>';
"""
import logging
import time
from collections import defaultdict
from datetime import datetime
from typing import Optional, List, Dict
from fastapi import APIRouter, Depends, HTTPException, Query
from app.dependencies import get_admin_user
from app.database import get_supabase
from app.models import (
AdminStatsResponse, AdminUserListItem, AdminUserDetail,
AdminChangePlanRequest, AdminSuspendRequest, AdminChatbotListItem,
AdminSystemHealth, AdminConversationListItem, SuccessResponse,
)
from app.services.vector_store import vector_store
from app.services.storage import delete_from_storage
from app.config import settings
router = APIRouter(prefix="/admin", tags=["Admin"])
logger = logging.getLogger(__name__)
_app_start_time = time.time()
# ── Stats ──────────────────────────────────────────────────────────────────────
@router.get("/stats", response_model=AdminStatsResponse)
async def get_stats(admin=Depends(get_admin_user)):
"""Platform-wide statistics."""
supabase = get_supabase()
# Total users
try:
users_resp = supabase.table("user_profiles").select("user_id", count="exact").execute()
total_users = users_resp.count or 0
except Exception:
total_users = 0
# Total chatbots
try:
cb_resp = supabase.table("chatbots").select("id", count="exact").execute()
total_chatbots = cb_resp.count or 0
pub_resp = supabase.table("chatbots").select("id", count="exact").eq("is_published", True).execute()
total_published = pub_resp.count or 0
except Exception:
total_chatbots = 0
total_published = 0
# Total conversations
try:
conv_resp = supabase.table("conversations").select("id", count="exact").execute()
total_convos = conv_resp.count or 0
except Exception:
total_convos = 0
# Total messages
try:
msg_resp = supabase.table("messages").select("id", count="exact").execute()
total_messages = msg_resp.count or 0
except Exception:
total_messages = 0
# Active subscriptions by plan
active_subs: Dict[str, int] = defaultdict(int)
try:
subs_resp = supabase.table("subscriptions").select("plan, status").eq("status", "active").execute()
for s in (subs_resp.data or []):
active_subs[s["plan"]] += 1
except Exception:
pass
return AdminStatsResponse(
total_users=total_users,
total_chatbots=total_chatbots,
total_published_chatbots=total_published,
total_conversations=total_convos,
total_messages=total_messages,
active_subscriptions=dict(active_subs),
)
# ── Users ──────────────────────────────────────────────────────────────────────
@router.get("/users")
async def list_users(
admin=Depends(get_admin_user),
page: int = Query(1, ge=1),
limit: int = Query(20, ge=1, le=100),
search: Optional[str] = None,
plan: Optional[str] = None,
):
"""Paginated list of all users with company and subscription info."""
supabase = get_supabase()
offset = (page - 1) * limit
try:
# Fetch companies (contains owner_id and name)
companies_resp = supabase.table("companies").select("id, owner_id, name, website, industry").execute()
companies = {c["owner_id"]: c for c in (companies_resp.data or [])}
# Fetch subscriptions
subs_resp = supabase.table("subscriptions").select("user_id, plan, status").execute()
subs = {s["user_id"]: s for s in (subs_resp.data or [])}
# Fetch user profiles (suspension, admin flag)
profiles_resp = supabase.table("user_profiles").select("user_id, is_admin, suspended_at").execute()
profiles = {p["user_id"]: p for p in (profiles_resp.data or [])}
# Fetch auth users via admin API
try:
auth_users_resp = supabase.auth.admin.list_users()
auth_users = auth_users_resp if isinstance(auth_users_resp, list) else getattr(auth_users_resp, 'users', [])
except Exception as e:
logger.warning(f"Could not list auth users: {e}")
auth_users = []
# Fetch chatbot counts per company
cb_resp = supabase.table("chatbots").select("company_id").execute()
cb_by_company: Dict[str, int] = defaultdict(int)
for cb in (cb_resp.data or []):
cb_by_company[cb["company_id"]] += 1
# Fetch conversation counts per chatbot (to get per-user conv count)
chatbots_resp = supabase.table("chatbots").select("id, company_id").execute()
chatbot_company_map = {cb["id"]: cb["company_id"] for cb in (chatbots_resp.data or [])}
conv_resp = supabase.table("conversations").select("chatbot_id", count="exact").execute()
conv_by_chatbot: Dict[str, int] = defaultdict(int)
for conv in (conv_resp.data or []):
conv_by_chatbot[conv["chatbot_id"]] += 1
conv_by_company: Dict[str, int] = defaultdict(int)
for cb_id, count in conv_by_chatbot.items():
company_id = chatbot_company_map.get(cb_id)
if company_id:
conv_by_company[company_id] += count
# Build user list
users_list = []
for auth_user in auth_users:
uid = getattr(auth_user, "id", None) or auth_user.get("id", "")
email = getattr(auth_user, "email", None) or auth_user.get("email", "")
created_at = getattr(auth_user, "created_at", None) or auth_user.get("created_at")
# Apply filters
if search and search.lower() not in email.lower():
company_info = companies.get(uid, {})
if search.lower() not in (company_info.get("name") or "").lower():
continue
sub_info = subs.get(uid, {})
user_plan = sub_info.get("plan", "free")
if plan and user_plan != plan:
continue
company_info = companies.get(uid, {})
profile_info = profiles.get(uid, {})
users_list.append(AdminUserListItem(
id=uid,
email=email,
company_name=company_info.get("name"),
plan=user_plan,
subscription_status=sub_info.get("status", "active"),
chatbot_count=cb_by_company.get(company_info.get("id", ""), 0),
conversations_count=conv_by_company.get(company_info.get("id", ""), 0),
is_suspended=bool(profile_info.get("suspended_at")),
is_admin=bool(profile_info.get("is_admin", False)),
created_at=created_at,
))
total = len(users_list)
paginated = users_list[offset:offset + limit]
return {
"users": [u.model_dump() for u in paginated],
"total": total,
"page": page,
"pages": max(1, (total + limit - 1) // limit),
}
except Exception as e:
logger.error(f"Admin list_users error: {e}")
raise HTTPException(status_code=500, detail="Failed to fetch users")
@router.get("/users/{user_id}", response_model=AdminUserDetail)
async def get_user(user_id: str, admin=Depends(get_admin_user)):
"""Detailed info about a specific user."""
supabase = get_supabase()
try:
auth_user = supabase.auth.admin.get_user_by_id(user_id)
auth_u = getattr(auth_user, "user", auth_user)
email = getattr(auth_u, "email", "") or auth_u.get("email", "")
created_at = getattr(auth_u, "created_at", None) or auth_u.get("created_at")
except Exception:
email = ""
created_at = None
company = supabase.table("companies").select("*").eq("owner_id", user_id).execute()
company_info = company.data[0] if company.data else {}
sub = supabase.table("subscriptions").select("plan, status").eq("user_id", user_id).execute()
sub_info = sub.data[0] if sub.data else {}
profile = supabase.table("user_profiles").select("is_admin, suspended_at").eq("user_id", user_id).execute()
profile_info = profile.data[0] if profile.data else {}
chatbots = []
if company_info.get("id"):
cb_resp = supabase.table("chatbots").select("id, name, is_published, created_at") \
.eq("company_id", company_info["id"]).execute()
chatbots = cb_resp.data or []
return AdminUserDetail(
id=user_id,
email=email,
company_name=company_info.get("name"),
website=company_info.get("website"),
industry=company_info.get("industry"),
plan=sub_info.get("plan", "free"),
subscription_status=sub_info.get("status", "active"),
chatbot_count=len(chatbots),
conversations_count=0,
is_suspended=bool(profile_info.get("suspended_at")),
is_admin=bool(profile_info.get("is_admin", False)),
created_at=created_at,
chatbots=chatbots,
)
@router.patch("/users/{user_id}/plan")
async def change_user_plan(user_id: str, data: AdminChangePlanRequest, admin=Depends(get_admin_user)):
"""Manually grant or change a user's subscription plan."""
valid_plans = ["free", "starter", "business", "agency", "enterprise"]
if data.plan not in valid_plans:
raise HTTPException(status_code=400, detail=f"Invalid plan. Must be one of: {valid_plans}")
supabase = get_supabase()
try:
supabase.table("subscriptions").upsert({
"user_id": user_id,
"plan": data.plan,
"status": "active",
}, on_conflict="user_id").execute()
except Exception as e:
logger.error(f"Failed to change plan for {user_id}: {e}")
raise HTTPException(status_code=500, detail="Failed to update plan")
logger.info(f"Admin {admin.id} changed plan for user {user_id} to {data.plan}. Reason: {data.reason}")
return {"message": f"Plan updated to {data.plan}", "user_id": user_id, "plan": data.plan}
@router.post("/users/{user_id}/suspend")
async def suspend_user(user_id: str, data: AdminSuspendRequest, admin=Depends(get_admin_user)):
"""Suspend or unsuspend a user account."""
supabase = get_supabase()
update_data: dict = {"updated_at": datetime.utcnow().isoformat()}
if data.suspend:
update_data["suspended_at"] = datetime.utcnow().isoformat()
if data.reason:
update_data["suspended_reason"] = data.reason
action = "suspended"
else:
update_data["suspended_at"] = None
update_data["suspended_reason"] = None
action = "unsuspended"
try:
supabase.table("user_profiles").upsert(
{"user_id": user_id, **update_data},
on_conflict="user_id"
).execute()
except Exception as e:
logger.error(f"Failed to {action} user {user_id}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to {action} user")
logger.info(f"Admin {admin.id} {action} user {user_id}")
return {"message": f"User {action}", "user_id": user_id}
@router.delete("/users/{user_id}", response_model=SuccessResponse)
async def delete_user(user_id: str, admin=Depends(get_admin_user)):
"""Permanently delete a user and all their data."""
supabase = get_supabase()
# 1. Get company
company = supabase.table("companies").select("id").eq("owner_id", user_id).execute()
company_id = company.data[0]["id"] if company.data else None
if company_id:
# 2. Get all chatbots and clean up Qdrant + storage
chatbots = supabase.table("chatbots").select("id, qdrant_collection_name, logo_url") \
.eq("company_id", company_id).execute()
for cb in (chatbots.data or []):
if cb.get("qdrant_collection_name"):
try:
vector_store.delete_collection(cb["qdrant_collection_name"])
except Exception as e:
logger.warning(f"Failed to delete Qdrant collection for chatbot {cb['id']}: {e}")
if cb.get("logo_url"):
delete_from_storage(supabase, "logos", cb["logo_url"])
# 3. Delete documents from storage
docs = supabase.table("documents").select("file_url") \
.in_("chatbot_id", [cb["id"] for cb in (chatbots.data or [])]).execute()
for doc in (docs.data or []):
if doc.get("file_url"):
delete_from_storage(supabase, "documents", doc["file_url"])
# 4. Delete company (cascades to chatbots, documents, conversations)
supabase.table("companies").delete().eq("id", company_id).execute()
# 5. Delete subscription
supabase.table("subscriptions").delete().eq("user_id", user_id).execute()
# 6. Delete user profile
supabase.table("user_profiles").delete().eq("user_id", user_id).execute()
# 7. Delete auth user
try:
supabase.auth.admin.delete_user(user_id)
except Exception as e:
logger.error(f"Failed to delete auth user {user_id}: {e}")
raise HTTPException(status_code=500, detail="Failed to delete auth user")
logger.info(f"Admin {admin.id} deleted user {user_id}")
return SuccessResponse(success=True, message="User deleted successfully")
# ── Chatbots ───────────────────────────────────────────────────────────────────
@router.get("/chatbots")
async def list_all_chatbots(
admin=Depends(get_admin_user),
page: int = Query(1, ge=1),
limit: int = Query(20, ge=1, le=100),
search: Optional[str] = None,
):
"""Paginated list of ALL chatbots across all users."""
supabase = get_supabase()
offset = (page - 1) * limit
try:
# Get chatbots with company info
q = supabase.table("chatbots").select("*, companies(name, owner_id)") \
.order("created_at", desc=True)
result = q.execute()
all_chatbots = result.data or []
# Apply search filter
if search:
s = search.lower()
all_chatbots = [
cb for cb in all_chatbots
if s in (cb.get("name") or "").lower()
or s in (cb.get("companies", {}) or {}).get("name", "").lower()
]
total = len(all_chatbots)
paginated = all_chatbots[offset:offset + limit]
# Get owner emails for paginated set
owner_ids = list({(cb.get("companies") or {}).get("owner_id") for cb in paginated if cb.get("companies")})
owner_emails: Dict[str, str] = {}
if owner_ids:
try:
for oid in owner_ids:
try:
u = supabase.auth.admin.get_user_by_id(oid)
u_obj = getattr(u, "user", u)
owner_emails[oid] = getattr(u_obj, "email", "") or u_obj.get("email", "")
except Exception:
pass
except Exception:
pass
# Get doc and conv counts for paginated chatbots
cb_ids = [cb["id"] for cb in paginated]
doc_counts: Dict[str, int] = defaultdict(int)
conv_counts: Dict[str, int] = defaultdict(int)
if cb_ids:
docs_resp = supabase.table("documents").select("chatbot_id").in_("chatbot_id", cb_ids).execute()
for d in (docs_resp.data or []):
doc_counts[d["chatbot_id"]] += 1
convs_resp = supabase.table("conversations").select("chatbot_id").in_("chatbot_id", cb_ids).execute()
for c in (convs_resp.data or []):
conv_counts[c["chatbot_id"]] += 1
items = []
for cb in paginated:
company_info = cb.get("companies") or {}
owner_id = company_info.get("owner_id")
items.append(AdminChatbotListItem(
id=cb["id"],
name=cb.get("name", ""),
owner_email=owner_emails.get(owner_id),
company_name=company_info.get("name"),
is_published=cb.get("is_published", False),
document_count=doc_counts[cb["id"]],
conversation_count=conv_counts[cb["id"]],
created_at=cb.get("created_at"),
))
return {
"chatbots": [i.model_dump() for i in items],
"total": total,
"page": page,
"pages": max(1, (total + limit - 1) // limit),
}
except Exception as e:
logger.error(f"Admin list_chatbots error: {e}")
raise HTTPException(status_code=500, detail="Failed to fetch chatbots")
@router.delete("/chatbots/{chatbot_id}", response_model=SuccessResponse)
async def delete_chatbot_admin(chatbot_id: str, admin=Depends(get_admin_user)):
"""Force-delete any chatbot regardless of ownership."""
supabase = get_supabase()
cb = supabase.table("chatbots").select("*").eq("id", chatbot_id).execute()
if not cb.data:
raise HTTPException(status_code=404, detail="Chatbot not found")
chatbot = cb.data[0]
# Delete Qdrant collection
if chatbot.get("qdrant_collection_name"):
try:
vector_store.delete_collection(chatbot["qdrant_collection_name"])
except Exception as e:
logger.warning(f"Failed to delete Qdrant collection: {e}")
# Delete logo from storage
if chatbot.get("logo_url"):
delete_from_storage(supabase, "logos", chatbot["logo_url"])
supabase.table("chatbots").delete().eq("id", chatbot_id).execute()
logger.info(f"Admin {admin.id} deleted chatbot {chatbot_id}")
return SuccessResponse(success=True, message="Chatbot deleted")
# ── Conversations ──────────────────────────────────────────────────────────────
@router.get("/conversations")
async def list_conversations(
admin=Depends(get_admin_user),
page: int = Query(1, ge=1),
limit: int = Query(50, ge=1, le=200),
):
"""Recent conversations across all chatbots."""
supabase = get_supabase()
offset = (page - 1) * limit
try:
result = supabase.table("conversations") \
.select("*, chatbots(name)") \
.order("created_at", desc=True) \
.range(offset, offset + limit - 1) \
.execute()
convos = result.data or []
# Get first message for each conversation
conv_ids = [c["id"] for c in convos]
first_msgs: Dict[str, str] = {}
if conv_ids:
msgs_resp = supabase.table("messages") \
.select("conversation_id, content, role") \
.in_("conversation_id", conv_ids) \
.eq("role", "user") \
.order("created_at", desc=False) \
.execute()
seen = set()
for m in (msgs_resp.data or []):
cid = m["conversation_id"]
if cid not in seen:
seen.add(cid)
first_msgs[cid] = (m.get("content") or "")[:120]
# Total count
count_resp = supabase.table("conversations").select("id", count="exact").execute()
total = count_resp.count or 0
items = [
AdminConversationListItem(
id=c["id"],
chatbot_name=(c.get("chatbots") or {}).get("name"),
session_id=c.get("session_id"),
language=c.get("language"),
message_count=c.get("message_count", 0),
created_at=c.get("created_at"),
first_message=first_msgs.get(c["id"]),
)
for c in convos
]
return {
"conversations": [i.model_dump() for i in items],
"total": total,
"page": page,
"pages": max(1, (total + limit - 1) // limit),
}
except Exception as e:
logger.error(f"Admin list_conversations error: {e}")
raise HTTPException(status_code=500, detail="Failed to fetch conversations")
# ── System Health ──────────────────────────────────────────────────────────────
@router.get("/system/health", response_model=AdminSystemHealth)
async def system_health(admin=Depends(get_admin_user)):
"""Check health of all system components."""
# Check database
db_status = "unhealthy"
try:
supabase = get_supabase()
supabase.table("subscriptions").select("id").limit(1).execute()
db_status = "healthy"
except Exception as e:
logger.warning(f"DB health check failed: {e}")
# Check Qdrant
qdrant_status = "unhealthy"
try:
vector_store.client.get_collections()
qdrant_status = "healthy"
except Exception as e:
logger.warning(f"Qdrant health check failed: {e}")
# Check LLM provider API key availability
llm_providers = {
"openai": bool(getattr(settings, "openai_api_key", None)),
"anthropic": bool(getattr(settings, "anthropic_api_key", None)),
"google": bool(getattr(settings, "google_api_key", None)),
"fireworks": bool(getattr(settings, "fireworks_api_key", None)),
}
return AdminSystemHealth(
db=db_status,
qdrant=qdrant_status,
llm_providers=llm_providers,
timestamp=datetime.utcnow(),
)

View File

@@ -9,6 +9,7 @@ from app.database import get_supabase
from app.dependencies import get_current_user
from app.config import PLAN_LIMITS
from typing import List, Optional, Dict
from collections import defaultdict
from pydantic import BaseModel
from datetime import datetime, timedelta
import logging
@@ -127,14 +128,7 @@ async def get_analytics_overview(user=Depends(get_current_user)):
conversations_used=0,
)
# Gather per-chatbot analytics
chatbot_analytics = []
total_convos = 0
total_msgs = 0
total_sessions = 0
month_convos = 0
all_ratings = []
# ── Batch queries (fixes N+1) ────────────────────────────────────────────────
now = datetime.utcnow()
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
week_start = now - timedelta(days=now.weekday())
@@ -142,14 +136,60 @@ async def get_analytics_overview(user=Depends(get_current_user)):
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
thirty_days_ago = now - timedelta(days=30)
# Batch query 1: ALL conversations for all chatbots (single query)
all_convos_resp = supabase.table("conversations") \
.select("id, chatbot_id, session_id, language, created_at") \
.in_("chatbot_id", chatbot_ids) \
.execute()
all_convos = all_convos_resp.data or []
all_conv_ids = [c["id"] for c in all_convos]
# Batch query 2: ALL messages for all conversations (single query)
all_msgs: List[Dict] = []
if all_conv_ids:
# Split into chunks of 500 to avoid URL length limits
for i in range(0, len(all_conv_ids), 500):
chunk = all_conv_ids[i:i + 500]
msgs_resp = supabase.table("messages") \
.select("id, conversation_id, role, content, created_at") \
.in_("conversation_id", chunk) \
.execute()
all_msgs.extend(msgs_resp.data or [])
# Batch query 3: ALL feedback for all chatbots (single query)
all_feedback: List[Dict] = []
if chatbot_ids:
fb_resp = supabase.table("message_feedback") \
.select("chatbot_id, feedback") \
.in_("chatbot_id", chatbot_ids) \
.execute()
all_feedback = fb_resp.data or []
# Index data by chatbot_id for O(1) lookups
convos_by_chatbot: Dict[str, List[Dict]] = defaultdict(list)
for c in all_convos:
convos_by_chatbot[c["chatbot_id"]].append(c)
msgs_by_conv: Dict[str, List[Dict]] = defaultdict(list)
for m in all_msgs:
msgs_by_conv[m["conversation_id"]].append(m)
fb_by_chatbot: Dict[str, List[Dict]] = defaultdict(list)
for f in all_feedback:
fb_by_chatbot[f["chatbot_id"]].append(f)
# ── Aggregate per chatbot ────────────────────────────────────────────────────
chatbot_analytics = []
total_convos = 0
total_msgs = 0
total_sessions = 0
month_convos = 0
all_ratings = []
for chatbot in chatbot_list:
cid = chatbot["id"]
# Total conversations
convos = supabase.table("conversations").select("id, session_id, language, created_at", count="exact") \
.eq("chatbot_id", cid).execute()
conv_count = convos.count or 0
conv_data = convos.data or []
conv_data = convos_by_chatbot[cid]
conv_count = len(conv_data)
total_convos += conv_count
# Unique sessions
@@ -157,34 +197,35 @@ async def get_analytics_overview(user=Depends(get_current_user)):
unique_sess = len(sessions)
total_sessions += unique_sess
# Total messages
msgs = supabase.table("messages").select("id", count="exact") \
.in_("conversation_id", [c["id"] for c in conv_data] if conv_data else [""]).execute()
msg_count = msgs.count or 0
# Messages for this chatbot
chatbot_msgs = []
for c in conv_data:
chatbot_msgs.extend(msgs_by_conv[c["id"]])
msg_count = len(chatbot_msgs)
total_msgs += msg_count
# Time-based conversation counts
today_count = sum(1 for c in conv_data if c.get("created_at") and c["created_at"][:10] == today_start.strftime("%Y-%m-%d"))
today_str = today_start.strftime("%Y-%m-%d")
today_count = sum(1 for c in conv_data if c.get("created_at") and c["created_at"][:10] == today_str)
week_count = sum(1 for c in conv_data if c.get("created_at") and c["created_at"] >= week_start.isoformat())
month_count = sum(1 for c in conv_data if c.get("created_at") and c["created_at"] >= month_start.isoformat())
month_convos += month_count
# Daily conversations (last 30 days)
daily = {}
daily: Dict[str, int] = {}
for c in conv_data:
if c.get("created_at") and c["created_at"] >= thirty_days_ago.isoformat():
day = c["created_at"][:10]
daily[day] = daily.get(day, 0) + 1
daily_list = [DailyConversations(date=d, count=n) for d, n in sorted(daily.items())]
# Languages used
# Languages
lang_counts: Dict[str, int] = {}
for c in conv_data:
lang = c.get("language", "en")
lang_counts[lang] = lang_counts.get(lang, 0) + 1
# Peak hour (approximate from created_at)
# Peak hour
hour_counts: Dict[int, int] = {}
for c in conv_data:
if c.get("created_at") and len(c["created_at"]) > 13:
@@ -195,35 +236,25 @@ async def get_analytics_overview(user=Depends(get_current_user)):
pass
peak = max(hour_counts, key=hour_counts.get) if hour_counts else None
# Top queries (from user messages, get first message per conversation)
top_queries: List[TopQuery] = []
if conv_data:
conv_ids = [c["id"] for c in conv_data[:100]] # limit to recent 100
user_msgs = supabase.table("messages").select("content") \
.in_("conversation_id", conv_ids) \
.eq("role", "user") \
.limit(200).execute()
# Top queries from user messages
query_counts: Dict[str, int] = {}
for m in (user_msgs.data or []):
for m in chatbot_msgs:
if m.get("role") == "user":
content = (m.get("content") or "")[:100].strip()
if content:
query_counts[content] = query_counts.get(content, 0) + 1
top_sorted = sorted(query_counts.items(), key=lambda x: -x[1])[:5]
top_queries = [TopQuery(query=q, count=n) for q, n in top_sorted]
top_queries = [TopQuery(query=q, count=n) for q, n in sorted(query_counts.items(), key=lambda x: -x[1])[:5]]
# Rating
rating = chatbot.get("average_rating")
if rating:
all_ratings.append(rating)
# Feedback counts
fb_result = supabase.table("message_feedback").select("feedback", count="exact") \
.eq("chatbot_id", cid).execute()
total_fb = fb_result.count or 0
fb_pos = sum(1 for f in (fb_result.data or []) if f.get("feedback") == "positive")
fb_neg = total_fb - fb_pos
# Feedback
chatbot_fb = fb_by_chatbot[cid]
fb_pos = sum(1 for f in chatbot_fb if f.get("feedback") == "positive")
fb_neg = len(chatbot_fb) - fb_pos
# Average messages per conversation
avg_msgs = round(msg_count / conv_count, 1) if conv_count > 0 else 0.0
chatbot_analytics.append(ChatbotAnalyticsResponse(
@@ -234,7 +265,7 @@ async def get_analytics_overview(user=Depends(get_current_user)):
total_messages=msg_count,
average_messages_per_conversation=avg_msgs,
average_rating=rating,
total_ratings=total_fb,
total_ratings=len(chatbot_fb),
conversations_today=today_count,
conversations_this_week=week_count,
conversations_this_month=month_count,

287
app/routers/appointments.py Normal file
View File

@@ -0,0 +1,287 @@
from fastapi import APIRouter, HTTPException, Depends, Query
from app.database import get_supabase
from app.dependencies import get_current_user
from app.config import PLAN_LIMITS
from app.models import (
AppointmentCreate, AppointmentResponse, AppointmentStatusUpdate,
BusinessHoursEntry, BusinessHoursSave,
)
from typing import List, Optional
from datetime import datetime, timedelta, date
import uuid
import logging
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/appointments", tags=["Appointments"])
public_router = APIRouter(tags=["Appointments"])
# ── Helpers ───────────────────────────────────────────────────────────────────
def _check_booking_access(user_id: str, supabase):
sub = supabase.table("subscriptions").select("plan").eq("user_id", user_id).eq("status", "active").execute()
plan = sub.data[0]["plan"] if sub.data else "free"
if plan not in ("starter", "business", "agency", "enterprise"):
raise HTTPException(status_code=402, detail="Appointment booking requires Starter plan or higher")
return plan
def _get_user_company_id(user_id: str, supabase) -> str:
result = supabase.table("companies").select("id").eq("owner_id", user_id).execute()
if not result.data:
raise HTTPException(status_code=404, detail="Company not found")
return result.data[0]["id"]
def _verify_chatbot_ownership(chatbot_id: str, company_id: str, supabase):
chatbot = supabase.table("chatbots").select("id").eq("id", chatbot_id).eq("company_id", company_id).execute()
if not chatbot.data:
raise HTTPException(status_code=404, detail="Chatbot not found")
def _parse_time(t: str) -> tuple[int, int]:
"""Parse HH:MM into (hour, minute)."""
h, m = t.split(":")
return int(h), int(m)
def _get_available_slots(chatbot_id: str, target_date: date, supabase) -> List[dict]:
"""Return list of available {slot_start, slot_end} dicts for the given date."""
day_of_week = target_date.weekday() # 0=Mon
hours = supabase.table("business_hours") \
.select("*").eq("chatbot_id", chatbot_id).eq("day_of_week", day_of_week).execute()
if not hours.data or not hours.data[0].get("is_open"):
return []
h = hours.data[0]
open_h, open_m = _parse_time(h["open_time"])
close_h, close_m = _parse_time(h["close_time"])
duration = h.get("slot_duration_minutes", 60)
slot_start = datetime(target_date.year, target_date.month, target_date.day, open_h, open_m)
slot_end_limit = datetime(target_date.year, target_date.month, target_date.day, close_h, close_m)
# Fetch already-booked slots for that day
day_start = datetime(target_date.year, target_date.month, target_date.day, 0, 0)
day_end = day_start + timedelta(days=1)
booked = supabase.table("appointments") \
.select("slot_start, slot_end") \
.eq("chatbot_id", chatbot_id) \
.neq("status", "cancelled") \
.gte("slot_start", day_start.isoformat()) \
.lt("slot_start", day_end.isoformat()) \
.execute()
booked_starts = {b["slot_start"] for b in (booked.data or [])}
slots = []
now = datetime.utcnow()
while slot_start + timedelta(minutes=duration) <= slot_end_limit:
slot_end = slot_start + timedelta(minutes=duration)
# Skip past slots
if slot_start > now:
iso_start = slot_start.isoformat()
if iso_start not in booked_starts:
slots.append({"slot_start": iso_start, "slot_end": slot_end.isoformat()})
slot_start = slot_end
return slots
# ── Protected endpoints (business owner) ─────────────────────────────────────
@router.get("", response_model=List[AppointmentResponse])
async def list_appointments(
chatbot_id: Optional[str] = Query(None),
status: Optional[str] = Query(None),
page: int = Query(1, ge=1),
limit: int = Query(50, ge=1, le=200),
user=Depends(get_current_user),
):
supabase = get_supabase()
_check_booking_access(user.id, supabase)
company_id = _get_user_company_id(user.id, supabase)
chatbots_q = supabase.table("chatbots").select("id").eq("company_id", company_id)
if chatbot_id:
chatbots_q = chatbots_q.eq("id", chatbot_id)
chatbots = chatbots_q.execute()
chatbot_ids = [c["id"] for c in (chatbots.data or [])]
if not chatbot_ids:
return []
offset = (page - 1) * limit
q = supabase.table("appointments").select("*").in_("chatbot_id", chatbot_ids)
if status:
q = q.eq("status", status)
result = q.order("slot_start", desc=False).range(offset, offset + limit - 1).execute()
return [AppointmentResponse(**a) for a in (result.data or [])]
@router.patch("/{appointment_id}", response_model=AppointmentResponse)
async def update_appointment_status(
appointment_id: str,
data: AppointmentStatusUpdate,
user=Depends(get_current_user),
):
valid = ("pending", "confirmed", "cancelled", "completed")
if data.status not in valid:
raise HTTPException(status_code=400, detail=f"Status must be one of {valid}")
supabase = get_supabase()
_check_booking_access(user.id, supabase)
company_id = _get_user_company_id(user.id, supabase)
appt = supabase.table("appointments").select("*, chatbots(company_id)") \
.eq("id", appointment_id).execute()
if not appt.data:
raise HTTPException(status_code=404, detail="Appointment not found")
if appt.data[0].get("chatbots", {}).get("company_id") != company_id:
raise HTTPException(status_code=403, detail="Not authorized")
result = supabase.table("appointments").update({"status": data.status}) \
.eq("id", appointment_id).execute()
return AppointmentResponse(**result.data[0])
@router.get("/chatbot/{chatbot_id}/hours")
async def get_business_hours(chatbot_id: str, user=Depends(get_current_user)):
supabase = get_supabase()
_check_booking_access(user.id, supabase)
company_id = _get_user_company_id(user.id, supabase)
_verify_chatbot_ownership(chatbot_id, company_id, supabase)
result = supabase.table("business_hours").select("*") \
.eq("chatbot_id", chatbot_id).order("day_of_week").execute()
return result.data or []
@router.put("/chatbot/{chatbot_id}/hours")
async def save_business_hours(
chatbot_id: str,
data: BusinessHoursSave,
user=Depends(get_current_user),
):
supabase = get_supabase()
_check_booking_access(user.id, supabase)
company_id = _get_user_company_id(user.id, supabase)
_verify_chatbot_ownership(chatbot_id, company_id, supabase)
# Upsert each day
for entry in data.hours:
existing = supabase.table("business_hours").select("id") \
.eq("chatbot_id", chatbot_id).eq("day_of_week", entry.day_of_week).execute()
row = {
"chatbot_id": chatbot_id,
"day_of_week": entry.day_of_week,
"is_open": entry.is_open,
"open_time": entry.open_time,
"close_time": entry.close_time,
"slot_duration_minutes": entry.slot_duration_minutes,
}
if existing.data:
supabase.table("business_hours").update(row).eq("id", existing.data[0]["id"]).execute()
else:
row["id"] = str(uuid.uuid4())
supabase.table("business_hours").insert(row).execute()
return {"success": True}
# ── Public endpoints (customers booking) ─────────────────────────────────────
@public_router.get("/chatbots/{chatbot_id}/booking-info")
async def get_booking_info(chatbot_id: str):
"""Return public booking info for the booking page (no auth required)."""
supabase = get_supabase()
result = supabase.table("chatbots") \
.select("id, name, booking_enabled, companies(name)") \
.eq("id", chatbot_id).execute()
if not result.data:
raise HTTPException(status_code=404, detail="Chatbot not found")
chatbot = result.data[0]
if not chatbot.get("booking_enabled"):
raise HTTPException(status_code=400, detail="Booking is not enabled for this chatbot")
return {
"chatbot_id": chatbot["id"],
"chatbot_name": chatbot.get("name", ""),
"company_name": (chatbot.get("companies") or {}).get("name", ""),
}
@public_router.get("/chatbots/{chatbot_id}/available-slots")
async def get_available_slots(
chatbot_id: str,
date: str = Query(..., description="YYYY-MM-DD"),
):
"""Return available time slots for a given date (public)."""
supabase = get_supabase()
chatbot = supabase.table("chatbots").select("id, booking_enabled, is_published") \
.eq("id", chatbot_id).execute()
if not chatbot.data:
raise HTTPException(status_code=404, detail="Chatbot not found")
if not chatbot.data[0].get("booking_enabled"):
raise HTTPException(status_code=400, detail="Booking not enabled for this chatbot")
try:
target = datetime.strptime(date, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format, use YYYY-MM-DD")
slots = _get_available_slots(chatbot_id, target, supabase)
return {"date": date, "slots": slots}
@public_router.post("/chatbots/{chatbot_id}/appointments", response_model=AppointmentResponse, status_code=201)
async def create_appointment(chatbot_id: str, data: AppointmentCreate):
"""Create an appointment (public endpoint, no auth required)."""
supabase = get_supabase()
chatbot = supabase.table("chatbots").select("id, booking_enabled") \
.eq("id", chatbot_id).execute()
if not chatbot.data:
raise HTTPException(status_code=404, detail="Chatbot not found")
if not chatbot.data[0].get("booking_enabled"):
raise HTTPException(status_code=400, detail="Booking not enabled for this chatbot")
# Verify the slot is still available
slot_start_dt = data.slot_start
target_date = slot_start_dt.date()
available = _get_available_slots(chatbot_id, target_date, supabase)
available_starts = {s["slot_start"] for s in available}
slot_iso = slot_start_dt.isoformat()
# Try a few normalizations (with/without timezone suffix)
if slot_iso not in available_starts and slot_iso + "Z" not in available_starts:
# Check without microseconds
slot_iso_no_ms = slot_start_dt.replace(microsecond=0).isoformat()
if slot_iso_no_ms not in available_starts:
raise HTTPException(status_code=409, detail="This slot is no longer available")
# Calculate slot_end based on business hours duration
hours = supabase.table("business_hours").select("slot_duration_minutes") \
.eq("chatbot_id", chatbot_id).eq("day_of_week", target_date.weekday()).execute()
duration = hours.data[0]["slot_duration_minutes"] if hours.data else 60
slot_end_dt = slot_start_dt + timedelta(minutes=duration)
appt_data = {
"id": str(uuid.uuid4()),
"chatbot_id": chatbot_id,
"conversation_id": data.conversation_id,
"customer_name": data.customer_name,
"customer_contact": data.customer_contact,
"service": data.service,
"slot_start": data.slot_start.isoformat(),
"slot_end": slot_end_dt.isoformat(),
"status": "pending",
"notes": data.notes,
}
result = supabase.table("appointments").insert(appt_data).execute()
if not result.data:
raise HTTPException(status_code=500, detail="Failed to create appointment")
return AppointmentResponse(**result.data[0])

View File

@@ -58,6 +58,12 @@ async def signup(data: UserSignup):
}
).execute()
# Safety-net: ensure user_profiles row exists (trigger should handle it, but just in case)
try:
supabase.table("user_profiles").insert({"user_id": user.id}).execute()
except Exception:
pass # Row may already exist from trigger
token = auth_resp.session.access_token if auth_resp.session else ""
return TokenResponse(
access_token=token,
@@ -66,6 +72,7 @@ async def signup(data: UserSignup):
email=user.email,
company_name=data.company_name,
plan="free",
is_admin=False,
),
)
except HTTPException:
@@ -109,6 +116,13 @@ async def login(data: UserLogin):
)
plan = sub.data[0]["plan"] if sub.data else "free"
# Get is_admin flag
try:
profile = supabase.table("user_profiles").select("is_admin").eq("user_id", user.id).execute()
is_admin = profile.data[0].get("is_admin", False) if profile.data else False
except Exception:
is_admin = False
return TokenResponse(
access_token=auth_resp.session.access_token,
user=UserResponse(
@@ -116,6 +130,7 @@ async def login(data: UserLogin):
email=user.email,
company_name=company_name,
plan=plan,
is_admin=is_admin,
),
)
except HTTPException:
@@ -230,9 +245,16 @@ async def get_me(user=Depends(get_current_user)):
)
plan = sub.data[0]["plan"] if sub.data else "free"
try:
profile = supabase.table("user_profiles").select("is_admin").eq("user_id", user.id).execute()
is_admin = profile.data[0].get("is_admin", False) if profile.data else False
except Exception:
is_admin = False
return UserResponse(
id=user.id,
email=user.email,
company_name=company_name,
plan=plan,
is_admin=is_admin,
)

View File

@@ -89,6 +89,17 @@ async def stripe_webhook(
supabase = get_supabase()
event_type = event.get("type", "")
event_id = event.get("id", "")
# Idempotency check: skip already-processed events
if event_id:
existing = supabase.table("stripe_webhook_events") \
.select("stripe_event_id") \
.eq("stripe_event_id", event_id) \
.execute()
if existing.data:
logger.info(f"Stripe event {event_id} already processed, skipping")
return {"received": True}
if event_type == "checkout.session.completed":
session = event["data"]["object"]
@@ -140,6 +151,16 @@ async def stripe_webhook(
except Exception as e:
logger.warning(f"Failed to send cancellation notification: {e}")
# Record event as processed
if event_id:
try:
supabase.table("stripe_webhook_events").insert({
"stripe_event_id": event_id,
"event_type": event_type,
}).execute()
except Exception as e:
logger.warning(f"Failed to record stripe event {event_id}: {e}")
return {"received": True}
except HTTPException:

167
app/routers/campaigns.py Normal file
View File

@@ -0,0 +1,167 @@
from fastapi import APIRouter, HTTPException, Depends, Query
from app.database import get_supabase
from app.dependencies import get_current_user
from app.models import CampaignCreate, CampaignResponse
from typing import List, Optional
import uuid
import logging
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/campaigns", tags=["Campaigns"])
# ── Helpers ───────────────────────────────────────────────────────────────────
def _check_campaigns_access(user_id: str, supabase):
sub = supabase.table("subscriptions").select("plan").eq("user_id", user_id).eq("status", "active").execute()
plan = sub.data[0]["plan"] if sub.data else "free"
if plan not in ("starter", "business", "agency", "enterprise"):
raise HTTPException(status_code=402, detail="Campaigns require Starter plan or higher")
return plan
def _get_user_company_id(user_id: str, supabase) -> str:
result = supabase.table("companies").select("id").eq("owner_id", user_id).execute()
if not result.data:
raise HTTPException(status_code=404, detail="Company not found")
return result.data[0]["id"]
def _verify_chatbot_ownership(chatbot_id: str, company_id: str, supabase):
chatbot = supabase.table("chatbots").select("id").eq("id", chatbot_id).eq("company_id", company_id).execute()
if not chatbot.data:
raise HTTPException(status_code=404, detail="Chatbot not found")
# ── Endpoints ─────────────────────────────────────────────────────────────────
@router.get("", response_model=List[CampaignResponse])
async def list_campaigns(
chatbot_id: Optional[str] = Query(None),
page: int = Query(1, ge=1),
limit: int = Query(20, ge=1, le=100),
user=Depends(get_current_user),
):
supabase = get_supabase()
_check_campaigns_access(user.id, supabase)
company_id = _get_user_company_id(user.id, supabase)
chatbots_q = supabase.table("chatbots").select("id").eq("company_id", company_id)
if chatbot_id:
chatbots_q = chatbots_q.eq("id", chatbot_id)
chatbots = chatbots_q.execute()
chatbot_ids = [c["id"] for c in (chatbots.data or [])]
if not chatbot_ids:
return []
offset = (page - 1) * limit
result = supabase.table("campaigns").select("*").in_("chatbot_id", chatbot_ids) \
.order("created_at", desc=True).range(offset, offset + limit - 1).execute()
return [CampaignResponse(**c) for c in (result.data or [])]
@router.post("", response_model=CampaignResponse, status_code=201)
async def create_campaign(data: CampaignCreate, user=Depends(get_current_user)):
supabase = get_supabase()
_check_campaigns_access(user.id, supabase)
company_id = _get_user_company_id(user.id, supabase)
_verify_chatbot_ownership(data.chatbot_id, company_id, supabase)
# Count Telegram subscribers for this chatbot
subscribers = supabase.table("channel_sessions").select("id", count="exact") \
.eq("chatbot_id", data.chatbot_id).eq("channel", "telegram").execute()
recipients_count = subscribers.count or 0
campaign_data = {
"id": str(uuid.uuid4()),
"chatbot_id": data.chatbot_id,
"title": data.title,
"message": data.message,
"status": "draft",
"recipients_count": recipients_count,
"sent_count": 0,
}
result = supabase.table("campaigns").insert(campaign_data).execute()
if not result.data:
raise HTTPException(status_code=500, detail="Failed to create campaign")
return CampaignResponse(**result.data[0])
@router.post("/{campaign_id}/send", response_model=CampaignResponse)
async def send_campaign(campaign_id: str, user=Depends(get_current_user)):
"""Broadcast the campaign message to all Telegram subscribers of the chatbot."""
supabase = get_supabase()
_check_campaigns_access(user.id, supabase)
company_id = _get_user_company_id(user.id, supabase)
campaign = supabase.table("campaigns").select("*, chatbots(company_id)") \
.eq("id", campaign_id).execute()
if not campaign.data:
raise HTTPException(status_code=404, detail="Campaign not found")
c = campaign.data[0]
if c.get("chatbots", {}).get("company_id") != company_id:
raise HTTPException(status_code=403, detail="Not authorized")
if c["status"] == "sent":
raise HTTPException(status_code=400, detail="Campaign already sent")
chatbot_id = c["chatbot_id"]
# Get the Telegram bot token for this chatbot
conn = supabase.table("channel_connections").select("bot_token") \
.eq("chatbot_id", chatbot_id).eq("channel", "telegram").eq("is_active", True).execute()
if not conn.data or not conn.data[0].get("bot_token"):
raise HTTPException(status_code=400, detail="No active Telegram connection for this chatbot")
bot_token = conn.data[0]["bot_token"]
# Get all Telegram subscribers (channel_sessions)
sessions = supabase.table("channel_sessions").select("external_id") \
.eq("chatbot_id", chatbot_id).eq("channel", "telegram").execute()
subscribers = sessions.data or []
# Mark as sending
supabase.table("campaigns").update({"status": "sending"}).eq("id", campaign_id).execute()
# Broadcast
from app.services.telegram_service import send_message as tg_send
sent = 0
for sub in subscribers:
try:
# external_id format is "tg:{token_prefix}:{chat_id}"
parts = sub["external_id"].split(":")
if len(parts) >= 3:
chat_id = int(parts[2])
await tg_send(bot_token, chat_id, c["message"])
sent += 1
except Exception as e:
logger.warning(f"Failed to send campaign to {sub['external_id']}: {e}")
# Mark as sent
from datetime import datetime, timezone
supabase.table("campaigns").update({
"status": "sent",
"sent_count": sent,
"recipients_count": len(subscribers),
"sent_at": datetime.now(timezone.utc).isoformat(),
}).eq("id", campaign_id).execute()
updated = supabase.table("campaigns").select("*").eq("id", campaign_id).execute()
return CampaignResponse(**updated.data[0])
@router.delete("/{campaign_id}")
async def delete_campaign(campaign_id: str, user=Depends(get_current_user)):
supabase = get_supabase()
_check_campaigns_access(user.id, supabase)
company_id = _get_user_company_id(user.id, supabase)
campaign = supabase.table("campaigns").select("*, chatbots(company_id)") \
.eq("id", campaign_id).execute()
if not campaign.data:
raise HTTPException(status_code=404, detail="Campaign not found")
if campaign.data[0].get("chatbots", {}).get("company_id") != company_id:
raise HTTPException(status_code=403, detail="Not authorized")
if campaign.data[0]["status"] == "sending":
raise HTTPException(status_code=400, detail="Cannot delete a campaign that is currently sending")
supabase.table("campaigns").delete().eq("id", campaign_id).execute()
return {"success": True}

View File

@@ -1,11 +1,9 @@
import json
import re
import uuid
import logging
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel
from typing import List, Optional
from typing import Optional
from app.config import settings, PLAN_LIMITS
from app.database import get_supabase
@@ -14,7 +12,6 @@ from app.services.rag import rag_engine
from app.services.telegram_service import (
delete_webhook, get_bot_info, send_message as tg_send, set_webhook,
)
from app.services.whatsapp_service import send_message as wa_send, verify_signature
from app.routers.chat import (
_get_conversation_history, _get_or_create_conversation, _save_message,
)
@@ -32,11 +29,6 @@ class TelegramConnectRequest(BaseModel):
bot_token: str
class WhatsAppConnectRequest(BaseModel):
chatbot_id: str
wa_keyword: Optional[str] = None
# ── Helpers ───────────────────────────────────────────────────────────────────
def _verify_chatbot_ownership(chatbot_id: str, user_id: str, supabase):
@@ -53,18 +45,6 @@ def _verify_chatbot_ownership(chatbot_id: str, user_id: str, supabase):
raise HTTPException(status_code=404, detail="Chatbot not found")
def _generate_keyword(chatbot_name: str, chatbot_id: str, supabase) -> str:
base = re.sub(r"[^A-Z0-9]", "", chatbot_name.upper())[:10] or "BOT"
existing = (
supabase.table("channel_connections")
.select("id").eq("channel", "whatsapp").eq("wa_keyword", base).execute()
)
if not existing.data:
return base
suffix = chatbot_id.replace("-", "")[:4].upper()
return f"{base[:6]}{suffix}"
def _get_or_create_channel_session(
chatbot_id: str, channel: str, external_id: str, supabase
) -> dict:
@@ -85,32 +65,13 @@ def _get_or_create_channel_session(
return result.data[0]
def _upsert_whatsapp_session(chatbot_id: str, phone: str, session_id: str, supabase):
existing = (
supabase.table("channel_sessions")
.select("id").eq("channel", "whatsapp").eq("external_id", phone).execute()
)
if existing.data:
supabase.table("channel_sessions").update(
{"chatbot_id": chatbot_id, "session_id": session_id}
).eq("id", existing.data[0]["id"]).execute()
else:
supabase.table("channel_sessions").insert({
"id": str(uuid.uuid4()),
"chatbot_id": chatbot_id,
"channel": "whatsapp",
"external_id": phone,
"session_id": session_id,
}).execute()
def _check_channel_plan(user_id: str, channel: str, supabase):
"""Raise 402 if the user's plan doesn't include the requested channel."""
sub = supabase.table("subscriptions").select("plan").eq("user_id", user_id).eq("status", "active").execute()
plan = sub.data[0]["plan"] if sub.data else "free"
allowed = PLAN_LIMITS.get(plan, PLAN_LIMITS["free"]).get("channels", [])
if channel not in allowed:
label = {"telegram": "Starter", "whatsapp": "Business"}.get(channel, "a higher")
label = {"telegram": "Starter"}.get(channel, "a higher")
raise HTTPException(status_code=402, detail=f"{channel.title()} channel requires {label} plan or higher")
@@ -151,12 +112,6 @@ def _detect_language(text: str) -> str:
return best if scores[best] > 0.25 else "en"
def _wa_link(keyword: str) -> str:
if settings.whatsapp_display_number:
return f"https://wa.me/{settings.whatsapp_display_number}?text=START+{keyword}"
return ""
# ── CRUD endpoints ────────────────────────────────────────────────────────────
@router.get("")
@@ -165,17 +120,11 @@ async def list_channels(chatbot_id: str = Query(...), user=Depends(get_current_u
_verify_chatbot_ownership(chatbot_id, user.id, supabase)
result = (
supabase.table("channel_connections")
.select("id,channel,bot_username,wa_keyword,is_active,created_at")
.select("id,channel,bot_username,is_active,created_at")
.eq("chatbot_id", chatbot_id)
.execute()
)
rows = result.data or []
for row in rows:
if row["channel"] == "whatsapp" and row.get("wa_keyword"):
row["wa_link"] = _wa_link(row["wa_keyword"])
else:
row["wa_link"] = None
return rows
return result.data or []
@router.post("/telegram")
@@ -224,47 +173,6 @@ async def connect_telegram(data: TelegramConnectRequest, user=Depends(get_curren
}
@router.post("/whatsapp")
async def connect_whatsapp(data: WhatsAppConnectRequest, user=Depends(get_current_user)):
supabase = get_supabase()
_verify_chatbot_ownership(data.chatbot_id, user.id, supabase)
_check_channel_plan(user.id, "whatsapp", supabase)
chatbot = supabase.table("chatbots").select("name").eq("id", data.chatbot_id).execute()
chatbot_name = chatbot.data[0]["name"] if chatbot.data else "BOT"
keyword = (data.wa_keyword or _generate_keyword(chatbot_name, data.chatbot_id, supabase)).upper()
keyword = re.sub(r"[^A-Z0-9]", "", keyword)[:12]
if not keyword:
raise HTTPException(status_code=400, detail="Keyword must contain letters or numbers")
taken = (
supabase.table("channel_connections")
.select("id").eq("channel", "whatsapp").eq("wa_keyword", keyword)
.neq("chatbot_id", data.chatbot_id).execute()
)
if taken.data:
raise HTTPException(status_code=400, detail=f"Keyword '{keyword}' is already taken. Choose a different one.")
existing = (
supabase.table("channel_connections")
.select("id").eq("chatbot_id", data.chatbot_id).eq("channel", "whatsapp").execute()
)
conn_data = {
"chatbot_id": data.chatbot_id,
"channel": "whatsapp",
"wa_keyword": keyword,
"is_active": True,
}
if existing.data:
supabase.table("channel_connections").update(conn_data).eq("id", existing.data[0]["id"]).execute()
else:
conn_data["id"] = str(uuid.uuid4())
supabase.table("channel_connections").insert(conn_data).execute()
return {"success": True, "keyword": keyword, "wa_link": _wa_link(keyword)}
@router.delete("/{connection_id}")
async def disconnect_channel(connection_id: str, user=Depends(get_current_user)):
supabase = get_supabase()
@@ -383,136 +291,3 @@ async def telegram_webhook(bot_token: str, request: Request):
return {"ok": True}
# ── WhatsApp webhooks ─────────────────────────────────────────────────────────
@webhook_router.get("/whatsapp")
async def whatsapp_verify(request: Request):
"""Meta webhook verification challenge."""
params = dict(request.query_params)
if (
params.get("hub.mode") == "subscribe"
and settings.whatsapp_verify_token
and params.get("hub.verify_token") == settings.whatsapp_verify_token
):
return Response(content=params["hub.challenge"], media_type="text/plain")
raise HTTPException(status_code=403, detail="Forbidden")
@webhook_router.post("/whatsapp")
async def whatsapp_webhook(request: Request):
"""Receive messages from WhatsApp Cloud API."""
raw_body = await request.body()
if settings.whatsapp_app_secret:
sig = request.headers.get("X-Hub-Signature-256", "")
if not verify_signature(raw_body, sig, settings.whatsapp_app_secret):
raise HTTPException(status_code=403, detail="Invalid signature")
try:
body = json.loads(raw_body)
value = body["entry"][0]["changes"][0]["value"]
if "messages" not in value:
return {"ok": True}
msg = value["messages"][0]
if msg.get("type") != "text":
return {"ok": True}
from_number = msg["from"]
text = msg["text"]["body"].strip()
except (KeyError, IndexError, json.JSONDecodeError):
return {"ok": True}
supabase = get_supabase()
async def _wa_reply(to: str, message: str):
if settings.whatsapp_access_token and settings.whatsapp_phone_number_id:
await wa_send(settings.whatsapp_phone_number_id, to, message, settings.whatsapp_access_token)
# Handle START <keyword>
if text.upper().startswith("START "):
keyword = re.sub(r"[^A-Z0-9]", "", text[6:].strip().upper())
conn = (
supabase.table("channel_connections")
.select("*").eq("channel", "whatsapp").eq("wa_keyword", keyword).eq("is_active", True).execute()
)
if not conn.data:
await _wa_reply(from_number, f"Sorry, chatbot '{keyword}' not found. Use the link from the business you're contacting.")
return {"ok": True}
chatbot_id = conn.data[0]["chatbot_id"]
chatbot_result = supabase.table("chatbots").select("name,welcome_message").eq("id", chatbot_id).execute()
chatbot = chatbot_result.data[0] if chatbot_result.data else {}
_upsert_whatsapp_session(chatbot_id, from_number, str(uuid.uuid4()), supabase)
welcome = chatbot.get("welcome_message") or f"Hello! I'm {chatbot.get('name', 'your assistant')}. How can I help you?"
await _wa_reply(from_number, welcome)
return {"ok": True}
# Regular message — find active session
session_result = (
supabase.table("channel_sessions")
.select("*").eq("channel", "whatsapp").eq("external_id", from_number).execute()
)
if not session_result.data:
await _wa_reply(from_number, "To start chatting, use the WhatsApp link from the business you're trying to contact.")
return {"ok": True}
session = session_result.data[0]
chatbot_id = session["chatbot_id"]
# Check subscription still allows WhatsApp
if not _check_chatbot_channel_subscription(chatbot_id, "whatsapp", supabase):
await _wa_reply(from_number, "This service is currently unavailable. Please contact the business directly.")
return {"ok": True}
chatbot_result = (
supabase.table("chatbots").select("*, companies(name, logo_url)").eq("id", chatbot_id).execute()
)
if not chatbot_result.data:
return {"ok": True}
chatbot = chatbot_result.data[0]
collection_name = chatbot.get("qdrant_collection_name")
if not collection_name:
await _wa_reply(from_number, "This chatbot isn't ready yet. Please try again later.")
return {"ok": True}
detected_lang = _detect_language(text)
company_data = chatbot.get("companies", {}) or {}
conversation = _get_or_create_conversation(
chatbot_id=chatbot_id,
session_id=session["session_id"],
user_id=None,
language=detected_lang,
supabase=supabase,
)
history = _get_conversation_history(conversation["id"], supabase)
chatbot_config = {**chatbot, "company_name": company_data.get("name", "")}
try:
result = await rag_engine.process_query(
query=text,
collection_name=collection_name,
chatbot_config=chatbot_config,
conversation_history=history,
language=detected_lang,
)
except Exception as e:
logger.error(f"WhatsApp RAG error for chatbot {chatbot_id}: {e}")
await _wa_reply(from_number, "Sorry, I encountered an error. Please try again.")
return {"ok": True}
confidence_score = max((s.score for s in result.get("sources", [])), default=0.0)
_save_message(conversation["id"], "user", text, supabase)
_save_message(
conversation["id"], "assistant", result["response"], supabase,
sources=[s.model_dump() for s in result.get("sources", [])],
model=result.get("model", ""),
confidence_score=confidence_score,
)
supabase.table("conversations").update(
{"message_count": len(history) + 2}
).eq("id", conversation["id"]).execute()
supabase.table("channel_sessions").update({"last_active": "now()"}).eq("id", session["id"]).execute()
await _wa_reply(from_number, result["response"])
return {"ok": True}

View File

@@ -124,6 +124,19 @@ async def chat(
# Get conversation history
history = _get_conversation_history(conversation["id"], supabase)
# If an agent has taken over this conversation, stop the bot from responding
conv_status = conversation.get("status", "open")
if conv_status == "agent_handling":
return ChatResponse(
response="",
session_id=session_id,
sources=[],
model_used="",
tokens_used=0,
needs_lead_capture=False,
handoff=False,
)
# Get company info for context
company_data = chatbot.get("companies", {}) or {}
chatbot_config = {
@@ -131,6 +144,19 @@ async def chat(
"company_name": company_data.get("name", ""),
}
# If booking is enabled, inject a note into the system prompt so the bot
# can guide users to the booking page
if chatbot.get("booking_enabled"):
from app.config import settings as _cfg
booking_url = f"{_cfg.app_url}/book/{chatbot_id}"
booking_note = (
f"\n\nAppointment booking: This business accepts appointments online. "
f"If the user wants to book an appointment, meeting, or consultation, "
f"provide them this booking link: {booking_url}"
)
existing_prompt = chatbot_config.get("system_prompt") or ""
chatbot_config["system_prompt"] = existing_prompt + booking_note
# Run RAG
result = await rag_engine.process_query(
query=message.message,

View File

@@ -5,6 +5,7 @@ from app.models import (
from app.database import get_supabase
from app.dependencies import get_current_user, get_user_subscription
from app.services.vector_store import vector_store
from app.services.storage import delete_from_storage
from app.config import PLAN_LIMITS
from typing import List
import uuid
@@ -83,9 +84,21 @@ async def create_chatbot(data: ChatbotCreate, user=Depends(get_current_user)):
"handoff_keywords": data.handoff_keywords,
}
try:
result = supabase.table("chatbots").insert(chatbot_data).execute()
if not result.data:
raise HTTPException(status_code=500, detail="Failed to create chatbot")
except HTTPException:
raise
except Exception as e:
# Cleanup orphaned Qdrant collection if DB insert failed
if collection_name:
try:
vector_store.delete_collection(collection_name)
logger.warning(f"Cleaned up orphaned Qdrant collection {collection_name} after DB failure")
except Exception:
pass
raise HTTPException(status_code=500, detail="Failed to create chatbot")
return _format_chatbot(result.data[0], supabase)
@@ -139,7 +152,11 @@ async def delete_chatbot(chatbot_id: str, user=Depends(get_current_user)):
try:
vector_store.delete_collection(chatbot["qdrant_collection_name"])
except Exception as e:
logger.warning(f"Failed to delete collection: {e}")
logger.warning(f"Failed to delete Qdrant collection: {e}")
# Delete logo from Supabase Storage
if chatbot.get("logo_url"):
delete_from_storage(supabase, "logos", chatbot["logo_url"])
supabase.table("chatbots").delete().eq("id", chatbot_id).execute()
return SuccessResponse(success=True, message="Chatbot deleted")
@@ -303,4 +320,5 @@ def _format_chatbot(chatbot: dict, supabase) -> ChatbotResponse:
handoff_message=chatbot.get("handoff_message", "I'll connect you with our team. Please wait."),
handoff_email=chatbot.get("handoff_email"),
handoff_keywords=chatbot.get("handoff_keywords") or ["human", "agent", "speak to someone", "talk to a person", "real person"],
booking_enabled=bool(chatbot.get("booking_enabled")),
)

View File

@@ -5,6 +5,7 @@ from app.dependencies import get_current_user
from app.services.document_processor import process_document
from app.services.embeddings import embedding_service
from app.services.vector_store import vector_store
from app.services.storage import delete_from_storage, extract_storage_path
from app.config import settings
from typing import List
import uuid
@@ -205,10 +206,72 @@ async def delete_document(chatbot_id: str, document_id: str, user=Depends(get_cu
except Exception as e:
logger.warning(f"Failed to delete vectors: {e}")
# Delete file from Supabase Storage
if doc.data[0].get("file_url"):
delete_from_storage(supabase, "documents", doc.data[0]["file_url"])
supabase.table("documents").delete().eq("id", document_id).execute()
return SuccessResponse(success=True, message="Document deleted")
@router.post("/{document_id}/retry", response_model=DocumentResponse)
async def retry_document_processing(
chatbot_id: str,
document_id: str,
background_tasks: BackgroundTasks,
user=Depends(get_current_user),
):
"""Retry processing a failed document."""
supabase = get_supabase()
chatbot = _get_user_chatbot(chatbot_id, user.id, supabase)
doc = supabase.table("documents").select("*").eq("id", document_id).eq("chatbot_id", chatbot_id).execute()
if not doc.data:
raise HTTPException(status_code=404, detail="Document not found")
document = doc.data[0]
if document.get("status") != "failed":
raise HTTPException(status_code=400, detail="Only failed documents can be retried")
file_url = document.get("file_url")
if not file_url:
raise HTTPException(
status_code=400,
detail="No file URL stored. Please re-upload this document."
)
# Download file from storage
try:
path = extract_storage_path(file_url, "documents")
if not path:
raise HTTPException(status_code=400, detail="Cannot locate file in storage. Please re-upload.")
file_bytes = supabase.storage.from_("documents").download(path)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to download document {document_id} for retry: {e}")
raise HTTPException(status_code=400, detail="Cannot retrieve file from storage. Please re-upload.")
# Reset status to processing
result = supabase.table("documents").update({
"status": "processing",
"error_message": None,
"chunk_count": 0,
}).eq("id", document_id).execute()
# Re-enqueue background processing
background_tasks.add_task(
_process_document_bg,
file_bytes=file_bytes,
file_name=document["file_name"],
doc_id=document_id,
chatbot=chatbot,
supabase=supabase,
)
return DocumentResponse(**result.data[0])
# ── URL Sources ───────────────────────────────────────────────────────────────
@url_router.get("", response_model=List[UrlSourceResponse])

View File

@@ -2,8 +2,9 @@ from fastapi import APIRouter, HTTPException, Depends, Query
from app.database import get_supabase
from app.dependencies import get_current_user
from app.config import PLAN_LIMITS
from app.models import InboxConversation, InboxMessage
from app.models import InboxConversation, InboxMessage, ConversationStatusUpdate, AgentReplyCreate
from typing import List, Optional
import uuid
import logging
logger = logging.getLogger(__name__)
@@ -79,6 +80,8 @@ async def list_inbox_conversations(
language=conv.get("language", "en"),
message_count=conv.get("message_count", 0),
first_message=first_message_text,
status=conv.get("status", "open"),
last_agent_reply_at=conv.get("last_agent_reply_at"),
created_at=conv.get("created_at"),
))
@@ -137,6 +140,67 @@ async def get_inbox_conversation(
}
@router.patch("/conversations/{conversation_id}/status")
async def update_conversation_status(
conversation_id: str,
data: ConversationStatusUpdate,
user=Depends(get_current_user),
):
"""Update conversation status (open, agent_handling, resolved)."""
if data.status not in ("open", "agent_handling", "resolved"):
raise HTTPException(status_code=400, detail="Invalid status")
supabase = get_supabase()
_check_inbox_access(user.id, supabase)
company_id = _get_user_company_id(user.id, supabase)
conv = supabase.table("conversations").select("*, chatbots(company_id)") \
.eq("id", conversation_id).execute()
if not conv.data:
raise HTTPException(status_code=404, detail="Conversation not found")
if conv.data[0].get("chatbots", {}).get("company_id") != company_id:
raise HTTPException(status_code=403, detail="Not authorized")
supabase.table("conversations").update({"status": data.status}).eq("id", conversation_id).execute()
return {"success": True, "status": data.status}
@router.post("/conversations/{conversation_id}/reply")
async def agent_reply(
conversation_id: str,
data: AgentReplyCreate,
user=Depends(get_current_user),
):
"""Send an agent reply to a conversation."""
supabase = get_supabase()
_check_inbox_access(user.id, supabase)
company_id = _get_user_company_id(user.id, supabase)
conv = supabase.table("conversations").select("*, chatbots(company_id)") \
.eq("id", conversation_id).execute()
if not conv.data:
raise HTTPException(status_code=404, detail="Conversation not found")
if conv.data[0].get("chatbots", {}).get("company_id") != company_id:
raise HTTPException(status_code=403, detail="Not authorized")
msg_id = str(uuid.uuid4())
supabase.table("messages").insert({
"id": msg_id,
"conversation_id": conversation_id,
"role": "agent",
"content": data.message,
}).execute()
# Mark as agent_handling if not already, and record reply time
current_status = conv.data[0].get("status", "open")
update_data: dict = {"last_agent_reply_at": "now()"}
if current_status == "open":
update_data["status"] = "agent_handling"
supabase.table("conversations").update(update_data).eq("id", conversation_id).execute()
return {"success": True, "message_id": msg_id}
@router.delete("/conversations/{conversation_id}")
async def delete_inbox_conversation(
conversation_id: str,

View File

@@ -2,7 +2,7 @@ from fastapi import APIRouter, HTTPException, Depends, Query
from fastapi.responses import StreamingResponse
from app.database import get_supabase
from app.dependencies import get_current_user
from app.models import LeadCreate, LeadResponse
from app.models import LeadCreate, LeadResponse, LeadUpdate
from typing import List, Optional
import uuid
import csv
@@ -60,6 +60,40 @@ async def list_leads(
return [LeadResponse(**lead) for lead in (result.data or [])]
@router.patch("/{lead_id}", response_model=LeadResponse)
async def update_lead(
lead_id: str,
data: LeadUpdate,
user=Depends(get_current_user),
):
"""Update lead status or notes."""
supabase = get_supabase()
_check_leads_access(user.id, supabase)
company_id = _get_user_company_id(user.id, supabase)
lead = supabase.table("leads").select("*, chatbots(company_id)") \
.eq("id", lead_id).execute()
if not lead.data:
raise HTTPException(status_code=404, detail="Lead not found")
if lead.data[0].get("chatbots", {}).get("company_id") != company_id:
raise HTTPException(status_code=403, detail="Not authorized")
update_fields: dict = {}
if data.status is not None:
valid_statuses = ("new", "contacted", "qualified", "closed", "lost")
if data.status not in valid_statuses:
raise HTTPException(status_code=400, detail=f"Status must be one of {valid_statuses}")
update_fields["status"] = data.status
if data.notes is not None:
update_fields["notes"] = data.notes
if not update_fields:
return LeadResponse(**lead.data[0])
result = supabase.table("leads").update(update_fields).eq("id", lead_id).execute()
return LeadResponse(**result.data[0])
@router.get("/export")
async def export_leads_csv(
chatbot_id: Optional[str] = Query(None),

View File

@@ -96,7 +96,7 @@ async def get_available_models(user=Depends(get_current_user)):
if plan == "free":
upgrade_label = "Upgrade to Starter for more AI models and messaging channels"
elif plan == "starter":
upgrade_label = "Upgrade to Business for GPT-4o, Claude, Gemini and WhatsApp"
upgrade_label = "Upgrade to Business for GPT-4o, Claude, and Gemini"
return ModelsResponse(
models=models,

View File

@@ -16,13 +16,20 @@ IMPORTANT RULES:
3. Be concise and helpful
4. Always maintain a professional, friendly tone
5. If asked about topics completely outside the context, politely redirect to relevant topics
{language_instruction}
{custom_instructions}
Context from knowledge base:
{context}
"""
LANGUAGE_NAMES = {
"en": "English", "fr": "French", "es": "Spanish", "de": "German",
"it": "Italian", "pt": "Portuguese", "ar": "Arabic", "zh": "Chinese",
"ja": "Japanese", "ko": "Korean", "ru": "Russian", "nl": "Dutch",
"tr": "Turkish", "pl": "Polish", "vi": "Vietnamese", "th": "Thai",
}
class RAGEngine:
def __init__(self):
@@ -102,8 +109,15 @@ class RAGEngine:
logger.warning(f"[RAG] No context found for query: '{query}' in collection '{collection_name}'")
# Step 4: Build messages
lang_name = LANGUAGE_NAMES.get(language, "English") if language and language != "en" else ""
language_instruction = (
f"\n6. Respond in {lang_name}. Match the language of the user's message."
if lang_name else ""
)
system_prompt = RAG_SYSTEM_PROMPT.format(
company_name=chatbot_config.get("company_name", ""),
language_instruction=language_instruction,
custom_instructions=chatbot_config.get("system_prompt") or "",
context=context,
)

46
app/services/storage.py Normal file
View File

@@ -0,0 +1,46 @@
"""
Supabase Storage helper utilities.
Used to delete files from storage buckets when deleting documents or chatbots.
"""
import logging
from typing import Optional
from urllib.parse import urlparse
logger = logging.getLogger(__name__)
def extract_storage_path(url: str, bucket: str) -> Optional[str]:
"""
Extract the file path from a Supabase Storage public URL.
URL format: {supabase_url}/storage/v1/object/public/{bucket}/{path}
Returns the path portion after the bucket name, or None if not parseable.
"""
if not url:
return None
try:
parsed = urlparse(url)
prefix = f"/storage/v1/object/public/{bucket}/"
if prefix in parsed.path:
idx = parsed.path.index(prefix) + len(prefix)
return parsed.path[idx:]
except Exception as e:
logger.warning(f"Failed to extract storage path from URL '{url}': {e}")
return None
def delete_from_storage(supabase, bucket: str, url: str) -> bool:
"""
Delete a file from a Supabase Storage bucket given its public URL.
Returns True on success, False if the URL couldn't be parsed or deletion failed.
"""
path = extract_storage_path(url, bucket)
if not path:
return False
try:
supabase.storage.from_(bucket).remove([path])
logger.info(f"Deleted storage file: {bucket}/{path}")
return True
except Exception as e:
logger.warning(f"Failed to delete storage file {bucket}/{path}: {e}")
return False

View File

@@ -1,36 +0,0 @@
import httpx
import hashlib
import hmac
import logging
logger = logging.getLogger(__name__)
_META_API = "https://graph.facebook.com/v19.0"
async def send_message(phone_number_id: str, to: str, text: str, access_token: str) -> bool:
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(
f"{_META_API}/{phone_number_id}/messages",
headers={"Authorization": f"Bearer {access_token}"},
json={
"messaging_product": "whatsapp",
"to": to,
"type": "text",
"text": {"body": text},
},
)
return r.status_code == 200
except Exception as e:
logger.error(f"WhatsApp send error: {e}")
return False
def verify_signature(payload: bytes, signature: str, app_secret: str) -> bool:
expected = "sha256=" + hmac.new(
app_secret.encode("utf-8"),
payload,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature)

View File

@@ -1,140 +1,182 @@
def generate_widget_js(app_url: str) -> str:
"""Generate the embeddable widget JavaScript with app_url baked in."""
return f"""
(function() {{
var APP_URL = "{app_url}";
// Find script tag to get chatbot ID
var scripts = document.querySelectorAll('script[data-chatbot]');
var chatbotId = null;
if (scripts.length > 0) {{
chatbotId = scripts[scripts.length - 1].getAttribute('data-chatbot');
}}
if (!chatbotId) {{
console.warn('[Contexta] No data-chatbot attribute found on script tag');
return;
}}
// Styles
var style = document.createElement('style');
style.textContent = `
.contexta-btn {{
position: fixed;
bottom: 24px;
right: 24px;
width: 56px;
height: 56px;
border-radius: 50%;
background: #6366f1;
border: none;
cursor: pointer;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
display: flex;
align-items: center;
justify-content: center;
z-index: 999998;
transition: transform 0.2s, box-shadow 0.2s;
}}
.contexta-btn:hover {{
transform: scale(1.08);
box-shadow: 0 6px 20px rgba(0,0,0,0.25);
}}
.contexta-btn svg {{
width: 26px;
height: 26px;
fill: white;
}}
.contexta-container {{
position: fixed;
bottom: 92px;
right: 24px;
width: 380px;
height: 580px;
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0,0,0,0.15);
z-index: 999999;
overflow: hidden;
display: none;
flex-direction: column;
border: 1px solid #e5e7eb;
}}
.contexta-container.open {{
display: flex;
}}
.contexta-iframe {{
width: 100%;
height: 100%;
border: none;
flex: 1;
}}
.contexta-close {{
position: absolute;
top: 8px;
right: 8px;
background: rgba(0,0,0,0.3);
border: none;
color: white;
border-radius: 50%;
width: 24px;
height: 24px;
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
}}
@media (max-width: 480px) {{
.contexta-container {{
bottom: 0;
right: 0;
width: 100vw;
height: 100vh;
border-radius: 0;
}}
}}
`;
document.head.appendChild(style);
// Button
var btn = document.createElement('button');
btn.className = 'contexta-btn';
btn.setAttribute('aria-label', 'Open chat');
btn.innerHTML = '<svg viewBox="0 0 24 24"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z"/></svg>';
document.body.appendChild(btn);
// Container with iframe
var container = document.createElement('div');
container.className = 'contexta-container';
var closeBtn = document.createElement('button');
closeBtn.className = 'contexta-close';
closeBtn.innerHTML = '&times;';
closeBtn.setAttribute('aria-label', 'Close chat');
container.appendChild(closeBtn);
var iframe = document.createElement('iframe');
iframe.className = 'contexta-iframe';
iframe.src = APP_URL + '/chat/' + chatbotId;
iframe.setAttribute('allow', 'microphone');
container.appendChild(iframe);
document.body.appendChild(container);
// Toggle logic
var isOpen = false;
function toggle() {{
isOpen = !isOpen;
if (isOpen) {{
container.classList.add('open');
btn.innerHTML = '<svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>';
}} else {{
container.classList.remove('open');
btn.innerHTML = '<svg viewBox="0 0 24 24"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z"/></svg>';
}}
}}
btn.addEventListener('click', toggle);
closeBtn.addEventListener('click', toggle);
}})();
"""
Widget JS generator.
Produces a self-contained, framework-agnostic JavaScript bundle served at
GET /widget.js. Embed on any page with:
<script
src="https://api.yoursite.com/widget.js"
data-chatbot="<chatbot-id>">
</script>
Works on vanilla HTML, WordPress, Webflow, Shopify, Next.js (_document),
and any framework where you control the HTML shell.
For React/Vue projects that want a native component, host-side devs can call
window.Contexta.open() / .close() / .toggle()
from their own button, or await the dedicated npm package (@contexta/widget).
Design decisions
----------------
- All CSS is ID-scoped (#ctxa-*) to avoid colliding with host-page styles.
- The iframe src is set lazily on first open — zero network cost until use.
- document.currentScript is captured synchronously (before any async code)
so it works even when the host page has many script tags.
- z-index 2147483647 is the highest a browser will honour.
- sandbox attribute restricts the iframe while still allowing forms/popups.
"""
_TEMPLATE = r"""(function () {
'use strict';
/* ── Double-init guard ─────────────────────────────────────────────── */
if (window.__ctxa) return;
/* ── Read chatbot ID from <script data-chatbot="..."> ──────────────── */
var _cur = document.currentScript;
var _id = _cur && _cur.getAttribute('data-chatbot');
if (!_id) {
var _all = document.querySelectorAll('script[data-chatbot]');
if (_all.length) _id = _all[_all.length - 1].getAttribute('data-chatbot');
}
if (!_id) {
console.warn('[Contexta] widget loaded but no data-chatbot attribute found.');
return;
}
var _base = '__APP_URL__';
var _url = _base + '/chat/' + _id;
/* ── Styles ─────────────────────────────────────────────────────────── */
var _css = [
'#ctxa-btn{',
'position:fixed;bottom:24px;right:24px;',
'width:56px;height:56px;border-radius:50%;',
'background:linear-gradient(135deg,#6366f1,#4f46e5);',
'border:none;cursor:pointer;padding:0;',
'box-shadow:0 4px 20px rgba(99,102,241,.45);',
'display:flex;align-items:center;justify-content:center;',
'z-index:2147483646;',
'transition:transform .2s ease,box-shadow .2s ease;',
'outline:none;',
'}',
'#ctxa-btn:hover{transform:scale(1.1);box-shadow:0 6px 28px rgba(99,102,241,.55)}',
'#ctxa-btn:focus-visible{outline:2px solid #6366f1;outline-offset:3px}',
/* Icon animations */
'#ctxa-ico-chat,#ctxa-ico-x{position:absolute;transition:opacity .15s ease,transform .2s ease}',
'#ctxa-ico-x{opacity:0;transform:rotate(-90deg)}',
'#ctxa-btn.open #ctxa-ico-chat{opacity:0;transform:rotate(90deg)}',
'#ctxa-btn.open #ctxa-ico-x{opacity:1;transform:rotate(0)}',
/* Panel */
'#ctxa-panel{',
'position:fixed;bottom:92px;right:24px;',
'width:380px;height:600px;',
'max-height:calc(100dvh - 120px);',
'border-radius:20px;overflow:hidden;background:#fff;',
'box-shadow:0 20px 60px rgba(0,0,0,.18),0 0 0 1px rgba(0,0,0,.07);',
'z-index:2147483647;',
'opacity:0;transform:translateY(12px) scale(.97);pointer-events:none;',
'transition:opacity .22s cubic-bezier(.4,0,.2,1),transform .22s cubic-bezier(.4,0,.2,1);',
'}',
'#ctxa-panel.open{opacity:1;transform:translateY(0) scale(1);pointer-events:all}',
'#ctxa-frame{width:100%;height:100%;border:none;display:block;background:#fff}',
/* Mobile: full-screen */
'@media(max-width:480px){',
'#ctxa-panel{bottom:0;right:0;left:0;width:100%;height:100%;',
'max-height:100dvh;border-radius:0;box-shadow:none}',
'#ctxa-btn{bottom:16px;right:16px}',
'}',
].join('');
var _style = document.createElement('style');
_style.textContent = _css;
document.head.appendChild(_style);
/* ── Button ─────────────────────────────────────────────────────────── */
var _btn = document.createElement('button');
_btn.id = 'ctxa-btn';
_btn.setAttribute('aria-label', 'Open chat');
_btn.setAttribute('aria-expanded', 'false');
_btn.innerHTML = (
'<svg id="ctxa-ico-chat" width="24" height="24" viewBox="0 0 24 24" fill="none">' +
'<path d="M12 2C6.477 2 2 6.2 2 11.4c0 2.8 1.26 5.3 3.26 7.04L4 22l4.2-1.75' +
'A11.1 11.1 0 0 0 12 20.8c5.523 0 10-4.2 10-9.4S17.523 2 12 2z" fill="white"/>' +
'</svg>' +
'<svg id="ctxa-ico-x" width="20" height="20" viewBox="0 0 24 24" fill="none">' +
'<path d="M18 6L6 18M6 6l12 12" stroke="white" stroke-width="2.5"' +
' stroke-linecap="round" stroke-linejoin="round"/>' +
'</svg>'
);
/* ── Panel + lazy iframe ─────────────────────────────────────────────── */
var _panel = document.createElement('div');
_panel.id = 'ctxa-panel';
_panel.setAttribute('role', 'dialog');
_panel.setAttribute('aria-label', 'Chat');
var _frame = document.createElement('iframe');
_frame.id = 'ctxa-frame';
_frame.title = 'Contexta chat';
_frame.setAttribute('allow', 'clipboard-write');
/* sandbox: scripts + same-origin needed for the React app to run */
_frame.setAttribute('sandbox',
'allow-scripts allow-same-origin allow-forms allow-popups allow-top-navigation-by-user-activation'
);
_panel.appendChild(_frame);
/* ── Mount after DOM ready ───────────────────────────────────────────── */
function _mount() {
document.body.appendChild(_btn);
document.body.appendChild(_panel);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', _mount);
} else {
_mount();
}
/* ── Open / Close ────────────────────────────────────────────────────── */
var _isOpen = false;
var _loaded = false;
function _open() {
if (!_loaded) { _frame.src = _url; _loaded = true; }
_isOpen = true;
_panel.classList.add('open');
_btn.classList.add('open');
_btn.setAttribute('aria-expanded', 'true');
_btn.setAttribute('aria-label', 'Close chat');
}
function _close() {
_isOpen = false;
_panel.classList.remove('open');
_btn.classList.remove('open');
_btn.setAttribute('aria-expanded', 'false');
_btn.setAttribute('aria-label', 'Open chat');
}
_btn.addEventListener('click', function () { _isOpen ? _close() : _open(); });
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && _isOpen) _close();
});
/* ── Public API ──────────────────────────────────────────────────────── */
window.__ctxa = true;
window.Contexta = {
open: _open,
close: _close,
toggle: function () { _isOpen ? _close() : _open(); },
};
}());
"""
def generate_widget_js(app_url: str) -> str:
"""Return the widget bundle with the frontend app URL baked in."""
return _TEMPLATE.replace('__APP_URL__', app_url.rstrip('/'))

View File

@@ -0,0 +1,42 @@
-- Migration 001: User profiles for admin flag and account suspension
-- Run this in Supabase SQL editor
CREATE TABLE IF NOT EXISTS user_profiles (
user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
is_admin BOOLEAN DEFAULT FALSE NOT NULL,
suspended_at TIMESTAMPTZ,
suspended_reason TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Enable RLS: users can only read their own profile; service role bypasses RLS
ALTER TABLE user_profiles ENABLE ROW LEVEL SECURITY;
CREATE POLICY "users_read_own_profile" ON user_profiles
FOR SELECT USING (auth.uid() = user_id);
-- Index for fast admin lookups
CREATE INDEX IF NOT EXISTS idx_user_profiles_admin ON user_profiles(is_admin) WHERE is_admin = TRUE;
-- Backfill existing users
INSERT INTO user_profiles (user_id)
SELECT id FROM auth.users
ON CONFLICT (user_id) DO NOTHING;
-- Trigger: auto-create profile row when a new user signs up
CREATE OR REPLACE FUNCTION create_user_profile()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO user_profiles (user_id) VALUES (NEW.id) ON CONFLICT (user_id) DO NOTHING;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION create_user_profile();
-- To grant admin to the first admin user (run separately after applying migration):
-- UPDATE user_profiles SET is_admin = TRUE WHERE user_id = '<your-user-uuid>';

View File

@@ -0,0 +1,8 @@
-- Migration 002: Add confidence_score and is_handoff to messages table
-- Run this in Supabase SQL editor
ALTER TABLE messages ADD COLUMN IF NOT EXISTS confidence_score DECIMAL(5,4);
ALTER TABLE messages ADD COLUMN IF NOT EXISTS is_handoff BOOLEAN DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS idx_messages_confidence
ON messages(confidence_score) WHERE confidence_score IS NOT NULL;

View File

@@ -0,0 +1,13 @@
-- Migration 003: Stripe webhook event deduplication table
-- Run this in Supabase SQL editor
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE IF NOT EXISTS stripe_webhook_events (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
stripe_event_id VARCHAR(255) UNIQUE NOT NULL,
event_type VARCHAR(100) NOT NULL,
processed_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_stripe_events_id ON stripe_webhook_events(stripe_event_id);

View File

@@ -23,4 +23,8 @@ dependencies = [
"pandas>=2.2.0",
"python-multipart>=0.0.9",
"pydantic-settings>=2.0.0",
"python-json-logger>=2.0.0",
"prometheus-fastapi-instrumentator>=6.0.0",
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
]

3
pytest.ini Normal file
View File

@@ -0,0 +1,3 @@
[pytest]
asyncio_mode = auto
testpaths = tests

View File

@@ -86,16 +86,14 @@ CREATE POLICY "feedback_select_owner" ON message_feedback FOR SELECT USING (
CREATE TABLE IF NOT EXISTS channel_connections (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
chatbot_id UUID NOT NULL REFERENCES chatbots(id) ON DELETE CASCADE,
channel VARCHAR(20) NOT NULL CHECK (channel IN ('telegram', 'whatsapp')),
channel VARCHAR(20) NOT NULL CHECK (channel IN ('telegram')),
bot_token TEXT,
bot_username TEXT,
wa_keyword VARCHAR(50),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(chatbot_id, channel)
);
CREATE INDEX IF NOT EXISTS idx_channel_connections_chatbot ON channel_connections(chatbot_id);
CREATE INDEX IF NOT EXISTS idx_channel_connections_wa_keyword ON channel_connections(wa_keyword) WHERE channel = 'whatsapp';
ALTER TABLE channel_connections ENABLE ROW LEVEL SECURITY;
CREATE POLICY "channel_connections_owner" ON channel_connections FOR ALL USING (
chatbot_id IN (

View File

@@ -5,16 +5,14 @@
CREATE TABLE IF NOT EXISTS channel_connections (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
chatbot_id UUID NOT NULL REFERENCES chatbots(id) ON DELETE CASCADE,
channel VARCHAR(20) NOT NULL CHECK (channel IN ('telegram', 'whatsapp')),
channel VARCHAR(20) NOT NULL CHECK (channel IN ('telegram')),
bot_token TEXT,
bot_username TEXT,
wa_keyword VARCHAR(50),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(chatbot_id, channel)
);
CREATE INDEX IF NOT EXISTS idx_channel_connections_chatbot ON channel_connections(chatbot_id);
CREATE INDEX IF NOT EXISTS idx_channel_connections_wa_keyword ON channel_connections(wa_keyword) WHERE channel = 'whatsapp';
ALTER TABLE channel_connections ENABLE ROW LEVEL SECURITY;
CREATE POLICY "channel_connections_owner" ON channel_connections FOR ALL USING (
chatbot_id IN (

View File

@@ -0,0 +1,97 @@
-- Contexta — Features Migration (Phase 1, 2, 3)
-- Run this in your Supabase SQL Editor
-- ══════════════════════════════════════════════════════════════
-- PHASE 1 — Live Chat Inbox + Lead CRM
-- ══════════════════════════════════════════════════════════════
-- Add status to conversations (open → agent_handling → resolved)
ALTER TABLE conversations
ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'open',
ADD COLUMN IF NOT EXISTS last_agent_reply_at TIMESTAMPTZ;
-- Add CRM fields to leads
ALTER TABLE leads
ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'new',
ADD COLUMN IF NOT EXISTS notes TEXT;
-- ══════════════════════════════════════════════════════════════
-- PHASE 2a — Appointment Booking
-- ══════════════════════════════════════════════════════════════
-- Add booking toggle to chatbots
ALTER TABLE chatbots
ADD COLUMN IF NOT EXISTS booking_enabled BOOLEAN DEFAULT FALSE;
-- Business hours per chatbot (one row per weekday)
CREATE TABLE IF NOT EXISTS business_hours (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
chatbot_id UUID NOT NULL REFERENCES chatbots(id) ON DELETE CASCADE,
day_of_week INTEGER NOT NULL CHECK (day_of_week BETWEEN 0 AND 6), -- 0=Mon, 6=Sun
is_open BOOLEAN DEFAULT TRUE,
open_time TIME NOT NULL DEFAULT '09:00',
close_time TIME NOT NULL DEFAULT '17:00',
slot_duration_minutes INTEGER NOT NULL DEFAULT 60,
UNIQUE(chatbot_id, day_of_week)
);
CREATE INDEX IF NOT EXISTS idx_business_hours_chatbot ON business_hours(chatbot_id);
ALTER TABLE business_hours ENABLE ROW LEVEL SECURITY;
CREATE POLICY "business_hours_owner" ON business_hours FOR ALL USING (
chatbot_id IN (
SELECT c.id FROM chatbots c
JOIN companies co ON c.company_id = co.id
WHERE co.owner_id = auth.uid()
)
);
-- Appointments table
CREATE TABLE IF NOT EXISTS appointments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
chatbot_id UUID NOT NULL REFERENCES chatbots(id) ON DELETE CASCADE,
conversation_id UUID REFERENCES conversations(id) ON DELETE SET NULL,
customer_name TEXT NOT NULL,
customer_contact TEXT NOT NULL, -- email or phone
service TEXT,
slot_start TIMESTAMPTZ NOT NULL,
slot_end TIMESTAMPTZ NOT NULL,
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending','confirmed','cancelled','completed')),
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_appointments_chatbot ON appointments(chatbot_id);
CREATE INDEX IF NOT EXISTS idx_appointments_slot ON appointments(chatbot_id, slot_start);
ALTER TABLE appointments ENABLE ROW LEVEL SECURITY;
CREATE POLICY "appointments_owner" ON appointments FOR ALL USING (
chatbot_id IN (
SELECT c.id FROM chatbots c
JOIN companies co ON c.company_id = co.id
WHERE co.owner_id = auth.uid()
)
);
-- Allow anonymous inserts (customers booking without auth)
CREATE POLICY "appointments_insert_public" ON appointments FOR INSERT WITH CHECK (true);
-- ══════════════════════════════════════════════════════════════
-- PHASE 2b — Telegram Campaigns
-- ══════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS campaigns (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
chatbot_id UUID NOT NULL REFERENCES chatbots(id) ON DELETE CASCADE,
title TEXT NOT NULL,
message TEXT NOT NULL,
status VARCHAR(20) DEFAULT 'draft' CHECK (status IN ('draft','sending','sent','failed')),
recipients_count INTEGER DEFAULT 0,
sent_count INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
sent_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_campaigns_chatbot ON campaigns(chatbot_id);
ALTER TABLE campaigns ENABLE ROW LEVEL SECURITY;
CREATE POLICY "campaigns_owner" ON campaigns FOR ALL USING (
chatbot_id IN (
SELECT c.id FROM chatbots c
JOIN companies co ON c.company_id = co.id
WHERE co.owner_id = auth.uid()
)
);

0
tests/__init__.py Normal file
View File

77
tests/conftest.py Normal file
View File

@@ -0,0 +1,77 @@
"""
Test fixtures for Contexta backend.
Uses unittest.mock to avoid hitting real Supabase/Qdrant in unit tests.
"""
import pytest
from unittest.mock import MagicMock, patch, AsyncMock
from fastapi.testclient import TestClient
@pytest.fixture
def client():
"""FastAPI test client. Patches Supabase and Qdrant at module level."""
with patch("app.database.get_supabase") as mock_sb, \
patch("app.services.vector_store.vector_store") as mock_vs:
mock_sb.return_value = _make_supabase_mock()
mock_vs.create_collection = MagicMock()
mock_vs.delete_collection = MagicMock()
mock_vs.search = MagicMock(return_value=[])
from app.main import app
yield TestClient(app)
@pytest.fixture
def supabase_mock():
"""Standalone Supabase mock for direct use."""
return _make_supabase_mock()
@pytest.fixture
def auth_headers():
"""Mock auth headers (token is verified by patching get_current_user)."""
return {"Authorization": "Bearer test-token"}
@pytest.fixture
def mock_current_user():
"""A mock Supabase auth user object."""
user = MagicMock()
user.id = "test-user-id"
user.email = "test@example.com"
return user
@pytest.fixture
def mock_admin_user():
"""A mock admin auth user object."""
user = MagicMock()
user.id = "admin-user-id"
user.email = "admin@example.com"
return user
def _make_supabase_mock():
"""Build a chainable Supabase client mock."""
supabase = MagicMock()
# Default table chain returns empty data
table_mock = MagicMock()
table_mock.select.return_value = table_mock
table_mock.insert.return_value = table_mock
table_mock.update.return_value = table_mock
table_mock.delete.return_value = table_mock
table_mock.upsert.return_value = table_mock
table_mock.eq.return_value = table_mock
table_mock.in_.return_value = table_mock
table_mock.limit.return_value = table_mock
table_mock.order.return_value = table_mock
table_mock.range.return_value = table_mock
table_mock.gte.return_value = table_mock
table_mock.lt.return_value = table_mock
table_mock.execute.return_value = MagicMock(data=[], count=0)
supabase.table.return_value = table_mock
supabase.auth = MagicMock()
return supabase

94
tests/test_admin.py Normal file
View File

@@ -0,0 +1,94 @@
"""Tests for admin endpoints — access control and basic structure."""
import pytest
from unittest.mock import MagicMock, patch
class TestAdminAccessControl:
"""Admin endpoints must return 401 without auth and 403 for non-admin users."""
ADMIN_ENDPOINTS = [
("GET", "/api/v1/admin/stats"),
("GET", "/api/v1/admin/users"),
("GET", "/api/v1/admin/chatbots"),
("GET", "/api/v1/admin/conversations"),
("GET", "/api/v1/admin/system/health"),
]
def test_admin_endpoints_require_auth(self, client):
for method, path in self.ADMIN_ENDPOINTS:
resp = client.request(method, path)
assert resp.status_code == 401, f"{method} {path} should require auth, got {resp.status_code}"
def test_non_admin_user_gets_403(self, client):
"""Authenticated user without is_admin flag should get 403."""
user = MagicMock()
user.id = "normal-user-id"
user.email = "user@example.com"
with patch("app.dependencies.get_supabase") as mock_sb:
sb = MagicMock()
# get_current_user: no suspension
sb.table.return_value.select.return_value.eq.return_value.execute.return_value = MagicMock(
data=[{"suspended_at": None, "is_admin": False}]
)
mock_sb.return_value = sb
with patch("app.dependencies.security") as mock_sec:
creds = MagicMock()
creds.credentials = "valid-token"
mock_sec.return_value = creds
with patch("app.database.get_supabase") as mock_db_sb:
db_sb = MagicMock()
db_sb.auth.get_user.return_value = MagicMock(user=user)
# Profile: not admin, not suspended
db_sb.table.return_value.select.return_value.eq.return_value.execute.return_value = MagicMock(
data=[{"is_admin": False, "suspended_at": None}]
)
mock_db_sb.return_value = db_sb
resp = client.get(
"/api/v1/admin/stats",
headers={"Authorization": "Bearer valid-token"},
)
assert resp.status_code in (401, 403)
class TestAdminModels:
def test_admin_stats_response_shape(self):
from app.models import AdminStatsResponse
stats = AdminStatsResponse(
total_users=10,
total_chatbots=5,
total_published_chatbots=3,
total_conversations=100,
total_messages=500,
active_subscriptions={"free": 8, "starter": 2},
)
assert stats.total_users == 10
assert stats.active_subscriptions["free"] == 8
def test_admin_user_list_item_defaults(self):
from app.models import AdminUserListItem
item = AdminUserListItem(id="id", email="test@example.com")
assert item.plan == "free"
assert item.is_admin == False
assert item.is_suspended == False
def test_admin_system_health_shape(self):
from app.models import AdminSystemHealth
from datetime import datetime
health = AdminSystemHealth(
db="healthy",
qdrant="healthy",
llm_providers={"openai": True, "anthropic": False},
timestamp=datetime.utcnow(),
)
assert health.db == "healthy"
assert health.llm_providers["openai"] == True
def test_change_plan_request_validates(self):
from app.models import AdminChangePlanRequest
req = AdminChangePlanRequest(plan="business", reason="Testing")
assert req.plan == "business"

255
tests/test_analytics.py Normal file
View File

@@ -0,0 +1,255 @@
"""Tests for analytics endpoints."""
import pytest
from unittest.mock import MagicMock, patch
AUTH = {"Authorization": "Bearer test-token"}
def _make_sb_with_plan(plan: str):
sb = MagicMock()
m = MagicMock()
m.select.return_value = m
m.insert.return_value = m
m.update.return_value = m
m.delete.return_value = m
m.eq.return_value = m
m.in_.return_value = m
m.limit.return_value = m
m.order.return_value = m
m.range.return_value = m
m.gte.return_value = m
m.lt.return_value = m
m.execute.return_value = MagicMock(data=[{"plan": plan}], count=0)
sb.table.return_value = m
sb.auth = MagicMock()
return sb
def _make_starter_sb(company_data=None, chatbot_data=None):
"""Starter plan supabase mock with configurable data."""
sb = MagicMock()
def table_side_effect(name):
m = MagicMock()
m.select.return_value = m
m.insert.return_value = m
m.update.return_value = m
m.delete.return_value = m
m.eq.return_value = m
m.in_.return_value = m
m.limit.return_value = m
m.order.return_value = m
m.range.return_value = m
m.gte.return_value = m
m.lt.return_value = m
if name == "subscriptions":
m.execute.return_value = MagicMock(data=[{"plan": "starter"}], count=0)
elif name == "companies":
m.execute.return_value = MagicMock(
data=company_data if company_data is not None else [{"id": "company-1"}],
count=0,
)
elif name == "chatbots":
m.execute.return_value = MagicMock(
data=chatbot_data if chatbot_data is not None else [],
count=0,
)
elif name == "conversations":
m.execute.return_value = MagicMock(data=[], count=0)
elif name == "messages":
m.execute.return_value = MagicMock(data=[], count=0)
elif name == "message_feedback":
m.execute.return_value = MagicMock(data=[], count=0)
else:
m.execute.return_value = MagicMock(data=[], count=0)
return m
sb.table.side_effect = table_side_effect
sb.auth = MagicMock()
return sb
class TestAnalyticsAuth:
def test_overview_requires_auth(self, client):
resp = client.get("/api/v1/analytics/overview")
assert resp.status_code == 401
def test_chatbot_detail_requires_auth(self, client):
resp = client.get("/api/v1/analytics/chatbot/some-id")
assert resp.status_code == 401
def test_gaps_requires_auth(self, client):
resp = client.get("/api/v1/analytics/chatbot/some-id/gaps")
assert resp.status_code == 401
class TestAnalyticsPlanGating:
def test_free_plan_blocked_on_overview(self, client):
with patch("app.routers.analytics.get_supabase") as mock_sb:
mock_sb.return_value = _make_sb_with_plan("free")
resp = client.get("/api/v1/analytics/overview", headers=AUTH)
assert resp.status_code == 402
assert "Starter" in resp.json()["detail"]
def test_free_plan_blocked_on_chatbot_detail(self, client):
with patch("app.routers.analytics.get_supabase") as mock_sb:
mock_sb.return_value = _make_sb_with_plan("free")
resp = client.get("/api/v1/analytics/chatbot/some-id", headers=AUTH)
assert resp.status_code == 402
def test_free_plan_blocked_on_gaps(self, client):
with patch("app.routers.analytics.get_supabase") as mock_sb:
mock_sb.return_value = _make_sb_with_plan("free")
resp = client.get("/api/v1/analytics/chatbot/some-id/gaps", headers=AUTH)
assert resp.status_code == 402
class TestAnalyticsOverview:
def test_overview_no_company_returns_404(self, client):
with patch("app.routers.analytics.get_supabase") as mock_sb:
mock_sb.return_value = _make_starter_sb(company_data=[])
resp = client.get("/api/v1/analytics/overview", headers=AUTH)
assert resp.status_code == 404
def test_overview_no_chatbots_returns_empty(self, client):
with patch("app.routers.analytics.get_supabase") as mock_sb:
mock_sb.return_value = _make_starter_sb(chatbot_data=[])
resp = client.get("/api/v1/analytics/overview", headers=AUTH)
assert resp.status_code == 200
body = resp.json()
assert body["total_chatbots"] == 0
assert body["total_conversations"] == 0
assert body["chatbots"] == []
assert body["plan"] == "starter"
def test_overview_with_chatbots(self, client):
chatbot_data = [{"id": "cb-1", "name": "Bot One", "is_published": True, "average_rating": 4.5}]
with patch("app.routers.analytics.get_supabase") as mock_sb:
mock_sb.return_value = _make_starter_sb(chatbot_data=chatbot_data)
resp = client.get("/api/v1/analytics/overview", headers=AUTH)
assert resp.status_code == 200
body = resp.json()
assert body["total_chatbots"] == 1
assert body["published_chatbots"] == 1
assert len(body["chatbots"]) == 1
assert body["chatbots"][0]["chatbot_name"] == "Bot One"
class TestAnalyticsChatbotDetail:
def test_chatbot_not_found_returns_404(self, client):
def table_side(name):
m = MagicMock()
m.select.return_value = m
m.eq.return_value = m
m.in_.return_value = m
m.limit.return_value = m
m.order.return_value = m
m.lt.return_value = m
if name == "subscriptions":
m.execute.return_value = MagicMock(data=[{"plan": "starter"}])
elif name == "companies":
m.execute.return_value = MagicMock(data=[{"id": "comp-1"}])
elif name == "chatbots":
m.execute.return_value = MagicMock(data=[])
else:
m.execute.return_value = MagicMock(data=[], count=0)
return m
sb = MagicMock()
sb.table.side_effect = table_side
sb.auth = MagicMock()
with patch("app.routers.analytics.get_supabase", return_value=sb):
resp = client.get("/api/v1/analytics/chatbot/nonexistent-id", headers=AUTH)
assert resp.status_code == 404
def test_chatbot_detail_success(self, client):
def table_side(name):
m = MagicMock()
m.select.return_value = m
m.eq.return_value = m
m.in_.return_value = m
m.limit.return_value = m
m.order.return_value = m
m.lt.return_value = m
if name == "subscriptions":
m.execute.return_value = MagicMock(data=[{"plan": "starter"}])
elif name == "companies":
m.execute.return_value = MagicMock(data=[{"id": "comp-1"}])
elif name == "chatbots":
m.execute.return_value = MagicMock(data=[{"id": "cb-1", "name": "Bot", "average_rating": 4.0}])
elif name == "conversations":
m.execute.return_value = MagicMock(data=[], count=0)
elif name == "messages":
m.execute.return_value = MagicMock(data=[], count=0)
elif name == "message_feedback":
m.execute.return_value = MagicMock(data=[], count=0)
else:
m.execute.return_value = MagicMock(data=[], count=0)
return m
sb = MagicMock()
sb.table.side_effect = table_side
sb.auth = MagicMock()
with patch("app.routers.analytics.get_supabase", return_value=sb):
resp = client.get("/api/v1/analytics/chatbot/cb-1", headers=AUTH)
assert resp.status_code == 200
body = resp.json()
assert body["chatbot_name"] == "Bot"
class TestAnalyticsGaps:
def test_gaps_no_company_returns_404(self, client):
def table_side(name):
m = MagicMock()
m.select.return_value = m
m.eq.return_value = m
m.in_.return_value = m
m.limit.return_value = m
m.order.return_value = m
m.lt.return_value = m
if name == "subscriptions":
m.execute.return_value = MagicMock(data=[{"plan": "starter"}])
else:
m.execute.return_value = MagicMock(data=[])
return m
sb = MagicMock()
sb.table.side_effect = table_side
sb.auth = MagicMock()
with patch("app.routers.analytics.get_supabase", return_value=sb):
resp = client.get("/api/v1/analytics/chatbot/some-id/gaps", headers=AUTH)
assert resp.status_code == 404
def test_gaps_no_conversations_returns_empty(self, client):
def table_side(name):
m = MagicMock()
m.select.return_value = m
m.eq.return_value = m
m.in_.return_value = m
m.limit.return_value = m
m.order.return_value = m
m.lt.return_value = m
if name == "subscriptions":
m.execute.return_value = MagicMock(data=[{"plan": "starter"}])
elif name == "companies":
m.execute.return_value = MagicMock(data=[{"id": "comp-1"}])
elif name == "chatbots":
m.execute.return_value = MagicMock(data=[{"id": "cb-1"}])
else:
m.execute.return_value = MagicMock(data=[], count=0)
return m
sb = MagicMock()
sb.table.side_effect = table_side
sb.auth = MagicMock()
with patch("app.routers.analytics.get_supabase", return_value=sb):
resp = client.get("/api/v1/analytics/chatbot/cb-1/gaps", headers=AUTH)
assert resp.status_code == 200
assert resp.json() == []

554
tests/test_appointments.py Normal file
View File

@@ -0,0 +1,554 @@
"""
Tests for appointment endpoints:
GET /api/v1/appointments
PATCH /api/v1/appointments/{id}
GET /api/v1/appointments/chatbot/{chatbot_id}/hours
PUT /api/v1/appointments/chatbot/{chatbot_id}/hours
GET /api/v1/chatbots/{chatbot_id}/booking-info (public)
GET /api/v1/chatbots/{chatbot_id}/available-slots (public)
POST /api/v1/chatbots/{chatbot_id}/appointments (public)
"""
import pytest
from unittest.mock import MagicMock, patch
from datetime import datetime, timedelta, date
# ── Helpers ────────────────────────────────────────────────────────────────────
def make_user(uid="user-1"):
u = MagicMock()
u.id = uid
u.email = "user@example.com"
return u
SAMPLE_APPT = {
"id": "appt-1",
"chatbot_id": "chatbot-1",
"conversation_id": None,
"customer_name": "Alice",
"customer_contact": "alice@example.com",
"service": "Consultation",
"slot_start": "2099-06-10T09:00:00",
"slot_end": "2099-06-10T10:00:00",
"status": "pending",
"notes": None,
"created_at": "2024-01-01T00:00:00",
}
SAMPLE_HOURS = {
"id": "bh-1",
"chatbot_id": "chatbot-1",
"day_of_week": 0, # Monday
"is_open": True,
"open_time": "09:00",
"close_time": "17:00",
"slot_duration_minutes": 60,
}
def make_supabase(plan="starter", company_id="company-1",
appointments=None, appointment=None,
hours=None, chatbot_booking=True):
sb = MagicMock()
def table_side(name):
t = MagicMock()
for m in ("select", "insert", "update", "delete", "eq", "neq",
"in_", "order", "range", "limit", "gte", "lt"):
getattr(t, m).return_value = t
if name == "subscriptions":
t.execute = MagicMock(return_value=MagicMock(data=[{"plan": plan}]))
elif name == "companies":
t.execute = MagicMock(return_value=MagicMock(data=[{"id": company_id}]))
elif name == "chatbots":
t.execute = MagicMock(return_value=MagicMock(data=[{
"id": "chatbot-1", "company_id": company_id,
"booking_enabled": chatbot_booking,
"is_published": True,
}]))
elif name == "appointments":
if appointment is not None:
t.execute = MagicMock(return_value=MagicMock(data=[appointment]))
else:
t.execute = MagicMock(return_value=MagicMock(
data=appointments if appointments is not None else [SAMPLE_APPT]
))
elif name == "business_hours":
rows = hours if hours is not None else [SAMPLE_HOURS]
t.execute = MagicMock(return_value=MagicMock(data=rows))
else:
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb.table.side_effect = table_side
return sb
# ── Tests: list appointments ───────────────────────────────────────────────────
class TestListAppointments:
def test_requires_auth(self, client):
resp = client.get("/api/v1/appointments")
assert resp.status_code == 401
def test_free_plan_returns_402(self, client):
user = make_user()
sb = make_supabase(plan="free")
with patch("app.routers.appointments.get_current_user", return_value=user), \
patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.get("/api/v1/appointments",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 402
def test_returns_appointment_list(self, client):
user = make_user()
sb = make_supabase(plan="starter")
with patch("app.routers.appointments.get_current_user", return_value=user), \
patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.get("/api/v1/appointments",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["customer_name"] == "Alice"
assert data[0]["status"] == "pending"
def test_returns_empty_when_no_chatbots(self, client):
user = make_user()
sb = make_supabase(plan="starter")
original = sb.table.side_effect
def patched(name):
t = original(name)
if name == "chatbots":
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb.table.side_effect = patched
with patch("app.routers.appointments.get_current_user", return_value=user), \
patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.get("/api/v1/appointments",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
assert resp.json() == []
# ── Tests: update appointment status ──────────────────────────────────────────
class TestUpdateAppointmentStatus:
def _owned_appt(self, company_id="company-1", status="pending"):
return {**SAMPLE_APPT, "status": status,
"chatbots": {"company_id": company_id}}
def test_requires_auth(self, client):
resp = client.patch("/api/v1/appointments/appt-1",
json={"status": "confirmed"})
assert resp.status_code == 401
def test_valid_status_transitions(self, client):
for new_status in ("pending", "confirmed", "cancelled", "completed"):
user = make_user()
appt = self._owned_appt()
updated = {**SAMPLE_APPT, "status": new_status}
call_count = {"n": 0}
def table_side(name, _ns=new_status):
t = MagicMock()
for m in ("select", "insert", "update", "delete",
"eq", "neq", "in_", "order", "range", "limit"):
getattr(t, m).return_value = t
if name == "subscriptions":
t.execute = MagicMock(return_value=MagicMock(data=[{"plan": "starter"}]))
elif name == "companies":
t.execute = MagicMock(return_value=MagicMock(data=[{"id": "company-1"}]))
elif name == "appointments":
call_count["n"] += 1
if call_count["n"] == 1:
t.execute = MagicMock(return_value=MagicMock(data=[appt]))
else:
t.execute = MagicMock(return_value=MagicMock(
data=[{**SAMPLE_APPT, "status": _ns}]))
elif name == "chatbots":
t.execute = MagicMock(return_value=MagicMock(data=[
{"id": "chatbot-1", "company_id": "company-1"}
]))
else:
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb = MagicMock()
sb.table.side_effect = table_side
with patch("app.routers.appointments.get_current_user", return_value=user), \
patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.patch("/api/v1/appointments/appt-1",
json={"status": new_status},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200, f"Failed for status={new_status}"
def test_invalid_status_returns_400(self, client):
user = make_user()
appt = self._owned_appt()
sb = make_supabase(plan="starter", appointment=appt)
with patch("app.routers.appointments.get_current_user", return_value=user), \
patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.patch("/api/v1/appointments/appt-1",
json={"status": "flying"},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 400
def test_403_for_other_companys_appointment(self, client):
user = make_user()
appt = self._owned_appt(company_id="company-OTHER")
sb = make_supabase(plan="starter", company_id="company-1", appointment=appt)
with patch("app.routers.appointments.get_current_user", return_value=user), \
patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.patch("/api/v1/appointments/appt-1",
json={"status": "confirmed"},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 403
def test_404_when_not_found(self, client):
user = make_user()
sb = make_supabase(plan="starter")
original = sb.table.side_effect
def patched(name):
t = original(name)
if name == "appointments":
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb.table.side_effect = patched
with patch("app.routers.appointments.get_current_user", return_value=user), \
patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.patch("/api/v1/appointments/nonexistent",
json={"status": "confirmed"},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 404
# ── Tests: business hours ──────────────────────────────────────────────────────
class TestBusinessHours:
def test_get_hours_requires_auth(self, client):
resp = client.get("/api/v1/appointments/chatbot/chatbot-1/hours")
assert resp.status_code == 401
def test_get_hours_returns_list(self, client):
user = make_user()
sb = make_supabase(plan="starter")
with patch("app.routers.appointments.get_current_user", return_value=user), \
patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.get("/api/v1/appointments/chatbot/chatbot-1/hours",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
assert data[0]["day_of_week"] == 0
assert data[0]["open_time"] == "09:00"
def test_save_hours_requires_auth(self, client):
resp = client.put("/api/v1/appointments/chatbot/chatbot-1/hours",
json={"hours": []})
assert resp.status_code == 401
def test_save_hours_success(self, client):
user = make_user()
sb = make_supabase(plan="starter")
hours_payload = [
{"day_of_week": 0, "is_open": True, "open_time": "09:00",
"close_time": "17:00", "slot_duration_minutes": 60},
{"day_of_week": 6, "is_open": False, "open_time": "09:00",
"close_time": "17:00", "slot_duration_minutes": 60},
]
with patch("app.routers.appointments.get_current_user", return_value=user), \
patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.put("/api/v1/appointments/chatbot/chatbot-1/hours",
json={"hours": hours_payload},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
assert resp.json()["success"] is True
def test_save_hours_free_plan_returns_402(self, client):
user = make_user()
sb = make_supabase(plan="free")
with patch("app.routers.appointments.get_current_user", return_value=user), \
patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.put("/api/v1/appointments/chatbot/chatbot-1/hours",
json={"hours": []},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 402
def test_save_hours_inserts_when_not_exists(self, client):
"""When no existing row, should INSERT."""
user = make_user()
sb = make_supabase(plan="starter", hours=[]) # no existing hours
insert_calls = []
original = sb.table.side_effect
def patched(name):
t = original(name)
if name == "business_hours":
orig_insert = t.insert
def track_insert(data):
insert_calls.append(data)
return t
t.insert = track_insert
return t
sb.table.side_effect = patched
with patch("app.routers.appointments.get_current_user", return_value=user), \
patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.put("/api/v1/appointments/chatbot/chatbot-1/hours",
json={"hours": [
{"day_of_week": 1, "is_open": True,
"open_time": "08:00", "close_time": "16:00",
"slot_duration_minutes": 30}
]},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
assert len(insert_calls) == 1
assert insert_calls[0]["day_of_week"] == 1
assert "id" in insert_calls[0]
# ── Tests: public booking-info ─────────────────────────────────────────────────
class TestPublicBookingInfo:
def test_returns_booking_info(self, client):
sb = make_supabase(chatbot_booking=True)
original = sb.table.side_effect
def patched(name):
t = original(name)
if name == "chatbots":
t.execute = MagicMock(return_value=MagicMock(data=[{
"id": "chatbot-1",
"name": "Support Bot",
"booking_enabled": True,
"companies": {"name": "ACME Corp"},
}]))
return t
sb.table.side_effect = patched
with patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.get("/api/v1/chatbots/chatbot-1/booking-info")
assert resp.status_code == 200
body = resp.json()
assert body["chatbot_name"] == "Support Bot"
assert body["company_name"] == "ACME Corp"
assert body["chatbot_id"] == "chatbot-1"
def test_404_when_chatbot_not_found(self, client):
sb = make_supabase()
original = sb.table.side_effect
def patched(name):
t = original(name)
if name == "chatbots":
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb.table.side_effect = patched
with patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.get("/api/v1/chatbots/nonexistent/booking-info")
assert resp.status_code == 404
def test_400_when_booking_disabled(self, client):
sb = make_supabase(chatbot_booking=False)
original = sb.table.side_effect
def patched(name):
t = original(name)
if name == "chatbots":
t.execute = MagicMock(return_value=MagicMock(data=[{
"id": "chatbot-1", "name": "Bot", "booking_enabled": False,
"companies": {"name": "ACME"},
}]))
return t
sb.table.side_effect = patched
with patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.get("/api/v1/chatbots/chatbot-1/booking-info")
assert resp.status_code == 400
# ── Tests: public available-slots ─────────────────────────────────────────────
class TestAvailableSlots:
def test_returns_slots_for_open_day(self, client):
sb = make_supabase(chatbot_booking=True, hours=[SAMPLE_HOURS])
# No booked appointments
original = sb.table.side_effect
def patched(name):
t = original(name)
if name == "appointments":
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb.table.side_effect = patched
# Use a far-future Monday so none are "past"
future_monday = "2099-06-09" # a Monday
with patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.get(f"/api/v1/chatbots/chatbot-1/available-slots?date={future_monday}")
assert resp.status_code == 200
body = resp.json()
assert body["date"] == future_monday
# 09:00-17:00 with 60-min slots = 8 slots
assert len(body["slots"]) == 8
def test_returns_empty_for_closed_day(self, client):
closed_hours = {**SAMPLE_HOURS, "is_open": False}
sb = make_supabase(chatbot_booking=True, hours=[closed_hours])
with patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.get("/api/v1/chatbots/chatbot-1/available-slots?date=2099-06-09")
assert resp.status_code == 200
assert resp.json()["slots"] == []
def test_returns_empty_when_no_hours_configured(self, client):
sb = make_supabase(chatbot_booking=True, hours=[])
with patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.get("/api/v1/chatbots/chatbot-1/available-slots?date=2099-06-09")
assert resp.status_code == 200
assert resp.json()["slots"] == []
def test_booked_slots_excluded(self, client):
"""A slot that is already booked should not appear."""
sb = make_supabase(chatbot_booking=True, hours=[SAMPLE_HOURS])
original = sb.table.side_effect
def patched(name):
t = original(name)
if name == "appointments":
# 09:00 is already booked
t.execute = MagicMock(return_value=MagicMock(data=[
{"slot_start": "2099-06-09T09:00:00",
"slot_end": "2099-06-09T10:00:00"}
]))
return t
sb.table.side_effect = patched
with patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.get("/api/v1/chatbots/chatbot-1/available-slots?date=2099-06-09")
assert resp.status_code == 200
slots = resp.json()["slots"]
starts = [s["slot_start"] for s in slots]
assert not any("T09:00:00" in s for s in starts)
def test_invalid_date_format_returns_400(self, client):
sb = make_supabase(chatbot_booking=True)
with patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.get("/api/v1/chatbots/chatbot-1/available-slots?date=not-a-date")
assert resp.status_code == 400
def test_booking_disabled_returns_400(self, client):
sb = make_supabase(chatbot_booking=False)
original = sb.table.side_effect
def patched(name):
t = original(name)
if name == "chatbots":
t.execute = MagicMock(return_value=MagicMock(data=[
{"id": "chatbot-1", "booking_enabled": False, "is_published": True}
]))
return t
sb.table.side_effect = patched
with patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.get("/api/v1/chatbots/chatbot-1/available-slots?date=2099-06-09")
assert resp.status_code == 400
def test_30_min_slots_yield_correct_count(self, client):
"""09:00-11:00 with 30-min slots should yield 4 slots."""
short_hours = {**SAMPLE_HOURS, "close_time": "11:00", "slot_duration_minutes": 30}
sb = make_supabase(chatbot_booking=True, hours=[short_hours])
original = sb.table.side_effect
def patched(name):
t = original(name)
if name == "appointments":
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb.table.side_effect = patched
with patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.get("/api/v1/chatbots/chatbot-1/available-slots?date=2099-06-09")
assert len(resp.json()["slots"]) == 4
# ── Tests: public create appointment ──────────────────────────────────────────
class TestCreateAppointment:
def _sb_with_open_slot(self, slot_start="2099-06-09T09:00:00"):
"""Supabase mock that returns one available slot matching slot_start."""
insert_result = {**SAMPLE_APPT, "slot_start": slot_start}
call_count = {"n": 0}
sb = MagicMock()
def table_side(name):
t = MagicMock()
for m in ("select", "insert", "update", "delete", "eq", "neq",
"in_", "order", "range", "limit", "gte", "lt"):
getattr(t, m).return_value = t
if name == "chatbots":
t.execute = MagicMock(return_value=MagicMock(data=[{
"id": "chatbot-1", "booking_enabled": True, "is_published": True,
}]))
elif name == "business_hours":
t.execute = MagicMock(return_value=MagicMock(data=[SAMPLE_HOURS]))
elif name == "appointments":
call_count["n"] += 1
if call_count["n"] == 1:
# _get_available_slots: booked slots check — nothing booked
t.execute = MagicMock(return_value=MagicMock(data=[]))
else:
# The actual INSERT
t.execute = MagicMock(return_value=MagicMock(data=[insert_result]))
else:
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb.table.side_effect = table_side
return sb
def test_creates_appointment_successfully(self, client):
sb = self._sb_with_open_slot()
with patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.post("/api/v1/chatbots/chatbot-1/appointments", json={
"customer_name": "Alice",
"customer_contact": "alice@example.com",
"slot_start": "2099-06-09T09:00:00",
"service": "Consultation",
})
assert resp.status_code == 201
assert resp.json()["customer_name"] == "Alice"
assert resp.json()["status"] == "pending"
def test_missing_required_fields_returns_422(self, client):
sb = make_supabase(chatbot_booking=True)
with patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.post("/api/v1/chatbots/chatbot-1/appointments",
json={"customer_name": "Alice"}) # missing contact + slot
assert resp.status_code == 422
def test_404_when_chatbot_not_found(self, client):
sb = make_supabase()
original = sb.table.side_effect
def patched(name):
t = original(name)
if name == "chatbots":
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb.table.side_effect = patched
with patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.post("/api/v1/chatbots/nonexistent/appointments", json={
"customer_name": "Alice",
"customer_contact": "alice@example.com",
"slot_start": "2099-06-09T09:00:00",
})
assert resp.status_code == 404
def test_409_when_slot_already_taken(self, client):
"""Slot is marked as booked, so should return 409 Conflict."""
sb = make_supabase(chatbot_booking=True, hours=[SAMPLE_HOURS])
original = sb.table.side_effect
def patched(name):
t = original(name)
if name == "appointments":
# 09:00 already booked
t.execute = MagicMock(return_value=MagicMock(data=[
{"slot_start": "2099-06-09T09:00:00",
"slot_end": "2099-06-09T10:00:00"}
]))
return t
sb.table.side_effect = patched
with patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.post("/api/v1/chatbots/chatbot-1/appointments", json={
"customer_name": "Alice",
"customer_contact": "alice@example.com",
"slot_start": "2099-06-09T09:00:00",
})
assert resp.status_code == 409

119
tests/test_auth.py Normal file
View File

@@ -0,0 +1,119 @@
"""Tests for authentication endpoints."""
import pytest
from unittest.mock import MagicMock, patch
def make_auth_user(user_id="user-123", email="test@example.com"):
user = MagicMock()
user.id = user_id
user.email = email
return user
def make_session(token="test-access-token"):
session = MagicMock()
session.access_token = token
return session
class TestSignup:
def test_signup_returns_401_without_body(self, client):
resp = client.post("/api/v1/auth/signup")
assert resp.status_code == 422 # validation error
def test_signup_success(self, client):
user = make_auth_user()
session = make_session()
with patch("app.routers.auth.get_supabase") as mock_sb:
sb = MagicMock()
auth_resp = MagicMock()
auth_resp.user = user
auth_resp.session = session
sb.auth.sign_up.return_value = auth_resp
sb.table.return_value.insert.return_value.execute.return_value = MagicMock(data=[{}])
sb.table.return_value.select.return_value.eq.return_value.execute.return_value = MagicMock(data=[])
mock_sb.return_value = sb
resp = client.post("/api/v1/auth/signup", json={
"email": "new@example.com",
"password": "password123",
"company_name": "Test Corp",
})
assert resp.status_code == 200
data = resp.json()
assert "access_token" in data
assert data["user"]["email"] == "test@example.com"
assert data["user"]["plan"] == "free"
assert data["user"]["is_admin"] == False
class TestLogin:
def test_login_returns_422_without_body(self, client):
resp = client.post("/api/v1/auth/login")
assert resp.status_code == 422
def test_login_success(self, client):
user = make_auth_user()
session = make_session()
with patch("app.routers.auth.get_supabase") as mock_sb:
sb = MagicMock()
auth_resp = MagicMock()
auth_resp.user = user
auth_resp.session = session
sb.auth.sign_in_with_password.return_value = auth_resp
# company query
comp_exec = MagicMock(data=[{"name": "Test Corp"}])
# subscription query
sub_exec = MagicMock(data=[{"plan": "starter"}])
# profile query
profile_exec = MagicMock(data=[{"is_admin": False}])
# Chain: table().select().eq().eq().execute()
def table_side(name):
t = MagicMock()
t.select.return_value = t
t.eq.return_value = t
if name == "companies":
t.execute.return_value = comp_exec
elif name == "subscriptions":
t.execute.return_value = sub_exec
elif name == "user_profiles":
t.execute.return_value = profile_exec
else:
t.execute.return_value = MagicMock(data=[])
return t
sb.table.side_effect = table_side
mock_sb.return_value = sb
resp = client.post("/api/v1/auth/login", json={
"email": "test@example.com",
"password": "password123",
})
assert resp.status_code == 200
data = resp.json()
assert data["access_token"] == "test-access-token"
assert data["user"]["plan"] == "starter"
class TestMe:
def test_me_returns_401_without_auth(self, client):
resp = client.get("/api/v1/auth/me")
assert resp.status_code == 401
def test_forgot_password_always_returns_200(self, client):
with patch("app.routers.auth.get_supabase") as mock_sb:
sb = MagicMock()
sb.auth.reset_password_for_email.return_value = None
mock_sb.return_value = sb
resp = client.post("/api/v1/auth/forgot-password", json={"email": "any@example.com"})
assert resp.status_code == 200
assert "message" in resp.json()

81
tests/test_billing.py Normal file
View File

@@ -0,0 +1,81 @@
"""Tests for billing webhook idempotency and Stripe integration."""
import pytest
import json
from unittest.mock import MagicMock, patch
class TestStripeWebhookIdempotency:
def test_duplicate_event_returns_200_without_processing(self, client):
"""Same Stripe event ID sent twice should only process once."""
event_id = "evt_test_123"
payload = json.dumps({
"id": event_id,
"type": "checkout.session.completed",
"data": {"object": {"metadata": {"user_id": "user-123", "plan": "starter"}, "customer": "cus_123", "subscription": "sub_123"}},
}).encode()
with patch("app.routers.billing.get_supabase") as mock_sb, \
patch("app.routers.billing.settings") as mock_settings:
mock_settings.stripe_webhook_secret = ""
mock_settings.stripe_secret_key = "sk_test_123"
mock_settings.app_env = "development"
mock_settings.n8n_handoff_webhook_url = None
sb = MagicMock()
# First call: event not found
first_check = MagicMock(data=[])
# Second call: event found (already processed)
second_check = MagicMock(data=[{"stripe_event_id": event_id}])
call_count = 0
def table_exec(*args, **kwargs):
return MagicMock(data=[])
sb.table.return_value.select.return_value.eq.return_value.execute.side_effect = [
first_check, # first idempotency check → not found
MagicMock(data=[]), # subscription upsert check
MagicMock(data=[]), # insert event record
]
sb.table.return_value.upsert.return_value.execute.return_value = MagicMock(data=[{}])
sb.table.return_value.insert.return_value.execute.return_value = MagicMock(data=[{}])
mock_sb.return_value = sb
# First request
resp1 = client.post(
"/api/v1/billing/webhook",
content=payload,
headers={"content-type": "application/json"},
)
assert resp1.status_code == 200
# Reset mock: now event IS found
sb.table.return_value.select.return_value.eq.return_value.execute.return_value = second_check
# Second request with same event ID
resp2 = client.post(
"/api/v1/billing/webhook",
content=payload,
headers={"content-type": "application/json"},
)
assert resp2.status_code == 200
assert resp2.json() == {"received": True}
def test_webhook_requires_stripe_signature_in_production(self, client):
"""In production, webhook without signature should fail."""
payload = json.dumps({"id": "evt_123", "type": "test"}).encode()
with patch("app.routers.billing.settings") as mock_settings:
mock_settings.stripe_webhook_secret = "whsec_real_secret"
mock_settings.stripe_secret_key = "sk_live_123"
mock_settings.app_env = "production"
resp = client.post(
"/api/v1/billing/webhook",
content=payload,
headers={"content-type": "application/json"},
# No stripe-signature header
)
# Should fail due to missing signature
assert resp.status_code in (400, 500)

453
tests/test_campaigns.py Normal file
View File

@@ -0,0 +1,453 @@
"""
Tests for campaign endpoints:
GET /api/v1/campaigns
POST /api/v1/campaigns
POST /api/v1/campaigns/{id}/send
DELETE /api/v1/campaigns/{id}
"""
import pytest
from unittest.mock import MagicMock, patch, AsyncMock
# ── Helpers ────────────────────────────────────────────────────────────────────
def make_user(uid="user-1"):
u = MagicMock()
u.id = uid
u.email = "user@example.com"
return u
SAMPLE_CAMPAIGN = {
"id": "camp-1",
"chatbot_id": "chatbot-1",
"title": "Summer Sale",
"message": "Big discount today!",
"status": "draft",
"recipients_count": 10,
"sent_count": 0,
"created_at": "2024-01-01T00:00:00",
"sent_at": None,
}
def make_supabase(plan="starter", company_id="company-1",
campaigns=None, campaign=None,
subscribers_count=10):
sb = MagicMock()
def table_side(name):
t = MagicMock()
for m in ("select", "insert", "update", "delete", "eq",
"in_", "order", "range", "limit", "neq"):
getattr(t, m).return_value = t
t.count = subscribers_count
if name == "subscriptions":
t.execute = MagicMock(return_value=MagicMock(data=[{"plan": plan}]))
elif name == "companies":
t.execute = MagicMock(return_value=MagicMock(data=[{"id": company_id}]))
elif name == "chatbots":
t.execute = MagicMock(return_value=MagicMock(data=[
{"id": "chatbot-1", "company_id": company_id}
]))
elif name == "campaigns":
if campaign is not None:
t.execute = MagicMock(return_value=MagicMock(data=[campaign]))
else:
t.execute = MagicMock(return_value=MagicMock(
data=campaigns if campaigns is not None else [SAMPLE_CAMPAIGN]
))
elif name == "channel_connections":
t.execute = MagicMock(return_value=MagicMock(data=[
{"bot_token": "123456:ABCdef"}
]))
elif name == "channel_sessions":
mock_result = MagicMock()
mock_result.data = [
{"external_id": "tg:123456:111"},
{"external_id": "tg:123456:222"},
]
mock_result.count = subscribers_count
t.execute = MagicMock(return_value=mock_result)
else:
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb.table.side_effect = table_side
return sb
# ── Tests: list campaigns ──────────────────────────────────────────────────────
class TestListCampaigns:
def test_requires_auth(self, client):
resp = client.get("/api/v1/campaigns")
assert resp.status_code == 401
def test_free_plan_returns_402(self, client):
user = make_user()
sb = make_supabase(plan="free")
with patch("app.routers.campaigns.get_current_user", return_value=user), \
patch("app.routers.campaigns.get_supabase", return_value=sb):
resp = client.get("/api/v1/campaigns",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 402
def test_returns_campaign_list(self, client):
user = make_user()
sb = make_supabase(plan="starter")
with patch("app.routers.campaigns.get_current_user", return_value=user), \
patch("app.routers.campaigns.get_supabase", return_value=sb):
resp = client.get("/api/v1/campaigns",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["title"] == "Summer Sale"
assert data[0]["status"] == "draft"
def test_empty_when_no_chatbots(self, client):
user = make_user()
sb = make_supabase(plan="starter")
original = sb.table.side_effect
def patched(name):
t = original(name)
if name == "chatbots":
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb.table.side_effect = patched
with patch("app.routers.campaigns.get_current_user", return_value=user), \
patch("app.routers.campaigns.get_supabase", return_value=sb):
resp = client.get("/api/v1/campaigns",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
assert resp.json() == []
def test_pagination_accepted(self, client):
user = make_user()
sb = make_supabase(plan="starter")
with patch("app.routers.campaigns.get_current_user", return_value=user), \
patch("app.routers.campaigns.get_supabase", return_value=sb):
resp = client.get("/api/v1/campaigns?page=2&limit=5",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
# ── Tests: create campaign ─────────────────────────────────────────────────────
class TestCreateCampaign:
def test_requires_auth(self, client):
resp = client.post("/api/v1/campaigns",
json={"chatbot_id": "chatbot-1",
"title": "X", "message": "Y"})
assert resp.status_code == 401
def test_creates_campaign_draft(self, client):
user = make_user()
new_camp = {**SAMPLE_CAMPAIGN, "id": "camp-new"}
sb = make_supabase(plan="starter")
original = sb.table.side_effect
def patched(name):
t = original(name)
if name == "campaigns":
t.execute = MagicMock(return_value=MagicMock(data=[new_camp]))
return t
sb.table.side_effect = patched
with patch("app.routers.campaigns.get_current_user", return_value=user), \
patch("app.routers.campaigns.get_supabase", return_value=sb):
resp = client.post("/api/v1/campaigns",
json={"chatbot_id": "chatbot-1",
"title": "Summer Sale",
"message": "Big discount today!"},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 201
data = resp.json()
assert data["status"] == "draft"
assert data["id"] == "camp-new"
def test_missing_fields_returns_422(self, client):
user = make_user()
sb = make_supabase(plan="starter")
with patch("app.routers.campaigns.get_current_user", return_value=user), \
patch("app.routers.campaigns.get_supabase", return_value=sb):
resp = client.post("/api/v1/campaigns",
json={"chatbot_id": "chatbot-1"}, # missing title/message
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 422
def test_404_when_chatbot_not_owned(self, client):
user = make_user()
sb = make_supabase(plan="starter")
original = sb.table.side_effect
def patched(name):
t = original(name)
if name == "chatbots":
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb.table.side_effect = patched
with patch("app.routers.campaigns.get_current_user", return_value=user), \
patch("app.routers.campaigns.get_supabase", return_value=sb):
resp = client.post("/api/v1/campaigns",
json={"chatbot_id": "stranger-bot",
"title": "X", "message": "Y"},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 404
def test_recipients_count_uses_subscriber_count(self, client):
"""recipients_count should equal the number of Telegram channel_sessions."""
user = make_user()
inserted = []
sb = make_supabase(plan="starter", subscribers_count=42)
original = sb.table.side_effect
def patched(name):
t = original(name)
if name == "campaigns":
orig_insert = t.insert
def track_insert(data):
inserted.append(data)
return t
t.insert = track_insert
t.execute = MagicMock(return_value=MagicMock(data=[{**SAMPLE_CAMPAIGN, "recipients_count": 42}]))
return t
sb.table.side_effect = patched
with patch("app.routers.campaigns.get_current_user", return_value=user), \
patch("app.routers.campaigns.get_supabase", return_value=sb):
resp = client.post("/api/v1/campaigns",
json={"chatbot_id": "chatbot-1",
"title": "T", "message": "M"},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 201
assert resp.json()["recipients_count"] == 42
# ── Tests: send campaign ───────────────────────────────────────────────────────
class TestSendCampaign:
def _make_owned_campaign(self, company_id="company-1", status="draft"):
return {**SAMPLE_CAMPAIGN, "status": status,
"chatbots": {"company_id": company_id}}
def test_requires_auth(self, client):
resp = client.post("/api/v1/campaigns/camp-1/send")
assert resp.status_code == 401
def test_sends_to_subscribers(self, client):
user = make_user()
camp = self._make_owned_campaign()
sent_camp = {**SAMPLE_CAMPAIGN, "status": "sent", "sent_count": 2}
call_count = {"n": 0}
def table_side(name):
t = MagicMock()
for m in ("select", "insert", "update", "delete",
"eq", "in_", "order", "range", "limit", "neq"):
getattr(t, m).return_value = t
if name == "subscriptions":
t.execute = MagicMock(return_value=MagicMock(data=[{"plan": "starter"}]))
elif name == "companies":
t.execute = MagicMock(return_value=MagicMock(data=[{"id": "company-1"}]))
elif name == "campaigns":
call_count["n"] += 1
if call_count["n"] == 1:
t.execute = MagicMock(return_value=MagicMock(data=[camp]))
else:
t.execute = MagicMock(return_value=MagicMock(data=[sent_camp]))
elif name == "channel_connections":
t.execute = MagicMock(return_value=MagicMock(data=[
{"bot_token": "123456:ABCdef"}
]))
elif name == "channel_sessions":
t.execute = MagicMock(return_value=MagicMock(data=[
{"external_id": "tg:123456:111"},
{"external_id": "tg:123456:222"},
]))
else:
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb = MagicMock()
sb.table.side_effect = table_side
with patch("app.routers.campaigns.get_current_user", return_value=user), \
patch("app.routers.campaigns.get_supabase", return_value=sb), \
patch("app.services.telegram_service.send_message", new_callable=AsyncMock) as mock_send:
mock_send.return_value = None
resp = client.post("/api/v1/campaigns/camp-1/send",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
assert resp.json()["status"] == "sent"
assert mock_send.call_count == 2
def test_already_sent_returns_400(self, client):
user = make_user()
camp = self._make_owned_campaign(status="sent")
sb = make_supabase(plan="starter", campaign=camp)
with patch("app.routers.campaigns.get_current_user", return_value=user), \
patch("app.routers.campaigns.get_supabase", return_value=sb):
resp = client.post("/api/v1/campaigns/camp-1/send",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 400
assert "already sent" in resp.json()["detail"].lower()
def test_400_when_no_telegram_connection(self, client):
user = make_user()
camp = self._make_owned_campaign()
sb = make_supabase(plan="starter", campaign=camp)
original = sb.table.side_effect
def patched(name):
t = original(name)
if name == "channel_connections":
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb.table.side_effect = patched
with patch("app.routers.campaigns.get_current_user", return_value=user), \
patch("app.routers.campaigns.get_supabase", return_value=sb):
resp = client.post("/api/v1/campaigns/camp-1/send",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 400
assert "telegram" in resp.json()["detail"].lower()
def test_403_for_other_companys_campaign(self, client):
user = make_user()
camp = self._make_owned_campaign(company_id="company-OTHER")
sb = make_supabase(plan="starter", company_id="company-1", campaign=camp)
with patch("app.routers.campaigns.get_current_user", return_value=user), \
patch("app.routers.campaigns.get_supabase", return_value=sb):
resp = client.post("/api/v1/campaigns/camp-1/send",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 403
def test_404_when_campaign_not_found(self, client):
user = make_user()
sb = make_supabase(plan="starter")
original = sb.table.side_effect
def patched(name):
t = original(name)
if name == "campaigns":
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb.table.side_effect = patched
with patch("app.routers.campaigns.get_current_user", return_value=user), \
patch("app.routers.campaigns.get_supabase", return_value=sb):
resp = client.post("/api/v1/campaigns/nonexistent/send",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 404
def test_partial_failures_do_not_crash(self, client):
"""If some subscribers fail, the campaign still completes."""
user = make_user()
camp = self._make_owned_campaign()
sent_camp = {**SAMPLE_CAMPAIGN, "status": "sent", "sent_count": 1}
call_count = {"n": 0}
def table_side(name):
t = MagicMock()
for m in ("select", "insert", "update", "delete",
"eq", "in_", "order", "range", "limit", "neq"):
getattr(t, m).return_value = t
if name == "subscriptions":
t.execute = MagicMock(return_value=MagicMock(data=[{"plan": "starter"}]))
elif name == "companies":
t.execute = MagicMock(return_value=MagicMock(data=[{"id": "company-1"}]))
elif name == "campaigns":
call_count["n"] += 1
if call_count["n"] == 1:
t.execute = MagicMock(return_value=MagicMock(data=[camp]))
else:
t.execute = MagicMock(return_value=MagicMock(data=[sent_camp]))
elif name == "channel_connections":
t.execute = MagicMock(return_value=MagicMock(data=[
{"bot_token": "123456:ABCdef"}
]))
elif name == "channel_sessions":
t.execute = MagicMock(return_value=MagicMock(data=[
{"external_id": "tg:123456:111"},
{"external_id": "bad-format"}, # malformed — should be skipped
]))
else:
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb = MagicMock()
sb.table.side_effect = table_side
with patch("app.routers.campaigns.get_current_user", return_value=user), \
patch("app.routers.campaigns.get_supabase", return_value=sb), \
patch("app.services.telegram_service.send_message", new_callable=AsyncMock) as mock_send:
mock_send.return_value = None
resp = client.post("/api/v1/campaigns/camp-1/send",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
assert resp.json()["status"] == "sent"
# ── Tests: delete campaign ─────────────────────────────────────────────────────
class TestDeleteCampaign:
def _make_owned_campaign(self, company_id="company-1", status="draft"):
return {**SAMPLE_CAMPAIGN, "status": status,
"chatbots": {"company_id": company_id}}
def test_requires_auth(self, client):
resp = client.delete("/api/v1/campaigns/camp-1")
assert resp.status_code == 401
def test_deletes_draft_campaign(self, client):
user = make_user()
camp = self._make_owned_campaign(status="draft")
sb = make_supabase(plan="starter", campaign=camp)
with patch("app.routers.campaigns.get_current_user", return_value=user), \
patch("app.routers.campaigns.get_supabase", return_value=sb):
resp = client.delete("/api/v1/campaigns/camp-1",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
assert resp.json()["success"] is True
def test_deletes_sent_campaign(self, client):
user = make_user()
camp = self._make_owned_campaign(status="sent")
sb = make_supabase(plan="starter", campaign=camp)
with patch("app.routers.campaigns.get_current_user", return_value=user), \
patch("app.routers.campaigns.get_supabase", return_value=sb):
resp = client.delete("/api/v1/campaigns/camp-1",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
def test_cannot_delete_sending_campaign(self, client):
user = make_user()
camp = self._make_owned_campaign(status="sending")
sb = make_supabase(plan="starter", campaign=camp)
with patch("app.routers.campaigns.get_current_user", return_value=user), \
patch("app.routers.campaigns.get_supabase", return_value=sb):
resp = client.delete("/api/v1/campaigns/camp-1",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 400
def test_403_for_other_companys_campaign(self, client):
user = make_user()
camp = self._make_owned_campaign(company_id="company-OTHER")
sb = make_supabase(plan="starter", company_id="company-1", campaign=camp)
with patch("app.routers.campaigns.get_current_user", return_value=user), \
patch("app.routers.campaigns.get_supabase", return_value=sb):
resp = client.delete("/api/v1/campaigns/camp-1",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 403
def test_404_when_not_found(self, client):
user = make_user()
sb = make_supabase(plan="starter")
original = sb.table.side_effect
def patched(name):
t = original(name)
if name == "campaigns":
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb.table.side_effect = patched
with patch("app.routers.campaigns.get_current_user", return_value=user), \
patch("app.routers.campaigns.get_supabase", return_value=sb):
resp = client.delete("/api/v1/campaigns/nonexistent",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 404

258
tests/test_channels.py Normal file
View File

@@ -0,0 +1,258 @@
"""Tests for channels and webhook endpoints."""
import pytest
from unittest.mock import MagicMock, patch, AsyncMock
AUTH = {"Authorization": "Bearer test-token"}
def _make_channels_sb(plan="starter", company=True, chatbot=True, connection=None):
"""Build a supabase mock for channel tests."""
sb = MagicMock()
def table_side(name):
m = MagicMock()
m.select.return_value = m
m.insert.return_value = m
m.update.return_value = m
m.delete.return_value = m
m.eq.return_value = m
m.in_.return_value = m
m.limit.return_value = m
m.order.return_value = m
m.range.return_value = m
m.gte.return_value = m
m.lt.return_value = m
if name == "subscriptions":
m.execute.return_value = MagicMock(data=[{"plan": plan, "status": "active"}])
elif name == "companies":
m.execute.return_value = MagicMock(
data=[{"id": "comp-1", "owner_id": "test-user-id"}] if company else []
)
elif name == "chatbots":
m.execute.return_value = MagicMock(
data=[{"id": "cb-1", "company_id": "comp-1",
"qdrant_collection_name": "col-1",
"is_published": True,
"name": "Test Bot",
"welcome_message": "Hi!",
"companies": {"name": "Acme", "logo_url": None}}] if chatbot else []
)
elif name == "channel_connections":
conn = connection if connection is not None else []
m.execute.return_value = MagicMock(data=conn)
elif name == "channel_sessions":
m.execute.return_value = MagicMock(
data=[{"id": "sess-1", "session_id": "s-123", "chatbot_id": "cb-1",
"channel": "telegram", "external_id": "tg:abc:12345"}]
)
elif name == "conversations":
m.execute.return_value = MagicMock(data=[{"id": "conv-1", "session_id": "s-123",
"status": "open", "message_count": 0}])
elif name == "messages":
m.execute.return_value = MagicMock(data=[], count=0)
else:
m.execute.return_value = MagicMock(data=[], count=0)
return m
sb.table.side_effect = table_side
sb.auth = MagicMock()
return sb
class TestChannelsAuth:
def test_list_channels_requires_auth(self, client):
resp = client.get("/api/v1/channels?chatbot_id=cb-1")
assert resp.status_code == 401
def test_connect_telegram_requires_auth(self, client):
resp = client.post("/api/v1/channels/telegram",
json={"chatbot_id": "cb-1", "bot_token": "fake:token"})
assert resp.status_code == 401
def test_disconnect_requires_auth(self, client):
resp = client.delete("/api/v1/channels/conn-1")
assert resp.status_code == 401
class TestListChannels:
def test_returns_empty_list_when_no_channels(self, client):
with patch("app.routers.channels.get_supabase") as mock_sb:
mock_sb.return_value = _make_channels_sb()
resp = client.get("/api/v1/channels?chatbot_id=cb-1", headers=AUTH)
assert resp.status_code == 200
assert resp.json() == []
def test_returns_connection_list(self, client):
conn = [{"id": "conn-1", "channel": "telegram", "bot_username": "mybot",
"is_active": True, "created_at": "2024-01-01T00:00:00"}]
with patch("app.routers.channels.get_supabase") as mock_sb:
mock_sb.return_value = _make_channels_sb(connection=conn)
resp = client.get("/api/v1/channels?chatbot_id=cb-1", headers=AUTH)
assert resp.status_code == 200
assert len(resp.json()) == 1
assert resp.json()[0]["channel"] == "telegram"
def test_chatbot_not_found_returns_404(self, client):
with patch("app.routers.channels.get_supabase") as mock_sb:
mock_sb.return_value = _make_channels_sb(chatbot=False)
resp = client.get("/api/v1/channels?chatbot_id=bad-id", headers=AUTH)
assert resp.status_code == 404
class TestConnectTelegram:
def test_free_plan_blocked(self, client):
with patch("app.routers.channels.get_supabase") as mock_sb, \
patch("app.routers.channels.get_bot_info", new_callable=AsyncMock) as mock_bot:
mock_bot.return_value = {"username": "mybot"}
mock_sb.return_value = _make_channels_sb(plan="free")
resp = client.post("/api/v1/channels/telegram",
json={"chatbot_id": "cb-1", "bot_token": "abc:token"},
headers=AUTH)
assert resp.status_code == 402
assert "Starter" in resp.json()["detail"]
def test_invalid_bot_token_returns_400(self, client):
with patch("app.routers.channels.get_supabase") as mock_sb, \
patch("app.routers.channels.get_bot_info", new_callable=AsyncMock) as mock_bot:
mock_bot.return_value = None # Invalid token
mock_sb.return_value = _make_channels_sb(plan="starter")
resp = client.post("/api/v1/channels/telegram",
json={"chatbot_id": "cb-1", "bot_token": "invalid:token"},
headers=AUTH)
assert resp.status_code == 400
assert "Invalid bot token" in resp.json()["detail"]
def test_successful_connection(self, client):
with patch("app.routers.channels.get_supabase") as mock_sb, \
patch("app.routers.channels.get_bot_info", new_callable=AsyncMock) as mock_bot, \
patch("app.routers.channels.set_webhook", new_callable=AsyncMock) as mock_webhook, \
patch("app.routers.channels.settings") as mock_settings:
mock_bot.return_value = {"username": "mybot"}
mock_webhook.return_value = True
mock_settings.api_url = "https://api.example.com"
mock_sb.return_value = _make_channels_sb(plan="starter")
resp = client.post("/api/v1/channels/telegram",
json={"chatbot_id": "cb-1", "bot_token": "valid:token"},
headers=AUTH)
assert resp.status_code == 200
body = resp.json()
assert body["success"] is True
assert body["bot_username"] == "mybot"
assert "t.me/mybot" in body["bot_link"]
def test_missing_api_url_returns_500(self, client):
with patch("app.routers.channels.get_supabase") as mock_sb, \
patch("app.routers.channels.get_bot_info", new_callable=AsyncMock) as mock_bot, \
patch("app.routers.channels.settings") as mock_settings:
mock_bot.return_value = {"username": "mybot"}
mock_settings.api_url = None
mock_sb.return_value = _make_channels_sb(plan="starter")
resp = client.post("/api/v1/channels/telegram",
json={"chatbot_id": "cb-1", "bot_token": "valid:token"},
headers=AUTH)
assert resp.status_code == 500
class TestDisconnectChannel:
def test_connection_not_found_returns_404(self, client):
with patch("app.routers.channels.get_supabase") as mock_sb:
mock_sb.return_value = _make_channels_sb(connection=[])
resp = client.delete("/api/v1/channels/no-such-conn", headers=AUTH)
assert resp.status_code == 404
def test_disconnect_success(self, client):
conn = [{"id": "conn-1", "channel": "telegram", "chatbot_id": "cb-1",
"bot_token": "tok:en", "is_active": True}]
with patch("app.routers.channels.get_supabase") as mock_sb, \
patch("app.routers.channels.delete_webhook", new_callable=AsyncMock):
mock_sb.return_value = _make_channels_sb(connection=conn)
resp = client.delete("/api/v1/channels/conn-1", headers=AUTH)
assert resp.status_code == 200
assert resp.json()["success"] is True
class TestTelegramWebhook:
def _post_webhook(self, client, body, bot_token="abc:token"):
return client.post(f"/api/v1/webhooks/telegram/{bot_token}", json=body)
def test_webhook_non_message_returns_ok(self, client):
with patch("app.routers.channels.get_supabase") as mock_sb:
mock_sb.return_value = _make_channels_sb()
resp = self._post_webhook(client, {"update_id": 1})
assert resp.status_code == 200
assert resp.json()["ok"] is True
def test_webhook_no_matching_connection_returns_ok(self, client):
with patch("app.routers.channels.get_supabase") as mock_sb:
mock_sb.return_value = _make_channels_sb(connection=[])
resp = self._post_webhook(client, {
"message": {"chat": {"id": 12345}, "text": "Hello"}
})
assert resp.status_code == 200
assert resp.json()["ok"] is True
def test_webhook_start_command_sends_welcome(self, client):
conn = [{"id": "conn-1", "chatbot_id": "cb-1", "channel": "telegram",
"bot_token": "abc:token", "is_active": True}]
with patch("app.routers.channels.get_supabase") as mock_sb, \
patch("app.routers.channels.tg_send", new_callable=AsyncMock) as mock_send, \
patch("app.routers.channels._check_chatbot_channel_subscription", return_value=True):
mock_sb.return_value = _make_channels_sb(connection=conn)
resp = self._post_webhook(client, {
"message": {"chat": {"id": 12345}, "text": "/start"}
})
assert resp.status_code == 200
mock_send.assert_called_once()
def test_webhook_processes_message_via_rag(self, client):
conn = [{"id": "conn-1", "chatbot_id": "cb-1", "channel": "telegram",
"bot_token": "abc:token", "is_active": True}]
rag_result = {"response": "I can help!", "sources": [], "model": "gpt-4", "tokens_used": 10}
with patch("app.routers.channels.get_supabase") as mock_sb, \
patch("app.routers.channels.tg_send", new_callable=AsyncMock) as mock_send, \
patch("app.routers.channels.rag_engine") as mock_rag, \
patch("app.routers.channels._check_chatbot_channel_subscription", return_value=True):
mock_rag.process_query = AsyncMock(return_value=rag_result)
mock_sb.return_value = _make_channels_sb(connection=conn)
resp = self._post_webhook(client, {
"message": {"chat": {"id": 12345}, "text": "What are your hours?"}
})
assert resp.status_code == 200
mock_send.assert_called_once()
args = mock_send.call_args[0]
assert args[2] == "I can help!"
def test_webhook_subscription_expired_sends_unavailable(self, client):
conn = [{"id": "conn-1", "chatbot_id": "cb-1", "channel": "telegram",
"bot_token": "abc:token", "is_active": True}]
with patch("app.routers.channels.get_supabase") as mock_sb, \
patch("app.routers.channels.tg_send", new_callable=AsyncMock) as mock_send, \
patch("app.routers.channels._check_chatbot_channel_subscription", return_value=False):
mock_sb.return_value = _make_channels_sb(connection=conn)
resp = self._post_webhook(client, {
"message": {"chat": {"id": 12345}, "text": "Hello"}
})
assert resp.status_code == 200
mock_send.assert_called_once()
assert "unavailable" in mock_send.call_args[0][2].lower()
class TestDetectLanguage:
def test_arabic_text_detected(self):
from app.routers.channels import _detect_language
assert _detect_language("مرحبا كيف حالك") == "ar"
def test_chinese_text_detected(self):
from app.routers.channels import _detect_language
assert _detect_language("你好世界这是中文") == "zh"
def test_english_default(self):
from app.routers.channels import _detect_language
assert _detect_language("Hello how are you doing today") == "en"
def test_empty_string_defaults_to_english(self):
from app.routers.channels import _detect_language
assert _detect_language("") == "en"

289
tests/test_chat.py Normal file
View File

@@ -0,0 +1,289 @@
"""Tests for chat endpoints."""
import pytest
from unittest.mock import MagicMock, patch, AsyncMock
AUTH = {"Authorization": "Bearer test-token"}
def _make_chatbot(published=True, collection="col-1", **kwargs):
base = {
"id": "cb-1",
"name": "Test Bot",
"is_published": published,
"qdrant_collection_name": collection,
"company_id": "company-1",
"handoff_enabled": False,
"handoff_keywords": [],
"lead_capture_enabled": False,
"lead_capture_trigger": None,
"booking_enabled": False,
"system_prompt": "You are helpful.",
"welcome_message": "Hello!",
"companies": {"name": "Acme", "logo_url": None},
}
base.update(kwargs)
return base
def _make_chat_sb(chatbot=None, existing_conv=None, insert_conv=None):
"""Build a chainable supabase mock for chat tests."""
sb = MagicMock()
chatbot_data = [chatbot] if chatbot is not None else [_make_chatbot()]
conversation_insert = insert_conv or {"id": "conv-1", "session_id": "sess-1",
"status": "open", "message_count": 0}
call_counts = {}
def table_side(name):
m = MagicMock()
m.select.return_value = m
m.insert.return_value = m
m.update.return_value = m
m.delete.return_value = m
m.eq.return_value = m
m.in_.return_value = m
m.limit.return_value = m
m.order.return_value = m
m.range.return_value = m
m.gte.return_value = m
m.lt.return_value = m
if name == "chatbots":
m.execute.return_value = MagicMock(data=chatbot_data)
elif name == "conversations":
if existing_conv is not None:
m.execute.return_value = MagicMock(data=existing_conv, count=len(existing_conv))
else:
call_counts.setdefault("conversations", 0)
original_execute = m.execute
def conv_execute():
call_counts["conversations"] += 1
if call_counts["conversations"] == 1:
return MagicMock(data=[], count=0)
return MagicMock(data=[conversation_insert], count=1)
m.execute.side_effect = conv_execute
elif name == "messages":
m.execute.return_value = MagicMock(data=[], count=0)
elif name == "companies":
m.execute.return_value = MagicMock(data=[{"id": "company-1", "owner_id": "owner-1"}])
elif name == "subscriptions":
m.execute.return_value = MagicMock(data=[{"plan": "starter"}])
else:
m.execute.return_value = MagicMock(data=[], count=0)
return m
sb.table.side_effect = table_side
sb.auth = MagicMock()
return sb
class TestChatAuth:
def test_chat_does_not_require_auth_for_published_bot(self, client):
"""Public chat endpoint should work without auth for published bots."""
rag_result = {
"response": "Hello!",
"sources": [],
"model": "gpt-4",
"tokens_used": 10,
}
with patch("app.routers.chat.get_supabase") as mock_sb, \
patch("app.routers.chat.rag_engine") as mock_rag:
mock_rag.process_query = AsyncMock(return_value=rag_result)
mock_sb.return_value = _make_chat_sb()
resp = client.post("/api/v1/chat/cb-1", json={"message": "Hi", "language": "en"})
assert resp.status_code == 200
def test_chat_unpublished_bot_requires_auth(self, client):
unpublished = _make_chatbot(published=False)
with patch("app.routers.chat.get_supabase") as mock_sb:
mock_sb.return_value = _make_chat_sb(chatbot=unpublished)
resp = client.post("/api/v1/chat/cb-1", json={"message": "Hi", "language": "en"})
assert resp.status_code == 403
def test_chat_returns_404_when_chatbot_missing(self, client):
def table_side(name):
m = MagicMock()
m.select.return_value = m
m.eq.return_value = m
m.execute.return_value = MagicMock(data=[])
return m
sb = MagicMock()
sb.table.side_effect = table_side
sb.auth = MagicMock()
with patch("app.routers.chat.get_supabase", return_value=sb):
resp = client.post("/api/v1/chat/no-such-bot", json={"message": "Hi", "language": "en"})
assert resp.status_code == 404
class TestChatRateLimiting:
def test_rate_limit_429_after_30_requests(self, client):
"""After 30 requests from same IP, should return 429."""
from app.routers.chat import _rate_store
import time
_rate_store["testclient"] = [time.time() for _ in range(30)]
try:
rag_result = {"response": "Hi", "sources": [], "model": "m", "tokens_used": 0}
with patch("app.routers.chat.get_supabase") as mock_sb, \
patch("app.routers.chat.rag_engine") as mock_rag:
mock_rag.process_query = AsyncMock(return_value=rag_result)
mock_sb.return_value = _make_chat_sb()
resp = client.post("/api/v1/chat/cb-1", json={"message": "Hi", "language": "en"})
finally:
_rate_store.pop("testclient", None)
assert resp.status_code == 429
class TestChatResponse:
def _do_chat(self, client, rag_result, chatbot=None, message="Hello"):
with patch("app.routers.chat.get_supabase") as mock_sb, \
patch("app.routers.chat.rag_engine") as mock_rag:
mock_rag.process_query = AsyncMock(return_value=rag_result)
mock_sb.return_value = _make_chat_sb(chatbot=chatbot)
return client.post("/api/v1/chat/cb-1", json={"message": message, "language": "en"})
def test_response_shape(self, client):
rag_result = {"response": "Hello!", "sources": [], "model": "gpt-4", "tokens_used": 15}
resp = self._do_chat(client, rag_result)
assert resp.status_code == 200
body = resp.json()
assert "response" in body
assert "session_id" in body
assert "sources" in body
assert "model_used" in body
assert "tokens_used" in body
assert "needs_lead_capture" in body
assert "handoff" in body
def test_response_contains_rag_text(self, client):
rag_result = {"response": "42 is the answer", "sources": [], "model": "m", "tokens_used": 5}
resp = self._do_chat(client, rag_result)
assert resp.json()["response"] == "42 is the answer"
def test_chatbot_with_no_collection_returns_400(self, client):
no_collection_bot = _make_chatbot(collection=None)
rag_result = {"response": "", "sources": [], "model": "", "tokens_used": 0}
resp = self._do_chat(client, rag_result, chatbot=no_collection_bot)
assert resp.status_code == 400
assert "knowledge base" in resp.json()["detail"].lower()
def test_agent_handling_status_returns_empty_response(self, client):
chatbot = _make_chatbot()
conv_with_agent = [{"id": "conv-1", "session_id": "sess-1", "status": "agent_handling",
"message_count": 5, "language": "en", "user_id": None}]
rag_result = {"response": "Should not be reached", "sources": [], "model": "", "tokens_used": 0}
with patch("app.routers.chat.get_supabase") as mock_sb, \
patch("app.routers.chat.rag_engine") as mock_rag:
mock_rag.process_query = AsyncMock(return_value=rag_result)
mock_sb.return_value = _make_chat_sb(chatbot=chatbot, existing_conv=conv_with_agent)
resp = client.post("/api/v1/chat/cb-1",
json={"message": "Hi", "language": "en", "session_id": "sess-1"})
assert resp.status_code == 200
assert resp.json()["response"] == ""
class TestChatHandoff:
def test_handoff_triggered_by_keyword(self, client):
chatbot = _make_chatbot(handoff_enabled=True, handoff_keywords=["human", "agent"],
handoff_email="owner@test.com")
rag_result = {"response": "Connecting you...", "sources": [], "model": "m", "tokens_used": 5}
with patch("app.routers.chat.get_supabase") as mock_sb, \
patch("app.routers.chat.rag_engine") as mock_rag, \
patch("app.routers.chat.send_handoff_notification", new_callable=AsyncMock, create=True):
mock_rag.process_query = AsyncMock(return_value=rag_result)
mock_sb.return_value = _make_chat_sb(chatbot=chatbot)
resp = client.post("/api/v1/chat/cb-1", json={"message": "I need a human agent", "language": "en"})
assert resp.status_code == 200
assert resp.json()["handoff"] is True
def test_handoff_not_triggered_without_keyword_match(self, client):
chatbot = _make_chatbot(handoff_enabled=True, handoff_keywords=["human"])
rag_result = {"response": "Sure!", "sources": [], "model": "m", "tokens_used": 5}
with patch("app.routers.chat.get_supabase") as mock_sb, \
patch("app.routers.chat.rag_engine") as mock_rag:
mock_rag.process_query = AsyncMock(return_value=rag_result)
mock_sb.return_value = _make_chat_sb(chatbot=chatbot)
resp = client.post("/api/v1/chat/cb-1", json={"message": "What are your hours?", "language": "en"})
assert resp.status_code == 200
assert resp.json()["handoff"] is False
class TestChatHistory:
def test_history_returns_empty_for_unknown_session(self, client):
def table_side(name):
m = MagicMock()
m.select.return_value = m
m.eq.return_value = m
m.order.return_value = m
m.execute.return_value = MagicMock(data=[])
return m
sb = MagicMock()
sb.table.side_effect = table_side
sb.auth = MagicMock()
with patch("app.routers.chat.get_supabase", return_value=sb):
resp = client.get("/api/v1/chat/cb-1/history/no-such-session")
assert resp.status_code == 200
assert resp.json() == []
class TestChatFeedback:
def test_feedback_valid_positive(self, client):
def table_side(name):
m = MagicMock()
m.select.return_value = m
m.insert.return_value = m
m.eq.return_value = m
m.execute.return_value = MagicMock(data=[{"id": "msg-1", "conversation_id": "conv-1"}])
return m
sb = MagicMock()
sb.table.side_effect = table_side
sb.auth = MagicMock()
with patch("app.routers.chat.get_supabase", return_value=sb):
resp = client.post(
"/api/v1/chat/cb-1/feedback",
json={"message_id": "msg-1", "feedback": "positive"},
)
assert resp.status_code == 200
assert resp.json()["success"] is True
def test_feedback_invalid_value_rejected(self, client):
resp = client.post(
"/api/v1/chat/cb-1/feedback",
json={"message_id": "msg-1", "feedback": "meh"},
)
assert resp.status_code == 400
def test_feedback_message_not_found(self, client):
def table_side(name):
m = MagicMock()
m.select.return_value = m
m.eq.return_value = m
m.execute.return_value = MagicMock(data=[])
return m
sb = MagicMock()
sb.table.side_effect = table_side
sb.auth = MagicMock()
with patch("app.routers.chat.get_supabase", return_value=sb):
resp = client.post(
"/api/v1/chat/cb-1/feedback",
json={"message_id": "no-such-msg", "feedback": "negative"},
)
assert resp.status_code == 404

90
tests/test_chatbots.py Normal file
View File

@@ -0,0 +1,90 @@
"""Tests for chatbot endpoints."""
import pytest
from unittest.mock import MagicMock, patch
class TestChatbotProtection:
def test_list_chatbots_requires_auth(self, client):
resp = client.get("/api/v1/chatbots")
assert resp.status_code == 401
def test_create_chatbot_requires_auth(self, client):
resp = client.post("/api/v1/chatbots", json={"name": "Test"})
assert resp.status_code == 401
def test_delete_chatbot_requires_auth(self, client):
resp = client.delete("/api/v1/chatbots/some-id")
assert resp.status_code == 401
class TestInputValidation:
def test_create_chatbot_rejects_long_name(self, client):
"""ChatbotCreate should reject names > 100 chars."""
from app.models import ChatbotCreate
import pytest
with pytest.raises(Exception):
ChatbotCreate(name="x" * 101)
def test_create_chatbot_strips_script_tags(self):
"""System prompt with script tags should be sanitized."""
from app.models import ChatbotCreate
data = ChatbotCreate(
name="Test",
system_prompt="Hello <script>alert('xss')</script> world",
)
assert "<script>" not in (data.system_prompt or "")
assert "world" in (data.system_prompt or "")
def test_create_chatbot_rejects_long_system_prompt(self):
"""System prompt > 10000 chars should raise validation error."""
from app.models import ChatbotCreate
import pytest
with pytest.raises(Exception):
ChatbotCreate(name="Test", system_prompt="x" * 10001)
def test_create_chatbot_strips_name_whitespace(self):
from app.models import ChatbotCreate
data = ChatbotCreate(name=" My Bot ")
assert data.name == "My Bot"
class TestQdrantOrphanCleanup:
def test_qdrant_collection_deleted_on_db_failure(self):
"""If DB insert fails after Qdrant creation, collection should be cleaned up."""
with patch("app.routers.chatbots.vector_store") as mock_vs, \
patch("app.routers.chatbots.get_supabase") as mock_sb, \
patch("app.routers.chatbots.get_current_user") as mock_auth:
# Auth passes
user = MagicMock()
user.id = "user-id"
mock_auth.return_value = user
sb = MagicMock()
# company lookup succeeds
sb.table.return_value.select.return_value.eq.return_value.execute.return_value = MagicMock(
data=[{"id": "company-id", "owner_id": "user-id"}]
)
# DB insert fails
sb.table.return_value.insert.return_value.execute.side_effect = Exception("DB error")
mock_sb.return_value = sb
mock_vs.create_collection = MagicMock()
mock_vs.delete_collection = MagicMock()
from fastapi.testclient import TestClient
from app.main import app
test_client = TestClient(app)
resp = test_client.post(
"/api/v1/chatbots",
json={"name": "Test Bot"},
headers={"Authorization": "Bearer test"},
)
# Should have attempted cleanup
# (The response will be 500 due to DB failure)
# The key assertion is that delete_collection was attempted
# (This is a partial integration test — full assertion needs auth mock)

285
tests/test_config_plans.py Normal file
View File

@@ -0,0 +1,285 @@
"""
Tests for PLAN_LIMITS in config.py.
Ensures each plan has the correct feature gates and that the pricing
tiers are properly differentiated.
"""
import pytest
from app.config import PLAN_LIMITS, MODEL_CATALOG, DEFAULT_MODELS, MODEL_PROVIDERS
class TestPlanStructure:
"""Every plan must have all required keys."""
REQUIRED_KEYS = {
"max_chatbots", "max_published", "max_documents_per_chatbot",
"max_document_size_mb", "models", "conversations_limit",
"code_export", "analytics", "gap_suggestions", "channels",
"url_sources", "leads_per_month", "inbox_replies", "leads_editing",
"show_branding", "appointments", "appointments_chatbots",
"campaigns", "campaigns_per_month", "max_campaign_recipients",
}
@pytest.mark.parametrize("plan", ["free", "starter", "business", "agency", "enterprise"])
def test_all_required_keys_present(self, plan):
config = PLAN_LIMITS[plan]
missing = self.REQUIRED_KEYS - set(config.keys())
assert not missing, f"Plan '{plan}' missing keys: {missing}"
@pytest.mark.parametrize("plan", ["free", "starter", "business", "agency", "enterprise"])
def test_models_list_is_non_empty(self, plan):
models = PLAN_LIMITS[plan]["models"]
assert len(models) > 0
@pytest.mark.parametrize("plan", ["free", "starter", "business", "agency", "enterprise"])
def test_default_model_exists_for_plan(self, plan):
default = DEFAULT_MODELS.get(plan)
assert default is not None, f"No default model for {plan}"
class TestFreePlanRestrictions:
def setup_method(self):
self.plan = PLAN_LIMITS["free"]
def test_max_published_is_one(self):
assert self.plan["max_published"] == 1
def test_no_inbox_replies(self):
assert self.plan["inbox_replies"] is False
def test_no_leads_editing(self):
assert self.plan["leads_editing"] is False
def test_no_appointments(self):
assert self.plan["appointments"] is False
assert self.plan["appointments_chatbots"] == 0
def test_no_campaigns(self):
assert self.plan["campaigns"] is False
assert self.plan["campaigns_per_month"] == 0
assert self.plan["max_campaign_recipients"] == 0
def test_show_branding(self):
assert self.plan["show_branding"] is True
def test_no_analytics(self):
assert self.plan["analytics"] is False
def test_no_gap_suggestions(self):
assert self.plan["gap_suggestions"] is False
def test_no_channels(self):
assert self.plan["channels"] == []
def test_no_url_sources(self):
assert self.plan["url_sources"] == 0
def test_no_leads(self):
assert self.plan["leads_per_month"] == 0
def test_only_free_model(self):
models = self.plan["models"]
assert len(models) == 1
assert "llama" in models[0].lower()
class TestStarterPlan:
def setup_method(self):
self.plan = PLAN_LIMITS["starter"]
def test_max_published_is_three(self):
assert self.plan["max_published"] == 3
def test_has_inbox_replies(self):
assert self.plan["inbox_replies"] is True
def test_has_leads_editing(self):
assert self.plan["leads_editing"] is True
def test_has_appointments(self):
assert self.plan["appointments"] is True
def test_appointments_limited_to_one_chatbot(self):
assert self.plan["appointments_chatbots"] == 1
def test_has_campaigns(self):
assert self.plan["campaigns"] is True
def test_campaigns_limited_per_month(self):
assert 0 < self.plan["campaigns_per_month"] < 999999
def test_campaign_recipients_limited(self):
assert 0 < self.plan["max_campaign_recipients"] < 999999
def test_show_branding(self):
# Starter still shows branding
assert self.plan["show_branding"] is True
def test_has_analytics(self):
assert self.plan["analytics"] is True
def test_no_gap_suggestions(self):
assert self.plan["gap_suggestions"] is False
def test_has_telegram_channel(self):
assert "telegram" in self.plan["channels"]
def test_fireworks_models_only(self):
models = self.plan["models"]
for m in models:
assert "fireworks" in m
def test_no_premium_models(self):
premium = {"gpt-4o", "gpt-4o-mini", "claude-haiku-4-5-20251001",
"gemini-2.5-flash", "gemini-2.5-lite", "gemini-2.5-pro"}
assert not premium.intersection(set(self.plan["models"]))
class TestBusinessPlan:
def setup_method(self):
self.plan = PLAN_LIMITS["business"]
def test_max_published_is_ten(self):
assert self.plan["max_published"] == 10
def test_can_remove_branding(self):
assert self.plan["show_branding"] is False
def test_unlimited_appointments_chatbots(self):
assert self.plan["appointments_chatbots"] == 999999
def test_unlimited_campaigns_per_month(self):
assert self.plan["campaigns_per_month"] == 999999
def test_has_campaign_recipient_limit(self):
# Business is capped below Agency/Enterprise
assert self.plan["max_campaign_recipients"] < PLAN_LIMITS["agency"]["max_campaign_recipients"]
def test_has_gap_suggestions(self):
assert self.plan["gap_suggestions"] is True
def test_has_premium_models(self):
premium = {"gpt-4o", "gpt-4o-mini", "claude-haiku-4-5-20251001"}
assert premium.issubset(set(self.plan["models"]))
def test_has_google_models(self):
google = {"gemini-2.5-flash", "gemini-2.5-pro"}
assert google.issubset(set(self.plan["models"]))
class TestAgencyPlan:
def setup_method(self):
self.plan = PLAN_LIMITS["agency"]
def test_unlimited_published(self):
assert self.plan["max_published"] == 999999
def test_unlimited_campaign_recipients(self):
assert self.plan["max_campaign_recipients"] == 999999
def test_code_export_enabled(self):
assert self.plan["code_export"] is True
def test_gap_suggestions_enabled(self):
assert self.plan["gap_suggestions"] is True
def test_no_branding(self):
assert self.plan["show_branding"] is False
class TestEnterprisePlan:
def setup_method(self):
self.plan = PLAN_LIMITS["enterprise"]
def test_wildcard_models(self):
assert "*" in self.plan["models"]
def test_all_features_enabled(self):
assert self.plan["appointments"] is True
assert self.plan["campaigns"] is True
assert self.plan["inbox_replies"] is True
assert self.plan["leads_editing"] is True
assert self.plan["gap_suggestions"] is True
assert self.plan["code_export"] is True
assert self.plan["show_branding"] is False
def test_unlimited_everything(self):
BIG = 999999
assert self.plan["max_published"] == BIG
assert self.plan["appointments_chatbots"] == BIG
assert self.plan["campaigns_per_month"] == BIG
assert self.plan["max_campaign_recipients"] == BIG
class TestTierProgression:
"""Each higher tier must be strictly better than the tier below it."""
def test_max_published_increases_with_tier(self):
assert PLAN_LIMITS["free"]["max_published"] \
<= PLAN_LIMITS["starter"]["max_published"] \
<= PLAN_LIMITS["business"]["max_published"] \
<= PLAN_LIMITS["agency"]["max_published"]
def test_conversation_limits_increase_with_tier(self):
assert PLAN_LIMITS["free"]["conversations_limit"] \
< PLAN_LIMITS["starter"]["conversations_limit"] \
< PLAN_LIMITS["business"]["conversations_limit"] \
< PLAN_LIMITS["agency"]["conversations_limit"]
def test_business_has_more_models_than_starter(self):
starter_models = set(PLAN_LIMITS["starter"]["models"])
business_models = set(PLAN_LIMITS["business"]["models"])
assert starter_models.issubset(business_models)
assert len(business_models) > len(starter_models)
def test_appointment_chatbots_increases_with_tier(self):
assert PLAN_LIMITS["free"]["appointments_chatbots"] \
<= PLAN_LIMITS["starter"]["appointments_chatbots"] \
<= PLAN_LIMITS["business"]["appointments_chatbots"]
def test_campaign_recipients_increases_with_tier(self):
assert PLAN_LIMITS["free"]["max_campaign_recipients"] \
< PLAN_LIMITS["starter"]["max_campaign_recipients"] \
< PLAN_LIMITS["business"]["max_campaign_recipients"] \
<= PLAN_LIMITS["agency"]["max_campaign_recipients"]
class TestModelCatalog:
"""MODEL_CATALOG and MODEL_PROVIDERS consistency checks."""
def test_all_catalog_models_have_required_fields(self):
for model_id, meta in MODEL_CATALOG.items():
assert "name" in meta, f"{model_id} missing 'name'"
assert "provider" in meta, f"{model_id} missing 'provider'"
assert "badge" in meta, f"{model_id} missing 'badge'"
def test_all_catalog_models_have_provider_mapping(self):
for model_id in MODEL_CATALOG:
assert model_id in MODEL_PROVIDERS, \
f"{model_id} in MODEL_CATALOG but not in MODEL_PROVIDERS"
def test_provider_values_are_known(self):
known = {"fireworks", "openai", "anthropic", "google"}
for model_id, provider in MODEL_PROVIDERS.items():
assert provider in known, \
f"{model_id} has unknown provider '{provider}'"
def test_non_enterprise_plan_models_are_in_catalog(self):
for plan_name, plan in PLAN_LIMITS.items():
if plan_name == "enterprise":
continue
for model_id in plan["models"]:
assert model_id in MODEL_CATALOG, \
f"Plan '{plan_name}' references '{model_id}' not in MODEL_CATALOG"
def test_default_models_are_in_catalog(self):
for plan, model_id in DEFAULT_MODELS.items():
assert model_id in MODEL_CATALOG, \
f"DEFAULT_MODELS[{plan}] = '{model_id}' not in MODEL_CATALOG"
def test_default_models_are_in_plan_limits(self):
for plan, model_id in DEFAULT_MODELS.items():
plan_models = PLAN_LIMITS[plan]["models"]
if "*" not in plan_models:
assert model_id in plan_models, \
f"Default model '{model_id}' for plan '{plan}' not in that plan's models list"

267
tests/test_documents.py Normal file
View File

@@ -0,0 +1,267 @@
"""Tests for document upload, list, delete, and URL source endpoints."""
import pytest
from unittest.mock import MagicMock, patch, AsyncMock
AUTH = {"Authorization": "Bearer test-token"}
def _make_doc_sb(company=True, chatbot=True, doc=None, url_source=None):
"""Build a supabase mock for document endpoint tests."""
sb = MagicMock()
def table_side(name):
m = MagicMock()
m.select.return_value = m
m.insert.return_value = m
m.update.return_value = m
m.delete.return_value = m
m.eq.return_value = m
m.in_.return_value = m
m.limit.return_value = m
m.order.return_value = m
m.range.return_value = m
m.gte.return_value = m
m.lt.return_value = m
if name == "companies":
m.execute.return_value = MagicMock(
data=[{"id": "company-1"}] if company else [],
count=1 if company else 0,
)
elif name == "chatbots":
m.execute.return_value = MagicMock(
data=[{"id": "cb-1", "company_id": "company-1",
"qdrant_collection_name": "col-1"}] if chatbot else [],
count=1 if chatbot else 0,
)
elif name == "documents":
if doc is not None:
m.execute.return_value = MagicMock(data=[doc], count=1)
else:
m.execute.return_value = MagicMock(
data=[{
"id": "doc-1",
"chatbot_id": "cb-1",
"file_name": "test.pdf",
"file_type": ".pdf",
"file_size": 1024,
"chunk_count": 0,
"status": "processing",
"created_at": "2024-01-01T00:00:00",
"updated_at": "2024-01-01T00:00:00",
"error_message": None,
"file_url": None,
}],
count=1,
)
elif name == "subscriptions":
m.execute.return_value = MagicMock(data=[{"plan": "starter"}], count=1)
elif name == "url_sources":
if url_source is not None:
m.execute.return_value = MagicMock(data=[url_source], count=1)
else:
m.execute.return_value = MagicMock(data=[], count=0)
else:
m.execute.return_value = MagicMock(data=[], count=0)
return m
sb.table.side_effect = table_side
sb.auth = MagicMock()
return sb
class TestDocumentAuth:
def test_upload_requires_auth(self, client):
resp = client.post(
"/api/v1/chatbots/cb-1/documents",
files={"file": ("test.pdf", b"PDF content", "application/pdf")},
)
assert resp.status_code == 401
def test_list_requires_auth(self, client):
resp = client.get("/api/v1/chatbots/cb-1/documents")
assert resp.status_code == 401
def test_delete_requires_auth(self, client):
resp = client.delete("/api/v1/chatbots/cb-1/documents/doc-1")
assert resp.status_code == 401
class TestDocumentUpload:
def test_upload_unsupported_type_returns_400(self, client):
with patch("app.routers.documents.get_supabase") as mock_sb:
mock_sb.return_value = _make_doc_sb()
resp = client.post(
"/api/v1/chatbots/cb-1/documents",
files={"file": ("image.png", b"image data", "image/png")},
headers=AUTH,
)
assert resp.status_code == 400
assert "not supported" in resp.json()["detail"]
@pytest.mark.parametrize("filename,mime", [
("report.pdf", "application/pdf"),
("data.csv", "text/csv"),
("doc.txt", "text/plain"),
("notes.md", "text/markdown"),
])
def test_upload_accepted_types(self, client, filename, mime):
with patch("app.routers.documents.get_supabase") as mock_sb, \
patch("app.routers.documents._process_document_bg", new_callable=AsyncMock):
mock_sb.return_value = _make_doc_sb()
resp = client.post(
"/api/v1/chatbots/cb-1/documents",
files={"file": (filename, b"content", mime)},
headers=AUTH,
)
assert resp.status_code == 201
def test_upload_company_not_found_returns_404(self, client):
with patch("app.routers.documents.get_supabase") as mock_sb:
mock_sb.return_value = _make_doc_sb(company=False)
resp = client.post(
"/api/v1/chatbots/cb-1/documents",
files={"file": ("test.pdf", b"data", "application/pdf")},
headers=AUTH,
)
assert resp.status_code == 404
def test_upload_chatbot_not_found_returns_404(self, client):
with patch("app.routers.documents.get_supabase") as mock_sb:
mock_sb.return_value = _make_doc_sb(chatbot=False)
resp = client.post(
"/api/v1/chatbots/cb-1/documents",
files={"file": ("test.pdf", b"data", "application/pdf")},
headers=AUTH,
)
assert resp.status_code == 404
def test_upload_response_has_processing_status(self, client):
with patch("app.routers.documents.get_supabase") as mock_sb, \
patch("app.routers.documents._process_document_bg", new_callable=AsyncMock):
mock_sb.return_value = _make_doc_sb()
resp = client.post(
"/api/v1/chatbots/cb-1/documents",
files={"file": ("report.pdf", b"pdf content", "application/pdf")},
headers=AUTH,
)
assert resp.status_code == 201
assert resp.json()["status"] == "processing"
class TestDocumentList:
def test_list_returns_documents(self, client):
with patch("app.routers.documents.get_supabase") as mock_sb:
mock_sb.return_value = _make_doc_sb()
resp = client.get("/api/v1/chatbots/cb-1/documents", headers=AUTH)
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
assert len(data) == 1
assert data[0]["file_name"] == "test.pdf"
def test_list_chatbot_not_found_returns_404(self, client):
with patch("app.routers.documents.get_supabase") as mock_sb:
mock_sb.return_value = _make_doc_sb(chatbot=False)
resp = client.get("/api/v1/chatbots/cb-1/documents", headers=AUTH)
assert resp.status_code == 404
class TestDocumentDelete:
def test_delete_document_not_found_returns_404(self, client):
# Override the documents table to return empty for doc lookup
def table_side(name):
m = MagicMock()
m.select.return_value = m
m.insert.return_value = m
m.update.return_value = m
m.delete.return_value = m
m.eq.return_value = m
m.in_.return_value = m
m.limit.return_value = m
m.order.return_value = m
if name == "companies":
m.execute.return_value = MagicMock(data=[{"id": "company-1"}])
elif name == "chatbots":
m.execute.return_value = MagicMock(
data=[{"id": "cb-1", "company_id": "company-1",
"qdrant_collection_name": "col-1"}]
)
elif name == "documents":
m.execute.return_value = MagicMock(data=[])
else:
m.execute.return_value = MagicMock(data=[])
return m
sb = MagicMock()
sb.table.side_effect = table_side
sb.auth = MagicMock()
with patch("app.routers.documents.get_supabase", return_value=sb):
resp = client.delete("/api/v1/chatbots/cb-1/documents/no-such-doc", headers=AUTH)
assert resp.status_code == 404
def test_delete_success(self, client):
doc = {
"id": "doc-1",
"chatbot_id": "cb-1",
"file_name": "report.pdf",
"file_type": ".pdf",
"file_size": 1024,
"chunk_count": 5,
"status": "completed",
"created_at": "2024-01-01T00:00:00",
"updated_at": "2024-01-01T00:00:00",
"error_message": None,
"file_url": None,
}
with patch("app.routers.documents.get_supabase") as mock_sb, \
patch("app.routers.documents.vector_store") as mock_vs:
mock_vs.delete_by_document_id = MagicMock()
mock_sb.return_value = _make_doc_sb(doc=doc)
resp = client.delete("/api/v1/chatbots/cb-1/documents/doc-1", headers=AUTH)
assert resp.status_code == 200
assert resp.json()["success"] is True
class TestUrlSources:
def test_list_url_sources_requires_auth(self, client):
resp = client.get("/api/v1/chatbots/cb-1/url-sources")
assert resp.status_code == 401
def test_add_url_source_requires_auth(self, client):
resp = client.post("/api/v1/chatbots/cb-1/url-sources", json={"url": "https://example.com"})
assert resp.status_code == 401
def test_add_url_source_free_plan_blocked(self, client):
def table_side(name):
m = MagicMock()
m.select.return_value = m
m.eq.return_value = m
m.execute.return_value = MagicMock(data=[], count=0)
if name == "companies":
m.execute.return_value = MagicMock(data=[{"id": "comp-1"}])
elif name == "chatbots":
m.execute.return_value = MagicMock(data=[{"id": "cb-1", "company_id": "comp-1"}])
elif name == "subscriptions":
m.execute.return_value = MagicMock(data=[{"plan": "free"}])
return m
sb = MagicMock()
sb.table.side_effect = table_side
sb.auth = MagicMock()
with patch("app.routers.documents.get_supabase", return_value=sb):
resp = client.post(
"/api/v1/chatbots/cb-1/url-sources",
json={"url": "https://example.com"},
headers=AUTH,
)
assert resp.status_code == 402
def test_list_url_sources_returns_empty(self, client):
with patch("app.routers.documents.get_supabase") as mock_sb:
mock_sb.return_value = _make_doc_sb()
resp = client.get("/api/v1/chatbots/cb-1/url-sources", headers=AUTH)
assert resp.status_code == 200
assert resp.json() == []

428
tests/test_inbox.py Normal file
View File

@@ -0,0 +1,428 @@
"""
Tests for inbox endpoints:
GET /api/v1/inbox/conversations
GET /api/v1/inbox/conversations/{id}
PATCH /api/v1/inbox/conversations/{id}/status
POST /api/v1/inbox/conversations/{id}/reply
DELETE /api/v1/inbox/conversations/{id}
"""
import pytest
from unittest.mock import MagicMock, patch
# ── Helpers ────────────────────────────────────────────────────────────────────
def make_user(uid="user-1"):
u = MagicMock()
u.id = uid
u.email = "user@example.com"
return u
def make_supabase(plan="starter", company_id="company-1", conversations=None,
messages=None, conversation=None):
"""
Build a Supabase mock wired for inbox tests.
table() calls are routed by table name; every chain returns self so
.select().eq()…execute() works.
"""
sb = MagicMock()
def table_side(name):
t = MagicMock()
t.select = MagicMock(return_value=t)
t.insert = MagicMock(return_value=t)
t.update = MagicMock(return_value=t)
t.delete = MagicMock(return_value=t)
t.eq = MagicMock(return_value=t)
t.neq = MagicMock(return_value=t)
t.in_ = MagicMock(return_value=t)
t.order = MagicMock(return_value=t)
t.range = MagicMock(return_value=t)
t.limit = MagicMock(return_value=t)
if name == "subscriptions":
t.execute = MagicMock(return_value=MagicMock(data=[{"plan": plan}]))
elif name == "companies":
t.execute = MagicMock(return_value=MagicMock(data=[{"id": company_id}]))
elif name == "chatbots":
t.execute = MagicMock(return_value=MagicMock(data=[
{"id": "chatbot-1", "name": "My Bot", "company_id": company_id}
]))
elif name == "conversations":
if conversation is not None:
t.execute = MagicMock(return_value=MagicMock(data=[conversation]))
else:
rows = conversations or [
{"id": "conv-1", "chatbot_id": "chatbot-1", "session_id": "s1",
"language": "en", "message_count": 3, "status": "open",
"last_agent_reply_at": None, "created_at": "2024-01-01T00:00:00"},
]
t.execute = MagicMock(return_value=MagicMock(data=rows))
elif name == "messages":
rows = messages or [
{"id": "msg-1", "role": "user", "content": "Hello",
"sources": None, "confidence_score": None,
"is_handoff": False, "created_at": "2024-01-01T00:00:00"},
]
t.execute = MagicMock(return_value=MagicMock(data=rows))
else:
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb.table.side_effect = table_side
return sb
# ── Tests: list conversations ──────────────────────────────────────────────────
class TestListConversations:
def test_requires_auth(self, client):
resp = client.get("/api/v1/inbox/conversations")
assert resp.status_code == 401
def test_free_plan_returns_402(self, client):
user = make_user()
sb = make_supabase(plan="free")
with patch("app.routers.inbox.get_current_user", return_value=user), \
patch("app.routers.inbox.get_supabase", return_value=sb):
resp = client.get("/api/v1/inbox/conversations",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 402
def test_returns_conversation_list(self, client):
user = make_user()
sb = make_supabase(plan="starter")
with patch("app.routers.inbox.get_current_user", return_value=user), \
patch("app.routers.inbox.get_supabase", return_value=sb):
resp = client.get("/api/v1/inbox/conversations",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
assert len(data) == 1
assert data[0]["id"] == "conv-1"
assert data[0]["status"] == "open"
assert data[0]["chatbot_name"] == "My Bot"
def test_empty_when_no_chatbots(self, client):
user = make_user()
sb = make_supabase(plan="starter")
# Override chatbots table to return nothing
original_side = sb.table.side_effect
def patched(name):
t = original_side(name)
if name == "chatbots":
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb.table.side_effect = patched
with patch("app.routers.inbox.get_current_user", return_value=user), \
patch("app.routers.inbox.get_supabase", return_value=sb):
resp = client.get("/api/v1/inbox/conversations",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
assert resp.json() == []
def test_pagination_params_accepted(self, client):
user = make_user()
sb = make_supabase(plan="starter")
with patch("app.routers.inbox.get_current_user", return_value=user), \
patch("app.routers.inbox.get_supabase", return_value=sb):
resp = client.get("/api/v1/inbox/conversations?page=2&limit=5",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
# ── Tests: get single conversation ────────────────────────────────────────────
class TestGetConversation:
def _owned_conv(self, company_id="company-1"):
return {
"id": "conv-1", "chatbot_id": "chatbot-1",
"session_id": "s1", "language": "en",
"status": "open", "last_agent_reply_at": None,
"created_at": "2024-01-01T00:00:00",
"chatbots": {"company_id": company_id, "name": "My Bot"},
}
def test_requires_auth(self, client):
resp = client.get("/api/v1/inbox/conversations/conv-1")
assert resp.status_code == 401
def test_returns_messages(self, client):
user = make_user()
conv = self._owned_conv()
sb = make_supabase(plan="starter", conversation=conv)
with patch("app.routers.inbox.get_current_user", return_value=user), \
patch("app.routers.inbox.get_supabase", return_value=sb):
resp = client.get("/api/v1/inbox/conversations/conv-1",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
body = resp.json()
assert body["conversation_id"] == "conv-1"
assert len(body["messages"]) == 1
assert body["messages"][0]["role"] == "user"
def test_returns_404_when_not_found(self, client):
user = make_user()
sb = make_supabase(plan="starter", conversation=None, conversations=[])
# conversations table returns empty
original = sb.table.side_effect
def patched(name):
t = original(name)
if name == "conversations":
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb.table.side_effect = patched
with patch("app.routers.inbox.get_current_user", return_value=user), \
patch("app.routers.inbox.get_supabase", return_value=sb):
resp = client.get("/api/v1/inbox/conversations/nonexistent",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 404
def test_returns_403_for_other_companys_conversation(self, client):
user = make_user()
# Conversation belongs to company-OTHER, user belongs to company-1
conv = self._owned_conv(company_id="company-OTHER")
sb = make_supabase(plan="starter", company_id="company-1", conversation=conv)
with patch("app.routers.inbox.get_current_user", return_value=user), \
patch("app.routers.inbox.get_supabase", return_value=sb):
resp = client.get("/api/v1/inbox/conversations/conv-1",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 403
# ── Tests: update conversation status ─────────────────────────────────────────
class TestUpdateConversationStatus:
def _owned_conv(self, company_id="company-1", status="open"):
return {
"id": "conv-1", "status": status,
"chatbots": {"company_id": company_id},
}
def test_requires_auth(self, client):
resp = client.patch("/api/v1/inbox/conversations/conv-1/status",
json={"status": "resolved"})
assert resp.status_code == 401
def test_valid_statuses_accepted(self, client):
for s in ("open", "agent_handling", "resolved"):
user = make_user()
conv = self._owned_conv(status="open")
sb = make_supabase(plan="starter", conversation=conv)
with patch("app.routers.inbox.get_current_user", return_value=user), \
patch("app.routers.inbox.get_supabase", return_value=sb):
resp = client.patch("/api/v1/inbox/conversations/conv-1/status",
json={"status": s},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
assert resp.json()["status"] == s
def test_invalid_status_returns_400(self, client):
user = make_user()
conv = self._owned_conv()
sb = make_supabase(plan="starter", conversation=conv)
with patch("app.routers.inbox.get_current_user", return_value=user), \
patch("app.routers.inbox.get_supabase", return_value=sb):
resp = client.patch("/api/v1/inbox/conversations/conv-1/status",
json={"status": "invalid_status"},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 400
def test_403_for_other_companys_conversation(self, client):
user = make_user()
conv = self._owned_conv(company_id="company-OTHER")
sb = make_supabase(plan="starter", company_id="company-1", conversation=conv)
with patch("app.routers.inbox.get_current_user", return_value=user), \
patch("app.routers.inbox.get_supabase", return_value=sb):
resp = client.patch("/api/v1/inbox/conversations/conv-1/status",
json={"status": "resolved"},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 403
def test_404_when_not_found(self, client):
user = make_user()
sb = make_supabase(plan="starter")
original = sb.table.side_effect
def patched(name):
t = original(name)
if name == "conversations":
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb.table.side_effect = patched
with patch("app.routers.inbox.get_current_user", return_value=user), \
patch("app.routers.inbox.get_supabase", return_value=sb):
resp = client.patch("/api/v1/inbox/conversations/nonexistent/status",
json={"status": "resolved"},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 404
# ── Tests: agent reply ─────────────────────────────────────────────────────────
class TestAgentReply:
def _owned_conv(self, company_id="company-1", status="open"):
return {
"id": "conv-1", "status": status,
"chatbots": {"company_id": company_id},
}
def test_requires_auth(self, client):
resp = client.post("/api/v1/inbox/conversations/conv-1/reply",
json={"message": "Hello!"})
assert resp.status_code == 401
def test_sends_reply_and_returns_message_id(self, client):
user = make_user()
conv = self._owned_conv(status="open")
sb = make_supabase(plan="starter", conversation=conv)
with patch("app.routers.inbox.get_current_user", return_value=user), \
patch("app.routers.inbox.get_supabase", return_value=sb):
resp = client.post("/api/v1/inbox/conversations/conv-1/reply",
json={"message": "Thanks for contacting us!"},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
body = resp.json()
assert body["success"] is True
assert "message_id" in body
def test_empty_message_returns_422(self, client):
user = make_user()
conv = self._owned_conv()
sb = make_supabase(plan="starter", conversation=conv)
with patch("app.routers.inbox.get_current_user", return_value=user), \
patch("app.routers.inbox.get_supabase", return_value=sb):
resp = client.post("/api/v1/inbox/conversations/conv-1/reply",
json={"message": ""},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 422
def test_sets_status_to_agent_handling_when_open(self, client):
user = make_user()
conv = self._owned_conv(status="open")
sb = make_supabase(plan="starter", conversation=conv)
update_calls = []
original = sb.table.side_effect
def patched(name):
t = original(name)
if name == "conversations":
orig_update = t.update
def track_update(data):
update_calls.append(data)
return t
t.update = track_update
return t
sb.table.side_effect = patched
with patch("app.routers.inbox.get_current_user", return_value=user), \
patch("app.routers.inbox.get_supabase", return_value=sb):
resp = client.post("/api/v1/inbox/conversations/conv-1/reply",
json={"message": "Hello"},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
# Should have updated status to agent_handling
statuses = [c.get("status") for c in update_calls if "status" in c]
assert "agent_handling" in statuses
def test_does_not_change_status_when_already_agent_handling(self, client):
user = make_user()
conv = self._owned_conv(status="agent_handling")
sb = make_supabase(plan="starter", conversation=conv)
update_calls = []
original = sb.table.side_effect
def patched(name):
t = original(name)
if name == "conversations":
orig_update = t.update
def track_update(data):
update_calls.append(data)
return t
t.update = track_update
return t
sb.table.side_effect = patched
with patch("app.routers.inbox.get_current_user", return_value=user), \
patch("app.routers.inbox.get_supabase", return_value=sb):
resp = client.post("/api/v1/inbox/conversations/conv-1/reply",
json={"message": "Follow-up"},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
# status should NOT appear in any update when already agent_handling
new_statuses = [c.get("status") for c in update_calls if "status" in c]
assert "agent_handling" not in new_statuses
def test_403_for_other_companys_conversation(self, client):
user = make_user()
conv = self._owned_conv(company_id="company-OTHER")
sb = make_supabase(plan="starter", company_id="company-1", conversation=conv)
with patch("app.routers.inbox.get_current_user", return_value=user), \
patch("app.routers.inbox.get_supabase", return_value=sb):
resp = client.post("/api/v1/inbox/conversations/conv-1/reply",
json={"message": "Hi"},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 403
def test_free_plan_returns_402(self, client):
user = make_user()
sb = make_supabase(plan="free")
with patch("app.routers.inbox.get_current_user", return_value=user), \
patch("app.routers.inbox.get_supabase", return_value=sb):
resp = client.post("/api/v1/inbox/conversations/conv-1/reply",
json={"message": "Hi"},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 402
# ── Tests: delete conversation ─────────────────────────────────────────────────
class TestDeleteConversation:
def _owned_conv(self, company_id="company-1"):
return {
"id": "conv-1",
"chatbots": {"company_id": company_id},
}
def test_requires_auth(self, client):
resp = client.delete("/api/v1/inbox/conversations/conv-1")
assert resp.status_code == 401
def test_deletes_successfully(self, client):
user = make_user()
conv = self._owned_conv()
sb = make_supabase(plan="starter", conversation=conv)
with patch("app.routers.inbox.get_current_user", return_value=user), \
patch("app.routers.inbox.get_supabase", return_value=sb):
resp = client.delete("/api/v1/inbox/conversations/conv-1",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
assert resp.json()["success"] is True
def test_returns_404_when_not_found(self, client):
user = make_user()
sb = make_supabase(plan="starter")
original = sb.table.side_effect
def patched(name):
t = original(name)
if name == "conversations":
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb.table.side_effect = patched
with patch("app.routers.inbox.get_current_user", return_value=user), \
patch("app.routers.inbox.get_supabase", return_value=sb):
resp = client.delete("/api/v1/inbox/conversations/nonexistent",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 404
def test_returns_403_for_other_companys_conversation(self, client):
user = make_user()
conv = self._owned_conv(company_id="company-OTHER")
sb = make_supabase(plan="starter", company_id="company-1", conversation=conv)
with patch("app.routers.inbox.get_current_user", return_value=user), \
patch("app.routers.inbox.get_supabase", return_value=sb):
resp = client.delete("/api/v1/inbox/conversations/conv-1",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 403

405
tests/test_leads.py Normal file
View File

@@ -0,0 +1,405 @@
"""
Tests for lead endpoints:
GET /api/v1/leads
PATCH /api/v1/leads/{id}
GET /api/v1/leads/export
POST /api/v1/chatbots/{chatbot_id}/leads (public)
"""
import pytest
from unittest.mock import MagicMock, patch
# ── Helpers ────────────────────────────────────────────────────────────────────
def make_user(uid="user-1"):
u = MagicMock()
u.id = uid
u.email = "user@example.com"
return u
SAMPLE_LEAD = {
"id": "lead-1",
"chatbot_id": "chatbot-1",
"conversation_id": None,
"email": "lead@example.com",
"name": "Jane Doe",
"phone": "+1234",
"company": "ACME",
"status": "new",
"notes": None,
"created_at": "2024-01-01T00:00:00",
}
def make_supabase(plan="starter", company_id="company-1",
leads=None, lead=None, chatbot_enabled=True):
sb = MagicMock()
def table_side(name):
t = MagicMock()
t.select = MagicMock(return_value=t)
t.insert = MagicMock(return_value=t)
t.update = MagicMock(return_value=t)
t.delete = MagicMock(return_value=t)
t.eq = MagicMock(return_value=t)
t.in_ = MagicMock(return_value=t)
t.order = MagicMock(return_value=t)
t.range = MagicMock(return_value=t)
t.limit = MagicMock(return_value=t)
t.neq = MagicMock(return_value=t)
if name == "subscriptions":
t.execute = MagicMock(return_value=MagicMock(data=[{"plan": plan}]))
elif name == "companies":
t.execute = MagicMock(return_value=MagicMock(data=[{"id": company_id}]))
elif name == "chatbots":
t.execute = MagicMock(return_value=MagicMock(data=[
{"id": "chatbot-1", "company_id": company_id,
"lead_capture_enabled": chatbot_enabled}
]))
elif name == "leads":
if lead is not None:
t.execute = MagicMock(return_value=MagicMock(data=[lead]))
else:
t.execute = MagicMock(return_value=MagicMock(
data=leads if leads is not None else [SAMPLE_LEAD]
))
else:
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb.table.side_effect = table_side
return sb
# ── Tests: list leads ─────────────────────────────────────────────────────────
class TestListLeads:
def test_requires_auth(self, client):
resp = client.get("/api/v1/leads")
assert resp.status_code == 401
def test_free_plan_returns_402(self, client):
user = make_user()
sb = make_supabase(plan="free")
with patch("app.routers.leads.get_current_user", return_value=user), \
patch("app.routers.leads.get_supabase", return_value=sb):
resp = client.get("/api/v1/leads", headers={"Authorization": "Bearer tok"})
assert resp.status_code == 402
def test_returns_lead_list(self, client):
user = make_user()
sb = make_supabase(plan="starter")
with patch("app.routers.leads.get_current_user", return_value=user), \
patch("app.routers.leads.get_supabase", return_value=sb):
resp = client.get("/api/v1/leads", headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
assert data[0]["email"] == "lead@example.com"
assert data[0]["status"] == "new"
def test_returns_empty_when_no_chatbots(self, client):
user = make_user()
sb = make_supabase(plan="starter")
original = sb.table.side_effect
def patched(name):
t = original(name)
if name == "chatbots":
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb.table.side_effect = patched
with patch("app.routers.leads.get_current_user", return_value=user), \
patch("app.routers.leads.get_supabase", return_value=sb):
resp = client.get("/api/v1/leads", headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
assert resp.json() == []
def test_pagination_params(self, client):
user = make_user()
sb = make_supabase(plan="starter")
with patch("app.routers.leads.get_current_user", return_value=user), \
patch("app.routers.leads.get_supabase", return_value=sb):
resp = client.get("/api/v1/leads?page=2&limit=10",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
# ── Tests: update lead ─────────────────────────────────────────────────────────
class TestUpdateLead:
def _make_owned_lead(self, company_id="company-1", status="new"):
return {**SAMPLE_LEAD, "status": status,
"chatbots": {"company_id": company_id}}
def test_requires_auth(self, client):
resp = client.patch("/api/v1/leads/lead-1", json={"status": "contacted"})
assert resp.status_code == 401
def test_update_status(self, client):
user = make_user()
owned_lead = self._make_owned_lead()
updated_lead = {**SAMPLE_LEAD, "status": "contacted"}
call_count = {"n": 0}
def table_side(name):
t = MagicMock()
t.select = MagicMock(return_value=t)
t.update = MagicMock(return_value=t)
t.eq = MagicMock(return_value=t)
t.in_ = MagicMock(return_value=t)
t.order = MagicMock(return_value=t)
if name == "subscriptions":
t.execute = MagicMock(return_value=MagicMock(data=[{"plan": "starter"}]))
elif name == "companies":
t.execute = MagicMock(return_value=MagicMock(data=[{"id": "company-1"}]))
elif name == "leads":
call_count["n"] += 1
if call_count["n"] == 1:
# First call: ownership check (select with chatbots join)
t.execute = MagicMock(return_value=MagicMock(data=[owned_lead]))
else:
# Second call: update returns updated row
t.execute = MagicMock(return_value=MagicMock(data=[updated_lead]))
else:
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb = MagicMock()
sb.table.side_effect = table_side
with patch("app.routers.leads.get_current_user", return_value=user), \
patch("app.routers.leads.get_supabase", return_value=sb):
resp = client.patch("/api/v1/leads/lead-1",
json={"status": "contacted"},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
assert resp.json()["status"] == "contacted"
def test_update_notes(self, client):
user = make_user()
owned_lead = self._make_owned_lead()
updated_lead = {**SAMPLE_LEAD, "notes": "Called on Monday"}
call_count = {"n": 0}
def table_side(name):
t = MagicMock()
t.select = MagicMock(return_value=t)
t.update = MagicMock(return_value=t)
t.eq = MagicMock(return_value=t)
t.in_ = MagicMock(return_value=t)
t.order = MagicMock(return_value=t)
if name == "subscriptions":
t.execute = MagicMock(return_value=MagicMock(data=[{"plan": "business"}]))
elif name == "companies":
t.execute = MagicMock(return_value=MagicMock(data=[{"id": "company-1"}]))
elif name == "leads":
call_count["n"] += 1
if call_count["n"] == 1:
t.execute = MagicMock(return_value=MagicMock(data=[owned_lead]))
else:
t.execute = MagicMock(return_value=MagicMock(data=[updated_lead]))
else:
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb = MagicMock()
sb.table.side_effect = table_side
with patch("app.routers.leads.get_current_user", return_value=user), \
patch("app.routers.leads.get_supabase", return_value=sb):
resp = client.patch("/api/v1/leads/lead-1",
json={"notes": "Called on Monday"},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
assert resp.json()["notes"] == "Called on Monday"
def test_invalid_status_returns_400(self, client):
user = make_user()
owned_lead = self._make_owned_lead()
sb = make_supabase(plan="starter", lead=owned_lead)
with patch("app.routers.leads.get_current_user", return_value=user), \
patch("app.routers.leads.get_supabase", return_value=sb):
resp = client.patch("/api/v1/leads/lead-1",
json={"status": "banana"},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 400
def test_valid_statuses_accepted(self, client):
for status in ("new", "contacted", "qualified", "closed", "lost"):
user = make_user()
owned_lead = self._make_owned_lead()
updated = {**SAMPLE_LEAD, "status": status}
call_count = {"n": 0}
def table_side(name, _status=status):
t = MagicMock()
t.select = MagicMock(return_value=t)
t.update = MagicMock(return_value=t)
t.eq = MagicMock(return_value=t)
t.in_ = MagicMock(return_value=t)
t.order = MagicMock(return_value=t)
if name == "subscriptions":
t.execute = MagicMock(return_value=MagicMock(data=[{"plan": "starter"}]))
elif name == "companies":
t.execute = MagicMock(return_value=MagicMock(data=[{"id": "company-1"}]))
elif name == "leads":
call_count["n"] += 1
if call_count["n"] == 1:
t.execute = MagicMock(return_value=MagicMock(data=[owned_lead]))
else:
t.execute = MagicMock(return_value=MagicMock(
data=[{**SAMPLE_LEAD, "status": _status}]))
else:
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb = MagicMock()
sb.table.side_effect = table_side
with patch("app.routers.leads.get_current_user", return_value=user), \
patch("app.routers.leads.get_supabase", return_value=sb):
resp = client.patch("/api/v1/leads/lead-1",
json={"status": status},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200, f"Failed for status={status}"
def test_not_found_returns_404(self, client):
user = make_user()
sb = make_supabase(plan="starter", leads=[])
original = sb.table.side_effect
def patched(name):
t = original(name)
if name == "leads":
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb.table.side_effect = patched
with patch("app.routers.leads.get_current_user", return_value=user), \
patch("app.routers.leads.get_supabase", return_value=sb):
resp = client.patch("/api/v1/leads/nonexistent",
json={"status": "contacted"},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 404
def test_403_for_other_companys_lead(self, client):
user = make_user()
owned_lead = {**SAMPLE_LEAD, "chatbots": {"company_id": "company-OTHER"}}
sb = make_supabase(plan="starter", company_id="company-1", lead=owned_lead)
with patch("app.routers.leads.get_current_user", return_value=user), \
patch("app.routers.leads.get_supabase", return_value=sb):
resp = client.patch("/api/v1/leads/lead-1",
json={"status": "contacted"},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 403
# ── Tests: export leads CSV ────────────────────────────────────────────────────
class TestExportLeads:
def test_requires_auth(self, client):
resp = client.get("/api/v1/leads/export")
assert resp.status_code == 401
def test_returns_csv_content_type(self, client):
user = make_user()
sb = make_supabase(plan="starter")
with patch("app.routers.leads.get_current_user", return_value=user), \
patch("app.routers.leads.get_supabase", return_value=sb):
resp = client.get("/api/v1/leads/export",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
assert "text/csv" in resp.headers["content-type"]
def test_csv_contains_headers_and_data(self, client):
user = make_user()
sb = make_supabase(plan="starter")
with patch("app.routers.leads.get_current_user", return_value=user), \
patch("app.routers.leads.get_supabase", return_value=sb):
resp = client.get("/api/v1/leads/export",
headers={"Authorization": "Bearer tok"})
text = resp.text
assert "email" in text
assert "lead@example.com" in text
def test_empty_csv_when_no_chatbots(self, client):
user = make_user()
sb = make_supabase(plan="starter")
original = sb.table.side_effect
def patched(name):
t = original(name)
if name == "chatbots":
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb.table.side_effect = patched
with patch("app.routers.leads.get_current_user", return_value=user), \
patch("app.routers.leads.get_supabase", return_value=sb):
resp = client.get("/api/v1/leads/export",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
# Only the header row
lines = [l for l in resp.text.strip().split("\n") if l]
assert len(lines) == 1
# ── Tests: public lead submission ─────────────────────────────────────────────
class TestPublicLeadSubmit:
def test_submit_lead_success(self, client):
sb = make_supabase(chatbot_enabled=True)
# No existing duplicate
original = sb.table.side_effect
call_count = {"n": 0}
def patched(name):
t = original(name)
if name == "leads":
call_count["n"] += 1
if call_count["n"] == 1:
# dedup check — no existing
t.execute = MagicMock(return_value=MagicMock(data=[]))
else:
# insert result
t.execute = MagicMock(return_value=MagicMock(data=[SAMPLE_LEAD]))
return t
sb.table.side_effect = patched
with patch("app.routers.leads.get_supabase", return_value=sb):
resp = client.post("/api/v1/chatbots/chatbot-1/leads",
json={"email": "lead@example.com", "name": "Jane Doe"})
assert resp.status_code == 201
assert resp.json()["email"] == "lead@example.com"
def test_returns_existing_lead_on_duplicate_email(self, client):
"""Deduplication: same email + chatbot_id returns existing row."""
existing = {**SAMPLE_LEAD, "id": "lead-existing"}
sb = make_supabase(chatbot_enabled=True)
original = sb.table.side_effect
def patched(name):
t = original(name)
if name == "leads":
t.execute = MagicMock(return_value=MagicMock(data=[existing]))
return t
sb.table.side_effect = patched
with patch("app.routers.leads.get_supabase", return_value=sb):
resp = client.post("/api/v1/chatbots/chatbot-1/leads",
json={"email": "lead@example.com", "name": "Jane"})
assert resp.status_code == 201
assert resp.json()["id"] == "lead-existing"
def test_404_when_chatbot_not_found(self, client):
sb = make_supabase()
original = sb.table.side_effect
def patched(name):
t = original(name)
if name == "chatbots":
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb.table.side_effect = patched
with patch("app.routers.leads.get_supabase", return_value=sb):
resp = client.post("/api/v1/chatbots/nonexistent/leads",
json={"email": "a@b.com"})
assert resp.status_code == 404
def test_400_when_lead_capture_disabled(self, client):
sb = make_supabase(chatbot_enabled=False)
with patch("app.routers.leads.get_supabase", return_value=sb):
resp = client.post("/api/v1/chatbots/chatbot-1/leads",
json={"email": "a@b.com"})
assert resp.status_code == 400

212
tests/test_marketplace.py Normal file
View File

@@ -0,0 +1,212 @@
"""Tests for marketplace endpoints."""
import pytest
from unittest.mock import MagicMock, patch
AUTH = {"Authorization": "Bearer test-token"}
def _make_marketplace_sb(chatbot_data=None, count=0):
"""Build a supabase mock for marketplace queries."""
sb = MagicMock()
def table_side(name):
m = MagicMock()
m.select.return_value = m
m.insert.return_value = m
m.update.return_value = m
m.delete.return_value = m
m.eq.return_value = m
m.in_.return_value = m
m.ilike.return_value = m
m.limit.return_value = m
m.order.return_value = m
m.range.return_value = m
m.gte.return_value = m
m.lt.return_value = m
if name == "chatbots":
m.execute.return_value = MagicMock(
data=chatbot_data if chatbot_data is not None else [],
count=count,
)
elif name == "conversations":
m.execute.return_value = MagicMock(data=[], count=0)
else:
m.execute.return_value = MagicMock(data=[], count=0)
return m
sb.table.side_effect = table_side
sb.auth = MagicMock()
return sb
class TestMarketplaceList:
def test_list_returns_empty_when_no_chatbots(self, client):
with patch("app.routers.marketplace.get_supabase") as mock_sb:
mock_sb.return_value = _make_marketplace_sb()
resp = client.get("/api/v1/marketplace/chatbots")
assert resp.status_code == 200
body = resp.json()
assert body["chatbots"] == []
assert body["total"] == 0
def test_list_returns_chatbots(self, client):
bots = [{
"id": "bot-1",
"name": "Support Bot",
"description": "A test bot",
"category": "Customer Support",
"industry": "Technology & SaaS",
"languages": ["en"],
"primary_color": "#6366f1",
"welcome_message": "Hello!",
"logo_url": None,
"average_rating": 4.5,
"total_conversations": 100,
"is_published": True,
"created_at": "2024-01-01T00:00:00",
"published_at": "2024-01-02T00:00:00",
"companies": {"name": "Acme Inc", "logo_url": None},
}]
with patch("app.routers.marketplace.get_supabase") as mock_sb:
mock_sb.return_value = _make_marketplace_sb(chatbot_data=bots, count=1)
resp = client.get("/api/v1/marketplace/chatbots")
assert resp.status_code == 200
body = resp.json()
assert len(body["chatbots"]) == 1
assert body["chatbots"][0]["name"] == "Support Bot"
assert body["chatbots"][0]["company_name"] == "Acme Inc"
def test_list_pagination_fields(self, client):
with patch("app.routers.marketplace.get_supabase") as mock_sb:
mock_sb.return_value = _make_marketplace_sb(count=50)
resp = client.get("/api/v1/marketplace/chatbots?page=1&limit=20")
assert resp.status_code == 200
body = resp.json()
assert body["page"] == 1
assert body["limit"] == 20
assert "has_more" in body
def test_list_limit_max_100(self, client):
with patch("app.routers.marketplace.get_supabase") as mock_sb:
mock_sb.return_value = _make_marketplace_sb()
resp = client.get("/api/v1/marketplace/chatbots?limit=200")
# FastAPI should reject > 100
assert resp.status_code == 422
def test_list_accepts_category_filter(self, client):
with patch("app.routers.marketplace.get_supabase") as mock_sb:
mock_sb.return_value = _make_marketplace_sb()
resp = client.get("/api/v1/marketplace/chatbots?category=Customer+Support")
assert resp.status_code == 200
def test_list_accepts_search_filter(self, client):
with patch("app.routers.marketplace.get_supabase") as mock_sb:
mock_sb.return_value = _make_marketplace_sb()
resp = client.get("/api/v1/marketplace/chatbots?search=bot")
assert resp.status_code == 200
def test_has_more_true_when_more_results(self, client):
with patch("app.routers.marketplace.get_supabase") as mock_sb:
mock_sb.return_value = _make_marketplace_sb(count=100)
resp = client.get("/api/v1/marketplace/chatbots?page=1&limit=20")
assert resp.json()["has_more"] is True
def test_has_more_false_on_last_page(self, client):
with patch("app.routers.marketplace.get_supabase") as mock_sb:
mock_sb.return_value = _make_marketplace_sb(count=10)
resp = client.get("/api/v1/marketplace/chatbots?page=1&limit=20")
assert resp.json()["has_more"] is False
class TestMarketplaceDetail:
def test_detail_not_found_returns_404(self, client):
with patch("app.routers.marketplace.get_supabase") as mock_sb:
mock_sb.return_value = _make_marketplace_sb(chatbot_data=[])
resp = client.get("/api/v1/marketplace/chatbots/nonexistent-id")
assert resp.status_code == 404
def test_detail_returns_chatbot(self, client):
bot = {
"id": "bot-1",
"name": "My Bot",
"description": "desc",
"category": "FAQ & Knowledge Base",
"industry": "Education & Training",
"languages": ["en", "fr"],
"primary_color": "#000000",
"welcome_message": "Hi!",
"logo_url": None,
"average_rating": 3.8,
"total_conversations": 50,
"is_published": True,
"created_at": "2024-01-01T00:00:00",
"published_at": "2024-01-02T00:00:00",
"companies": {"name": "Test Co", "logo_url": None},
}
with patch("app.routers.marketplace.get_supabase") as mock_sb:
mock_sb.return_value = _make_marketplace_sb(chatbot_data=[bot])
resp = client.get("/api/v1/marketplace/chatbots/bot-1")
assert resp.status_code == 200
body = resp.json()
assert body["name"] == "My Bot"
assert body["languages"] == ["en", "fr"]
class TestMarketplaceCategories:
def test_categories_returns_lists(self, client):
resp = client.get("/api/v1/marketplace/categories")
assert resp.status_code == 200
body = resp.json()
assert "categories" in body
assert "industries" in body
assert isinstance(body["categories"], list)
assert isinstance(body["industries"], list)
assert len(body["categories"]) > 0
assert len(body["industries"]) > 0
def test_categories_includes_customer_support(self, client):
resp = client.get("/api/v1/marketplace/categories")
assert "Customer Support" in resp.json()["categories"]
class TestMarketplaceRating:
def test_rate_requires_auth(self, client):
resp = client.post("/api/v1/marketplace/chatbots/bot-1/rate", json={"rating": 4})
assert resp.status_code == 401
def test_rate_chatbot_not_found(self, client):
with patch("app.routers.marketplace.get_supabase") as mock_sb:
mock_sb.return_value = _make_marketplace_sb(chatbot_data=[])
resp = client.post(
"/api/v1/marketplace/chatbots/nonexistent/rate",
json={"rating": 4},
headers=AUTH,
)
assert resp.status_code == 404
def test_rate_chatbot_success(self, client):
bot = {"id": "bot-1", "average_rating": 4.0}
with patch("app.routers.marketplace.get_supabase") as mock_sb:
mock_sb.return_value = _make_marketplace_sb(chatbot_data=[bot])
resp = client.post(
"/api/v1/marketplace/chatbots/bot-1/rate",
json={"rating": 5},
headers=AUTH,
)
assert resp.status_code == 200
body = resp.json()
assert "new_average" in body
assert body["new_average"] == 4.5 # (4.0 + 5) / 2
def test_rate_chatbot_first_rating(self, client):
"""When average_rating is None, should use the submitted rating as both sides."""
bot = {"id": "bot-1", "average_rating": None}
with patch("app.routers.marketplace.get_supabase") as mock_sb:
mock_sb.return_value = _make_marketplace_sb(chatbot_data=[bot])
resp = client.post(
"/api/v1/marketplace/chatbots/bot-1/rate",
json={"rating": 5},
headers=AUTH,
)
assert resp.status_code == 200
assert resp.json()["new_average"] == 5.0

107
tests/test_models.py Normal file
View File

@@ -0,0 +1,107 @@
"""Tests for models router — plan-based model availability."""
import pytest
from unittest.mock import MagicMock, patch
AUTH = {"Authorization": "Bearer test-token"}
def _make_sb_with_plan(plan: str):
sb = MagicMock()
m = MagicMock()
m.select.return_value = m
m.eq.return_value = m
m.execute.return_value = MagicMock(data=[{"plan": plan}])
sb.table.return_value = m
sb.auth = MagicMock()
return sb
class TestModelsAuth:
def test_available_requires_auth(self, client):
resp = client.get("/api/v1/models/available")
assert resp.status_code == 401
class TestModelsAvailable:
@pytest.mark.parametrize("plan", ["free", "starter", "pro", "enterprise"])
def test_returns_200_for_all_plans(self, client, plan):
with patch("app.routers.models.get_supabase") as mock_sb:
mock_sb.return_value = _make_sb_with_plan(plan)
resp = client.get("/api/v1/models/available", headers=AUTH)
assert resp.status_code == 200
def test_response_shape(self, client):
with patch("app.routers.models.get_supabase") as mock_sb:
mock_sb.return_value = _make_sb_with_plan("starter")
resp = client.get("/api/v1/models/available", headers=AUTH)
body = resp.json()
assert "models" in body
assert "plan" in body
assert "has_premium_access" in body
assert isinstance(body["models"], list)
def test_free_plan_has_no_premium_access(self, client):
with patch("app.routers.models.get_supabase") as mock_sb:
mock_sb.return_value = _make_sb_with_plan("free")
resp = client.get("/api/v1/models/available", headers=AUTH)
body = resp.json()
assert body["has_premium_access"] is False
assert body["upgrade_label"] is not None
def test_enterprise_has_premium_access(self, client):
with patch("app.routers.models.get_supabase") as mock_sb:
mock_sb.return_value = _make_sb_with_plan("enterprise")
resp = client.get("/api/v1/models/available", headers=AUTH)
body = resp.json()
assert body["has_premium_access"] is True
assert body["upgrade_label"] is None
def test_model_fields_present(self, client):
with patch("app.routers.models.get_supabase") as mock_sb:
mock_sb.return_value = _make_sb_with_plan("starter")
resp = client.get("/api/v1/models/available", headers=AUTH)
body = resp.json()
if body["models"]:
model = body["models"][0]
assert "id" in model
assert "name" in model
assert "provider" in model
assert "badge" in model
assert "is_default" in model
def test_exactly_one_default_model_per_plan(self, client):
for plan in ["starter", "pro"]:
with patch("app.routers.models.get_supabase") as mock_sb:
mock_sb.return_value = _make_sb_with_plan(plan)
resp = client.get("/api/v1/models/available", headers=AUTH)
body = resp.json()
defaults = [m for m in body["models"] if m["is_default"]]
assert len(defaults) <= 1, f"Plan {plan} has {len(defaults)} default models"
def test_starter_upgrade_label_mentions_business(self, client):
with patch("app.routers.models.get_supabase") as mock_sb:
mock_sb.return_value = _make_sb_with_plan("starter")
resp = client.get("/api/v1/models/available", headers=AUTH)
body = resp.json()
assert body["upgrade_label"] is not None
def test_unknown_plan_falls_back_to_free(self, client):
"""An unknown plan should fall back to free-tier behavior without crashing."""
with patch("app.routers.models.get_supabase") as mock_sb:
mock_sb.return_value = _make_sb_with_plan("banana")
resp = client.get("/api/v1/models/available", headers=AUTH)
assert resp.status_code == 200
def test_no_active_subscription_defaults_to_free(self, client):
with patch("app.routers.models.get_supabase") as mock_sb:
sb = MagicMock()
m = MagicMock()
m.select.return_value = m
m.eq.return_value = m
m.execute.return_value = MagicMock(data=[])
sb.table.return_value = m
sb.auth = MagicMock()
mock_sb.return_value = sb
resp = client.get("/api/v1/models/available", headers=AUTH)
assert resp.status_code == 200
assert resp.json()["plan"] == "free"

118
tests/test_rag.py Normal file
View File

@@ -0,0 +1,118 @@
"""Tests for RAG pipeline."""
import pytest
from unittest.mock import MagicMock, AsyncMock, patch
class TestRAGEngine:
@pytest.fixture
def rag(self):
from app.services.rag import RAGEngine
engine = RAGEngine()
return engine
@pytest.fixture
def chatbot_config(self):
return {
"model": "accounts/fireworks/models/llama-v3p3-70b-instruct",
"max_tokens": 500,
"temperature": 0.7,
"company_name": "Test Corp",
"system_prompt": "You are helpful.",
}
async def test_returns_response_when_documents_found(self, rag, chatbot_config):
with patch.object(rag.embedding_svc, "embed_text", return_value=[0.1] * 1536), \
patch.object(rag.vector_svc, "search", return_value=[{
"payload": {"text": "Test content", "file_name": "test.pdf", "page_number": 1},
"score": 0.8,
}]), \
patch.object(rag.llm_svc, "generate", new_callable=AsyncMock, return_value={
"content": "Test response",
"tokens_used": 100,
"model": "test-model",
}):
result = await rag.process_query(
query="What is the test?",
collection_name="test-collection",
chatbot_config=chatbot_config,
language="en",
)
assert result["response"] == "Test response"
assert len(result["sources"]) == 1
assert result["sources"][0].score == 0.8
async def test_returns_graceful_message_on_embedding_failure(self, rag, chatbot_config):
with patch.object(rag.embedding_svc, "embed_text", side_effect=Exception("Embedding failed")):
result = await rag.process_query(
query="Test query",
collection_name="test-collection",
chatbot_config=chatbot_config,
)
assert "trouble" in result["response"].lower()
assert result["sources"] == []
async def test_language_instruction_injected_for_french(self, rag, chatbot_config):
injected_prompt = None
async def capture_generate(messages, **kwargs):
nonlocal injected_prompt
injected_prompt = messages[0]["content"]
return {"content": "Bonjour", "tokens_used": 10, "model": "test"}
with patch.object(rag.embedding_svc, "embed_text", return_value=[0.1] * 1536), \
patch.object(rag.vector_svc, "search", return_value=[]), \
patch.object(rag.llm_svc, "generate", side_effect=capture_generate):
await rag.process_query(
query="Bonjour",
collection_name="test",
chatbot_config=chatbot_config,
language="fr",
)
assert injected_prompt is not None
assert "French" in injected_prompt
async def test_no_language_instruction_for_english(self, rag, chatbot_config):
injected_prompt = None
async def capture_generate(messages, **kwargs):
nonlocal injected_prompt
injected_prompt = messages[0]["content"]
return {"content": "Hello", "tokens_used": 10, "model": "test"}
with patch.object(rag.embedding_svc, "embed_text", return_value=[0.1] * 1536), \
patch.object(rag.vector_svc, "search", return_value=[]), \
patch.object(rag.llm_svc, "generate", side_effect=capture_generate):
await rag.process_query(
query="Hello",
collection_name="test",
chatbot_config=chatbot_config,
language="en",
)
assert injected_prompt is not None
# English should NOT inject a language instruction
assert "Respond in English" not in injected_prompt
async def test_empty_result_when_no_documents(self, rag, chatbot_config):
with patch.object(rag.embedding_svc, "embed_text", return_value=[0.1] * 1536), \
patch.object(rag.vector_svc, "search", return_value=[]), \
patch.object(rag.llm_svc, "generate", new_callable=AsyncMock, return_value={
"content": "I don't have info on that.",
"tokens_used": 20,
"model": "test",
}):
result = await rag.process_query(
query="What is X?",
collection_name="empty-collection",
chatbot_config=chatbot_config,
)
assert result["sources"] == []
assert result["response"] == "I don't have info on that."

125
tests/test_upload.py Normal file
View File

@@ -0,0 +1,125 @@
"""Tests for upload endpoints."""
import pytest
from unittest.mock import MagicMock, patch
AUTH = {"Authorization": "Bearer test-token"}
class TestUploadAuth:
def test_logo_upload_requires_auth(self, client):
resp = client.post(
"/api/v1/upload/logo",
files={"file": ("logo.png", b"fake-image-data", "image/png")},
)
assert resp.status_code == 401
class TestUploadLogoValidation:
def test_rejects_unsupported_file_type(self, client):
with patch("app.routers.upload.get_supabase"):
resp = client.post(
"/api/v1/upload/logo",
files={"file": ("doc.pdf", b"PDF content", "application/pdf")},
headers=AUTH,
)
assert resp.status_code == 400
assert "Invalid file type" in resp.json()["detail"]
def test_rejects_file_over_2mb(self, client):
big_bytes = b"x" * (2 * 1024 * 1024 + 1)
with patch("app.routers.upload.get_supabase"):
resp = client.post(
"/api/v1/upload/logo",
files={"file": ("big.png", big_bytes, "image/png")},
headers=AUTH,
)
assert resp.status_code == 413
@pytest.mark.parametrize("mime,filename", [
("image/png", "logo.png"),
("image/jpeg", "photo.jpg"),
("image/gif", "anim.gif"),
("image/svg+xml", "icon.svg"),
("image/webp", "img.webp"),
])
def test_accepts_all_allowed_image_types(self, client, mime, filename):
fake_url = "https://cdn.example.com/logos/test.png"
sb = MagicMock()
sb.storage.from_.return_value.upload.return_value = MagicMock()
sb.storage.from_.return_value.get_public_url.return_value = fake_url
sb.auth = MagicMock()
with patch("app.routers.upload.get_supabase", return_value=sb):
resp = client.post(
"/api/v1/upload/logo",
files={"file": (filename, b"image-bytes", mime)},
headers=AUTH,
)
assert resp.status_code == 200
assert resp.json()["url"] == fake_url
class TestUploadLogoSuccess:
def test_returns_public_url(self, client):
public_url = "https://storage.example.com/logos/test-user-id/abc123.png"
sb = MagicMock()
sb.storage.from_.return_value.upload.return_value = MagicMock()
sb.storage.from_.return_value.get_public_url.return_value = public_url
sb.auth = MagicMock()
with patch("app.routers.upload.get_supabase", return_value=sb):
resp = client.post(
"/api/v1/upload/logo",
files={"file": ("logo.png", b"fake-png-data", "image/png")},
headers=AUTH,
)
assert resp.status_code == 200
body = resp.json()
assert "url" in body
assert body["url"] == public_url
def test_upload_path_uses_user_id(self, client):
"""Storage path should be scoped to the authenticated user's ID."""
sb = MagicMock()
upload_mock = sb.storage.from_.return_value.upload
sb.storage.from_.return_value.get_public_url.return_value = "https://url"
sb.auth = MagicMock()
# Track the actual user returned by auth
auth_user = MagicMock()
auth_user.id = "test-user-id"
sb.auth.get_user.return_value = MagicMock(user=auth_user)
# Also mock user_profiles check
profile_mock = MagicMock()
profile_mock.select.return_value = profile_mock
profile_mock.eq.return_value = profile_mock
profile_mock.execute.return_value = MagicMock(data=[])
sb.table.return_value = profile_mock
with patch("app.routers.upload.get_supabase", return_value=sb), \
patch("app.dependencies.get_supabase", return_value=sb):
client.post(
"/api/v1/upload/logo",
files={"file": ("logo.png", b"data", "image/png")},
headers=AUTH,
)
upload_mock.assert_called_once()
call_kwargs = upload_mock.call_args
path = call_kwargs[1].get("path") or call_kwargs[0][0]
# Path should start with the user's ID
assert path.startswith("test-user-id")
def test_storage_failure_returns_500(self, client):
sb = MagicMock()
sb.storage.from_.return_value.upload.side_effect = Exception("Storage error")
sb.auth = MagicMock()
with patch("app.routers.upload.get_supabase", return_value=sb):
resp = client.post(
"/api/v1/upload/logo",
files={"file": ("logo.png", b"data", "image/png")},
headers=AUTH,
)
assert resp.status_code == 500

151
tests/test_widget.py Normal file
View File

@@ -0,0 +1,151 @@
"""
Tests for the widget.js endpoint and the JS bundle it generates.
GET /widget.js must:
- Return 200 with application/javascript content-type
- Include CORS header (any site can load it as a <script>)
- Cache-Control must be set
- Body must be valid JavaScript (basic structural checks)
- APP_URL placeholder must be replaced with the real app URL
- Must NOT expose the raw __APP_URL__ placeholder
- Must contain the public API surface (window.Contexta)
- Must contain the chatbot ID read logic (data-chatbot)
- Must be a self-executing IIFE
"""
import pytest
from unittest.mock import patch
from app.services.widget import generate_widget_js
# ── Unit tests: generate_widget_js() ──────────────────────────────────────────
class TestGenerateWidgetJs:
def test_replaces_app_url_placeholder(self):
js = generate_widget_js("https://app.example.com")
assert "https://app.example.com" in js
def test_strips_trailing_slash(self):
js = generate_widget_js("https://app.example.com/")
assert "https://app.example.com/" not in js
assert "https://app.example.com" in js
def test_no_raw_placeholder_in_output(self):
js = generate_widget_js("https://app.example.com")
assert "__APP_URL__" not in js
def test_constructs_chat_url_pattern(self):
js = generate_widget_js("https://app.example.com")
# The JS concatenates base + '/chat/' + chatbotId
assert "/chat/" in js
def test_is_iife(self):
js = generate_widget_js("https://app.example.com")
assert "(function" in js
assert "}());" in js or "})()" in js or "}())" in js or "()})" in js or "}());" in js
def test_contains_double_init_guard(self):
js = generate_widget_js("https://app.example.com")
assert "__ctxa" in js
def test_reads_data_chatbot_attribute(self):
js = generate_widget_js("https://app.example.com")
assert "data-chatbot" in js
def test_uses_document_current_script(self):
js = generate_widget_js("https://app.example.com")
assert "currentScript" in js
def test_public_api_exposed(self):
js = generate_widget_js("https://app.example.com")
assert "window.Contexta" in js
assert "open" in js
assert "close" in js
assert "toggle" in js
def test_lazy_iframe_loading(self):
"""Iframe src should only be set on first open, not at init time."""
js = generate_widget_js("https://app.example.com")
# The _loaded flag pattern ensures lazy load
assert "_loaded" in js or "frameLoaded" in js or "loaded" in js.lower()
def test_escape_key_closes_panel(self):
js = generate_widget_js("https://app.example.com")
assert "Escape" in js
def test_aria_attributes_present(self):
js = generate_widget_js("https://app.example.com")
assert "aria-label" in js
assert "aria-expanded" in js
def test_mobile_responsive_css(self):
js = generate_widget_js("https://app.example.com")
assert "480px" in js # mobile breakpoint
def test_high_z_index(self):
"""Widget must sit on top of host-page content."""
js = generate_widget_js("https://app.example.com")
# 2147483647 is the highest browser-supported z-index
assert "2147483647" in js
def test_sandbox_attribute_on_iframe(self):
js = generate_widget_js("https://app.example.com")
assert "sandbox" in js
assert "allow-scripts" in js
def test_returns_string(self):
result = generate_widget_js("https://app.example.com")
assert isinstance(result, str)
assert len(result) > 500 # must be a substantial bundle
# ── Integration tests: GET /widget.js ─────────────────────────────────────────
class TestWidgetEndpoint:
def test_returns_200(self, client):
resp = client.get("/widget.js")
assert resp.status_code == 200
def test_content_type_is_javascript(self, client):
resp = client.get("/widget.js")
assert "javascript" in resp.headers.get("content-type", "")
def test_cors_header_allows_any_origin(self, client):
resp = client.get("/widget.js")
assert resp.headers.get("access-control-allow-origin") == "*"
def test_cache_control_is_set(self, client):
resp = client.get("/widget.js")
cc = resp.headers.get("cache-control", "")
assert "public" in cc
assert "max-age" in cc
def test_x_content_type_options(self, client):
resp = client.get("/widget.js")
assert resp.headers.get("x-content-type-options") == "nosniff"
def test_body_contains_app_url(self, client):
resp = client.get("/widget.js")
# The test client uses the real settings.app_url baked into the JS
assert "/chat/" in resp.text
def test_body_does_not_contain_placeholder(self, client):
resp = client.get("/widget.js")
assert "__APP_URL__" not in resp.text
def test_body_contains_public_api(self, client):
resp = client.get("/widget.js")
assert "window.Contexta" in resp.text
def test_body_is_non_empty(self, client):
resp = client.get("/widget.js")
assert len(resp.text) > 200
def test_no_auth_required(self, client):
"""Widget.js must be publicly accessible — no Authorization header."""
resp = client.get("/widget.js")
assert resp.status_code == 200
def test_get_only(self, client):
"""Should not accept POST."""
resp = client.post("/widget.js")
assert resp.status_code == 405

580
uv.lock generated
View File

@@ -2,9 +2,15 @@ version = 1
revision = 3
requires-python = ">=3.12"
resolution-markers = [
"python_full_version >= '3.14'",
"python_full_version == '3.13.*'",
"python_full_version < '3.13'",
"python_full_version >= '3.14' and sys_platform == 'win32'",
"python_full_version >= '3.14' and sys_platform == 'emscripten'",
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
"python_full_version == '3.13.*' and sys_platform == 'win32'",
"python_full_version == '3.13.*' and sys_platform == 'emscripten'",
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
"python_full_version < '3.13' and sys_platform == 'win32'",
"python_full_version < '3.13' and sys_platform == 'emscripten'",
"python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'",
]
[[package]]
@@ -25,6 +31,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anthropic"
version = "0.88.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "distro" },
{ name = "docstring-parser" },
{ name = "httpx" },
{ name = "jiter" },
{ name = "pydantic" },
{ name = "sniffio" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/86/68/565f13059c0a6a6fd5f96f306f2a0fb478a0e1174ec18a4df16b5fac9379/anthropic-0.88.0.tar.gz", hash = "sha256:f4c7f6863d08c869913516f08d658fe53caaf8bcc4fbea3218df343d2a876c58", size = 596654, upload-time = "2026-04-01T19:59:05.287Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ad/ac/68f646998160c9f2e6f9353a31dd87292ef02b915b455aaf70a52a059a75/anthropic-0.88.0-py3-none-any.whl", hash = "sha256:71898b32332bc75d9739bc10095288d40a29605da6d00da2fe832b1aa036552f", size = 478338, upload-time = "2026-04-01T19:59:03.832Z" },
]
[[package]]
name = "anyio"
version = "4.12.1"
@@ -38,6 +63,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
[[package]]
name = "beautifulsoup4"
version = "4.14.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "soupsieve" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" },
]
[[package]]
name = "cachetools"
version = "6.2.6"
@@ -196,9 +234,23 @@ name = "contexta-be"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "anthropic" },
{ name = "beautifulsoup4" },
{ name = "fastapi" },
{ name = "generativeai" },
{ name = "google-generativeai" },
{ name = "httpx" },
{ name = "openai" },
{ name = "openpyxl" },
{ name = "pandas" },
{ name = "prometheus-fastapi-instrumentator" },
{ name = "pydantic-settings" },
{ name = "pypdf" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "python-docx" },
{ name = "python-json-logger" },
{ name = "python-multipart" },
{ name = "qdrant-client" },
{ name = "sentry-sdk" },
{ name = "spglib" },
@@ -209,9 +261,23 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "anthropic", specifier = ">=0.40.0" },
{ name = "beautifulsoup4", specifier = ">=4.12.0" },
{ name = "fastapi", specifier = ">=0.131.0" },
{ name = "generativeai", specifier = ">=0.0.1" },
{ name = "google-generativeai", specifier = ">=0.8.0" },
{ name = "httpx", specifier = ">=0.27.0" },
{ name = "openai", specifier = ">=2.21.0" },
{ name = "openpyxl", specifier = ">=3.1.0" },
{ name = "pandas", specifier = ">=2.2.0" },
{ name = "prometheus-fastapi-instrumentator", specifier = ">=6.0.0" },
{ name = "pydantic-settings", specifier = ">=2.0.0" },
{ name = "pypdf", specifier = ">=4.0.0" },
{ name = "pytest", specifier = ">=8.0.0" },
{ name = "pytest-asyncio", specifier = ">=0.23.0" },
{ name = "python-docx", specifier = ">=1.1.0" },
{ name = "python-json-logger", specifier = ">=2.0.0" },
{ name = "python-multipart", specifier = ">=0.0.9" },
{ name = "qdrant-client", specifier = ">=1.17.0" },
{ name = "sentry-sdk", specifier = ">=2.53.0" },
{ name = "spglib", specifier = ">=2.7.0" },
@@ -294,6 +360,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
]
[[package]]
name = "docstring-parser"
version = "0.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" },
]
[[package]]
name = "et-xmlfile"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" },
]
[[package]]
name = "fastapi"
version = "0.131.0"
@@ -340,6 +424,115 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/28/4e/b778a246ecdda98daab475890541b5a36dc92fd6247e45ed2eabdab87692/generativeai-0.0.1-py3-none-any.whl", hash = "sha256:0f1e10ea796713212b1c23a8b3697fc9a8e45226fd936302417f2f82de9c970f", size = 1224, upload-time = "2023-08-05T03:02:10.637Z" },
]
[[package]]
name = "google-ai-generativelanguage"
version = "0.6.15"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "google-api-core", extra = ["grpc"] },
{ name = "google-auth" },
{ name = "proto-plus" },
{ name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/11/d1/48fe5d7a43d278e9f6b5ada810b0a3530bbeac7ed7fcbcd366f932f05316/google_ai_generativelanguage-0.6.15.tar.gz", hash = "sha256:8f6d9dc4c12b065fe2d0289026171acea5183ebf2d0b11cefe12f3821e159ec3", size = 1375443, upload-time = "2025-01-13T21:50:47.459Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/a3/67b8a6ff5001a1d8864922f2d6488dc2a14367ceb651bc3f09a947f2f306/google_ai_generativelanguage-0.6.15-py3-none-any.whl", hash = "sha256:5a03ef86377aa184ffef3662ca28f19eeee158733e45d7947982eb953c6ebb6c", size = 1327356, upload-time = "2025-01-13T21:50:44.174Z" },
]
[[package]]
name = "google-api-core"
version = "2.25.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "google-auth" },
{ name = "googleapis-common-protos" },
{ name = "proto-plus" },
{ name = "protobuf" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/09/cd/63f1557235c2440fe0577acdbc32577c5c002684c58c7f4d770a92366a24/google_api_core-2.25.2.tar.gz", hash = "sha256:1c63aa6af0d0d5e37966f157a77f9396d820fba59f9e43e9415bc3dc5baff300", size = 166266, upload-time = "2025-10-03T00:07:34.778Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/d8/894716a5423933f5c8d2d5f04b16f052a515f78e815dab0c2c6f1fd105dc/google_api_core-2.25.2-py3-none-any.whl", hash = "sha256:e9a8f62d363dc8424a8497f4c2a47d6bcda6c16514c935629c257ab5d10210e7", size = 162489, upload-time = "2025-10-03T00:07:32.924Z" },
]
[package.optional-dependencies]
grpc = [
{ name = "grpcio" },
{ name = "grpcio-status" },
]
[[package]]
name = "google-api-python-client"
version = "2.193.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "google-api-core" },
{ name = "google-auth" },
{ name = "google-auth-httplib2" },
{ name = "httplib2" },
{ name = "uritemplate" },
]
sdist = { url = "https://files.pythonhosted.org/packages/90/f4/e14b6815d3b1885328dd209676a3a4c704882743ac94e18ef0093894f5c8/google_api_python_client-2.193.0.tar.gz", hash = "sha256:8f88d16e89d11341e0a8b199cafde0fb7e6b44260dffb88d451577cbd1bb5d33", size = 14281006, upload-time = "2026-03-17T18:25:29.415Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f0/6d/fe75167797790a56d17799b75e1129bb93f7ff061efc7b36e9731bd4be2b/google_api_python_client-2.193.0-py3-none-any.whl", hash = "sha256:c42aa324b822109901cfecab5dc4fc3915d35a7b376835233c916c70610322db", size = 14856490, upload-time = "2026-03-17T18:25:26.608Z" },
]
[[package]]
name = "google-auth"
version = "2.49.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "pyasn1-modules" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ea/80/6a696a07d3d3b0a92488933532f03dbefa4a24ab80fb231395b9a2a1be77/google_auth-2.49.1.tar.gz", hash = "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64", size = 333825, upload-time = "2026-03-12T19:30:58.135Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl", hash = "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7", size = 240737, upload-time = "2026-03-12T19:30:53.159Z" },
]
[[package]]
name = "google-auth-httplib2"
version = "0.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "google-auth" },
{ name = "httplib2" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ed/99/107612bef8d24b298bb5a7c8466f908ecda791d43f9466f5c3978f5b24c1/google_auth_httplib2-0.3.1.tar.gz", hash = "sha256:0af542e815784cb64159b4469aa5d71dd41069ba93effa006e1916b1dcd88e55", size = 11152, upload-time = "2026-03-30T22:50:26.766Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/97/e9/93afb14d23a949acaa3f4e7cc51a0024671174e116e35f42850764b99634/google_auth_httplib2-0.3.1-py3-none-any.whl", hash = "sha256:682356a90ef4ba3d06548c37e9112eea6fc00395a11b0303a644c1a86abc275c", size = 9534, upload-time = "2026-03-30T22:49:03.384Z" },
]
[[package]]
name = "google-generativeai"
version = "0.8.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "google-ai-generativelanguage" },
{ name = "google-api-core" },
{ name = "google-api-python-client" },
{ name = "google-auth" },
{ name = "protobuf" },
{ name = "pydantic" },
{ name = "tqdm" },
{ name = "typing-extensions" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/97/0f/ef33b5bb71437966590c6297104c81051feae95d54b11ece08533ef937d3/google_generativeai-0.8.6-py3-none-any.whl", hash = "sha256:37a0eaaa95e5bbf888828e20a4a1b2c196cc9527d194706e58a68ff388aeb0fa", size = 155098, upload-time = "2025-12-16T17:53:58.61Z" },
]
[[package]]
name = "googleapis-common-protos"
version = "1.73.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a1/c0/4a54c386282c13449eca8bbe2ddb518181dc113e78d240458a68856b4d69/googleapis_common_protos-1.73.1.tar.gz", hash = "sha256:13114f0e9d2391756a0194c3a8131974ed7bffb06086569ba193364af59163b6", size = 147506, upload-time = "2026-03-26T22:17:38.451Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/82/fcb6520612bec0c39b973a6c0954b6a0d948aadfe8f7e9487f60ceb8bfa6/googleapis_common_protos-1.73.1-py3-none-any.whl", hash = "sha256:e51f09eb0a43a8602f5a915870972e6b4a394088415c79d79605a46d8e826ee8", size = 297556, upload-time = "2026-03-26T22:15:58.455Z" },
]
[[package]]
name = "grpcio"
version = "1.78.1"
@@ -381,6 +574,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1c/ce/adfe7e5f701d503be7778291757452e3fab6b19acf51917c79f5d1cf7f8a/grpcio-1.78.1-cp314-cp314-win_amd64.whl", hash = "sha256:e2a6b33d1050dce2c6f563c5caf7f7cbeebf7fba8cde37ffe3803d50526900d1", size = 4932000, upload-time = "2026-02-20T01:15:36.127Z" },
]
[[package]]
name = "grpcio-status"
version = "1.71.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "googleapis-common-protos" },
{ name = "grpcio" },
{ name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fd/d1/b6e9877fedae3add1afdeae1f89d1927d296da9cf977eca0eb08fb8a460e/grpcio_status-1.71.2.tar.gz", hash = "sha256:c7a97e176df71cdc2c179cd1847d7fc86cca5832ad12e9798d7fed6b7a1aab50", size = 13677, upload-time = "2025-06-28T04:24:05.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/67/58/317b0134129b556a93a3b0afe00ee675b5657f0155509e22fcb853bafe2d/grpcio_status-1.71.2-py3-none-any.whl", hash = "sha256:803c98cb6a8b7dc6dbb785b1111aed739f241ab5e9da0bba96888aa74704cfd3", size = 14424, upload-time = "2025-06-28T04:23:42.136Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
@@ -454,6 +661,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httplib2"
version = "0.31.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyparsing" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c1/1f/e86365613582c027dda5ddb64e1010e57a3d53e99ab8a72093fa13d565ec/httplib2-0.31.2.tar.gz", hash = "sha256:385e0869d7397484f4eab426197a4c020b606edd43372492337c0b4010ae5d24", size = 250800, upload-time = "2026-01-23T11:04:44.165Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2f/90/fd509079dfcab01102c0fdd87f3a9506894bc70afcf9e9785ef6b2b3aff6/httplib2-0.31.2-py3-none-any.whl", hash = "sha256:dbf0c2fa3862acf3c55c078ea9c0bc4481d7dc5117cae71be9514912cf9f8349", size = 91099, upload-time = "2026-01-23T11:04:42.78Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
@@ -513,6 +732,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "jiter"
version = "0.13.0"
@@ -581,6 +809,86 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768, upload-time = "2026-02-02T12:37:55.055Z" },
]
[[package]]
name = "lxml"
version = "6.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" },
{ url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" },
{ url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" },
{ url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" },
{ url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" },
{ url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" },
{ url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" },
{ url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" },
{ url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" },
{ url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" },
{ url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" },
{ url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" },
{ url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" },
{ url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" },
{ url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" },
{ url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" },
{ url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" },
{ url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" },
{ url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" },
{ url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" },
{ url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" },
{ url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" },
{ url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" },
{ url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" },
{ url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" },
{ url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" },
{ url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" },
{ url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" },
{ url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" },
{ url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" },
{ url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" },
{ url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" },
{ url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" },
{ url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" },
{ url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" },
{ url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" },
{ url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" },
{ url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" },
{ url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" },
{ url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" },
{ url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" },
{ url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" },
{ url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" },
{ url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" },
{ url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" },
{ url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" },
{ url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" },
{ url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" },
{ url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" },
{ url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" },
{ url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" },
{ url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" },
{ url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" },
{ url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" },
{ url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" },
{ url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" },
{ url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" },
{ url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" },
{ url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" },
{ url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" },
{ url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" },
{ url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" },
{ url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" },
{ url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" },
{ url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" },
{ url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" },
{ url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" },
{ url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" },
{ url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" },
{ url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" },
{ url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" },
{ url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
@@ -861,6 +1169,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/56/0a89092a453bb2c676d66abee44f863e742b2110d4dbb1dbcca3f7e5fc33/openai-2.21.0-py3-none-any.whl", hash = "sha256:0bc1c775e5b1536c294eded39ee08f8407656537ccc71b1004104fe1602e267c", size = 1103065, upload-time = "2026-02-14T00:11:59.603Z" },
]
[[package]]
name = "openpyxl"
version = "3.1.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "et-xmlfile" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" },
]
[[package]]
name = "packaging"
version = "26.0"
@@ -870,6 +1190,67 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "pandas"
version = "3.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
{ name = "python-dateutil" },
{ name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/da/99/b342345300f13440fe9fe385c3c481e2d9a595ee3bab4d3219247ac94e9a/pandas-3.0.2.tar.gz", hash = "sha256:f4753e73e34c8d83221ba58f232433fca2748be8b18dbca02d242ed153945043", size = 4645855, upload-time = "2026-03-31T06:48:30.816Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/b0/c20bd4d6d3f736e6bd6b55794e9cd0a617b858eaad27c8f410ea05d953b7/pandas-3.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:232a70ebb568c0c4d2db4584f338c1577d81e3af63292208d615907b698a0f18", size = 10347921, upload-time = "2026-03-31T06:46:33.36Z" },
{ url = "https://files.pythonhosted.org/packages/35/d0/4831af68ce30cc2d03c697bea8450e3225a835ef497d0d70f31b8cdde965/pandas-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:970762605cff1ca0d3f71ed4f3a769ea8f85fc8e6348f6e110b8fea7e6eb5a14", size = 9888127, upload-time = "2026-03-31T06:46:36.253Z" },
{ url = "https://files.pythonhosted.org/packages/61/a9/16ea9346e1fc4a96e2896242d9bc674764fb9049b0044c0132502f7a771e/pandas-3.0.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aff4e6f4d722e0652707d7bcb190c445fe58428500c6d16005b02401764b1b3d", size = 10399577, upload-time = "2026-03-31T06:46:39.224Z" },
{ url = "https://files.pythonhosted.org/packages/c4/a8/3a61a721472959ab0ce865ef05d10b0d6bfe27ce8801c99f33d4fa996e65/pandas-3.0.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef8b27695c3d3dc78403c9a7d5e59a62d5464a7e1123b4e0042763f7104dc74f", size = 10880030, upload-time = "2026-03-31T06:46:42.412Z" },
{ url = "https://files.pythonhosted.org/packages/da/65/7225c0ea4d6ce9cb2160a7fb7f39804871049f016e74782e5dade4d14109/pandas-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f8d68083e49e16b84734eb1a4dcae4259a75c90fb6e2251ab9a00b61120c06ab", size = 11409468, upload-time = "2026-03-31T06:46:45.2Z" },
{ url = "https://files.pythonhosted.org/packages/fa/5b/46e7c76032639f2132359b5cf4c785dd8cf9aea5ea64699eac752f02b9db/pandas-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:32cc41f310ebd4a296d93515fcac312216adfedb1894e879303987b8f1e2b97d", size = 11936381, upload-time = "2026-03-31T06:46:48.293Z" },
{ url = "https://files.pythonhosted.org/packages/7b/8b/721a9cff6fa6a91b162eb51019c6243b82b3226c71bb6c8ef4a9bd65cbc6/pandas-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:a4785e1d6547d8427c5208b748ae2efb64659a21bd82bf440d4262d02bfa02a4", size = 9744993, upload-time = "2026-03-31T06:46:51.488Z" },
{ url = "https://files.pythonhosted.org/packages/d5/18/7f0bd34ae27b28159aa80f2a6799f47fda34f7fb938a76e20c7b7fe3b200/pandas-3.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:08504503f7101300107ecdc8df73658e4347586db5cfdadabc1592e9d7e7a0fd", size = 9056118, upload-time = "2026-03-31T06:46:54.548Z" },
{ url = "https://files.pythonhosted.org/packages/bf/ca/3e639a1ea6fcd0617ca4e8ca45f62a74de33a56ae6cd552735470b22c8d3/pandas-3.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b5918ba197c951dec132b0c5929a00c0bf05d5942f590d3c10a807f6e15a57d3", size = 10321105, upload-time = "2026-03-31T06:46:57.327Z" },
{ url = "https://files.pythonhosted.org/packages/0b/77/dbc82ff2fb0e63c6564356682bf201edff0ba16c98630d21a1fb312a8182/pandas-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d606a041c89c0a474a4702d532ab7e73a14fe35c8d427b972a625c8e46373668", size = 9864088, upload-time = "2026-03-31T06:46:59.935Z" },
{ url = "https://files.pythonhosted.org/packages/5c/2b/341f1b04bbca2e17e13cd3f08c215b70ef2c60c5356ef1e8c6857449edc7/pandas-3.0.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:710246ba0616e86891b58ab95f2495143bb2bc83ab6b06747c74216f583a6ac9", size = 10369066, upload-time = "2026-03-31T06:47:02.792Z" },
{ url = "https://files.pythonhosted.org/packages/12/c5/cbb1ffefb20a93d3f0e1fdcda699fb84976210d411b008f97f48bf6ce27e/pandas-3.0.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d3cfe227c725b1f3dff4278b43d8c784656a42a9325b63af6b1492a8232209e", size = 10876780, upload-time = "2026-03-31T06:47:06.205Z" },
{ url = "https://files.pythonhosted.org/packages/98/fe/2249ae5e0a69bd0ddf17353d0a5d26611d70970111f5b3600cdc8be883e7/pandas-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c3b723df9087a9a9a840e263ebd9f88b64a12075d1bf2ea401a5a42f254f084d", size = 11375181, upload-time = "2026-03-31T06:47:09.383Z" },
{ url = "https://files.pythonhosted.org/packages/de/64/77a38b09e70b6464883b8d7584ab543e748e42c1b5d337a2ee088e0df741/pandas-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3096110bf9eac0070b7208465f2740e2d8a670d5cb6530b5bb884eca495fd39", size = 11928899, upload-time = "2026-03-31T06:47:12.686Z" },
{ url = "https://files.pythonhosted.org/packages/5e/52/42855bf626868413f761addd574acc6195880ae247a5346477a4361c3acb/pandas-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:07a10f5c36512eead51bc578eb3354ad17578b22c013d89a796ab5eee90cd991", size = 9746574, upload-time = "2026-03-31T06:47:15.64Z" },
{ url = "https://files.pythonhosted.org/packages/88/39/21304ae06a25e8bf9fc820d69b29b2c495b2ae580d1e143146c309941760/pandas-3.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:5fdbfa05931071aba28b408e59226186b01eb5e92bea2ab78b65863ca3228d84", size = 9047156, upload-time = "2026-03-31T06:47:18.595Z" },
{ url = "https://files.pythonhosted.org/packages/72/20/7defa8b27d4f330a903bb68eea33be07d839c5ea6bdda54174efcec0e1d2/pandas-3.0.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:dbc20dea3b9e27d0e66d74c42b2d0c1bed9c2ffe92adea33633e3bedeb5ac235", size = 10756238, upload-time = "2026-03-31T06:47:22.012Z" },
{ url = "https://files.pythonhosted.org/packages/e9/95/49433c14862c636afc0e9b2db83ff16b3ad92959364e52b2955e44c8e94c/pandas-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b75c347eff42497452116ce05ef461822d97ce5b9ff8df6edacb8076092c855d", size = 10408520, upload-time = "2026-03-31T06:47:25.197Z" },
{ url = "https://files.pythonhosted.org/packages/3b/f8/462ad2b5881d6b8ec8e5f7ed2ea1893faa02290d13870a1600fe72ad8efc/pandas-3.0.2-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1478075142e83a5571782ad007fb201ed074bdeac7ebcc8890c71442e96adf7", size = 10324154, upload-time = "2026-03-31T06:47:28.097Z" },
{ url = "https://files.pythonhosted.org/packages/0a/65/d1e69b649cbcddda23ad6e4c40ef935340f6f652a006e5cbc3555ac8adb3/pandas-3.0.2-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5880314e69e763d4c8b27937090de570f1fb8d027059a7ada3f7f8e98bdcb677", size = 10714449, upload-time = "2026-03-31T06:47:30.85Z" },
{ url = "https://files.pythonhosted.org/packages/47/a4/85b59bc65b8190ea3689882db6cdf32a5003c0ccd5a586c30fdcc3ffc4fc/pandas-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b5329e26898896f06035241a626d7c335daa479b9bbc82be7c2742d048e41172", size = 11338475, upload-time = "2026-03-31T06:47:34.026Z" },
{ url = "https://files.pythonhosted.org/packages/1e/c4/bc6966c6e38e5d9478b935272d124d80a589511ed1612a5d21d36f664c68/pandas-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:81526c4afd31971f8b62671442a4b2b51e0aa9acc3819c9f0f12a28b6fcf85f1", size = 11786568, upload-time = "2026-03-31T06:47:36.941Z" },
{ url = "https://files.pythonhosted.org/packages/e8/74/09298ca9740beed1d3504e073d67e128aa07e5ca5ca2824b0c674c0b8676/pandas-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:7cadd7e9a44ec13b621aec60f9150e744cfc7a3dd32924a7e2f45edff31823b0", size = 10488652, upload-time = "2026-03-31T06:47:40.612Z" },
{ url = "https://files.pythonhosted.org/packages/bb/40/c6ea527147c73b24fc15c891c3fcffe9c019793119c5742b8784a062c7db/pandas-3.0.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:db0dbfd2a6cdf3770aa60464d50333d8f3d9165b2f2671bcc299b72de5a6677b", size = 10326084, upload-time = "2026-03-31T06:47:43.834Z" },
{ url = "https://files.pythonhosted.org/packages/95/25/bdb9326c3b5455f8d4d3549fce7abcf967259de146fe2cf7a82368141948/pandas-3.0.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0555c5882688a39317179ab4a0ed41d3ebc8812ab14c69364bbee8fb7a3f6288", size = 9914146, upload-time = "2026-03-31T06:47:46.67Z" },
{ url = "https://files.pythonhosted.org/packages/8d/77/3a227ff3337aa376c60d288e1d61c5d097131d0ac71f954d90a8f369e422/pandas-3.0.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01f31a546acd5574ef77fe199bc90b55527c225c20ccda6601cf6b0fd5ed597c", size = 10444081, upload-time = "2026-03-31T06:47:49.681Z" },
{ url = "https://files.pythonhosted.org/packages/15/88/3cdd54fa279341afa10acf8d2b503556b1375245dccc9315659f795dd2e9/pandas-3.0.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:deeca1b5a931fdf0c2212c8a659ade6d3b1edc21f0914ce71ef24456ca7a6535", size = 10897535, upload-time = "2026-03-31T06:47:53.033Z" },
{ url = "https://files.pythonhosted.org/packages/06/9d/98cc7a7624f7932e40f434299260e2917b090a579d75937cb8a57b9d2de3/pandas-3.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f48afd9bb13300ffb5a3316973324c787054ba6665cda0da3fbd67f451995db", size = 11446992, upload-time = "2026-03-31T06:47:56.193Z" },
{ url = "https://files.pythonhosted.org/packages/9a/cd/19ff605cc3760e80602e6826ddef2824d8e7050ed80f2e11c4b079741dc3/pandas-3.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6c4d8458b97a35717b62469a4ea0e85abd5ed8687277f5ccfc67f8a5126f8c53", size = 11968257, upload-time = "2026-03-31T06:47:59.137Z" },
{ url = "https://files.pythonhosted.org/packages/db/60/aba6a38de456e7341285102bede27514795c1eaa353bc0e7638b6b785356/pandas-3.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:b35d14bb5d8285d9494fe93815a9e9307c0876e10f1e8e89ac5b88f728ec8dcf", size = 9865893, upload-time = "2026-03-31T06:48:02.038Z" },
{ url = "https://files.pythonhosted.org/packages/08/71/e5ec979dd2e8a093dacb8864598c0ff59a0cee0bbcdc0bfec16a51684d4f/pandas-3.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:63d141b56ef686f7f0d714cfb8de4e320475b86bf4b620aa0b7da89af8cbdbbb", size = 9188644, upload-time = "2026-03-31T06:48:05.045Z" },
{ url = "https://files.pythonhosted.org/packages/f1/6c/7b45d85db19cae1eb524f2418ceaa9d85965dcf7b764ed151386b7c540f0/pandas-3.0.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:140f0cffb1fa2524e874dde5b477d9defe10780d8e9e220d259b2c0874c89d9d", size = 10776246, upload-time = "2026-03-31T06:48:07.789Z" },
{ url = "https://files.pythonhosted.org/packages/a8/3e/7b00648b086c106e81766f25322b48aa8dfa95b55e621dbdf2fdd413a117/pandas-3.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae37e833ff4fed0ba352f6bdd8b73ba3ab3256a85e54edfd1ab51ae40cca0af8", size = 10424801, upload-time = "2026-03-31T06:48:10.897Z" },
{ url = "https://files.pythonhosted.org/packages/da/6e/558dd09a71b53b4008e7fc8a98ec6d447e9bfb63cdaeea10e5eb9b2dabe8/pandas-3.0.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d888a5c678a419a5bb41a2a93818e8ed9fd3172246555c0b37b7cc27027effd", size = 10345643, upload-time = "2026-03-31T06:48:13.7Z" },
{ url = "https://files.pythonhosted.org/packages/be/e3/921c93b4d9a280409451dc8d07b062b503bbec0531d2627e73a756e99a82/pandas-3.0.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b444dc64c079e84df91baa8bf613d58405645461cabca929d9178f2cd392398d", size = 10743641, upload-time = "2026-03-31T06:48:16.659Z" },
{ url = "https://files.pythonhosted.org/packages/56/ca/fd17286f24fa3b4d067965d8d5d7e14fe557dd4f979a0b068ac0deaf8228/pandas-3.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4544c7a54920de8eeacaa1466a6b7268ecfbc9bc64ab4dbb89c6bbe94d5e0660", size = 11361993, upload-time = "2026-03-31T06:48:19.475Z" },
{ url = "https://files.pythonhosted.org/packages/e4/a5/2f6ed612056819de445a433ca1f2821ac3dab7f150d569a59e9cc105de1d/pandas-3.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:734be7551687c00fbd760dc0522ed974f82ad230d4a10f54bf51b80d44a08702", size = 11815274, upload-time = "2026-03-31T06:48:22.695Z" },
{ url = "https://files.pythonhosted.org/packages/00/2f/b622683e99ec3ce00b0854bac9e80868592c5b051733f2cf3a868e5fea26/pandas-3.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:57a07209bebcbcf768d2d13c9b78b852f9a15978dac41b9e6421a81ad4cdd276", size = 10888530, upload-time = "2026-03-31T06:48:25.806Z" },
{ url = "https://files.pythonhosted.org/packages/cb/2b/f8434233fab2bd66a02ec014febe4e5adced20e2693e0e90a07d118ed30e/pandas-3.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:5371b72c2d4d415d08765f32d689217a43227484e81b2305b52076e328f6f482", size = 9455341, upload-time = "2026-03-31T06:48:28.418Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "portalocker"
version = "3.2.0"
@@ -897,6 +1278,28 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/47/43deadb113d8730e59d5045eb0968eb2ca8ccbad7506bd4fc4a18294e114/postgrest-2.28.0-py3-none-any.whl", hash = "sha256:7bca2f24dd1a1bf8a3d586c7482aba6cd41662da6733045fad585b63b7f7df75", size = 22008, upload-time = "2026-02-10T13:16:59.307Z" },
]
[[package]]
name = "prometheus-client"
version = "0.24.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f0/58/a794d23feb6b00fc0c72787d7e87d872a6730dd9ed7c7b3e954637d8f280/prometheus_client-0.24.1.tar.gz", hash = "sha256:7e0ced7fbbd40f7b84962d5d2ab6f17ef88a72504dcf7c0b40737b43b2a461f9", size = 85616, upload-time = "2026-01-14T15:26:26.965Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/74/c3/24a2f845e3917201628ecaba4f18bab4d18a337834c1df2a159ee9d22a42/prometheus_client-0.24.1-py3-none-any.whl", hash = "sha256:150db128af71a5c2482b36e588fc8a6b95e498750da4b17065947c16070f4055", size = 64057, upload-time = "2026-01-14T15:26:24.42Z" },
]
[[package]]
name = "prometheus-fastapi-instrumentator"
version = "7.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "prometheus-client" },
{ name = "starlette" },
]
sdist = { url = "https://files.pythonhosted.org/packages/69/6d/24d53033cf93826aa7857699a4450c1c67e5b9c710e925b1ed2b320c04df/prometheus_fastapi_instrumentator-7.1.0.tar.gz", hash = "sha256:be7cd61eeea4e5912aeccb4261c6631b3f227d8924542d79eaf5af3f439cbe5e", size = 20220, upload-time = "2025-03-19T19:35:05.351Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/27/72/0824c18f3bc75810f55dacc2dd933f6ec829771180245ae3cc976195dec0/prometheus_fastapi_instrumentator-7.1.0-py3-none-any.whl", hash = "sha256:978130f3c0bb7b8ebcc90d35516a6fe13e02d2eb358c8f83887cdef7020c31e9", size = 19296, upload-time = "2025-03-19T19:35:04.323Z" },
]
[[package]]
name = "propcache"
version = "0.4.1"
@@ -982,18 +1385,50 @@ wheels = [
]
[[package]]
name = "protobuf"
version = "6.33.5"
name = "proto-plus"
version = "1.27.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ba/25/7c72c307aafc96fa87062aa6291d9f7c94836e43214d43722e86037aac02/protobuf-6.33.5.tar.gz", hash = "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c", size = 444465, upload-time = "2026-01-29T21:51:33.494Z" }
dependencies = [
{ name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/81/0d/94dfe80193e79d55258345901acd2917523d56e8381bc4dee7fd38e3868a/proto_plus-1.27.2.tar.gz", hash = "sha256:b2adde53adadf75737c44d3dcb0104fde65250dfc83ad59168b4aa3e574b6a24", size = 57204, upload-time = "2026-03-26T22:18:57.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b1/79/af92d0a8369732b027e6d6084251dd8e782c685c72da161bd4a2e00fbabb/protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b", size = 425769, upload-time = "2026-01-29T21:51:21.751Z" },
{ url = "https://files.pythonhosted.org/packages/55/75/bb9bc917d10e9ee13dee8607eb9ab963b7cf8be607c46e7862c748aa2af7/protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c", size = 437118, upload-time = "2026-01-29T21:51:24.022Z" },
{ url = "https://files.pythonhosted.org/packages/a2/6b/e48dfc1191bc5b52950246275bf4089773e91cb5ba3592621723cdddca62/protobuf-6.33.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5", size = 427766, upload-time = "2026-01-29T21:51:25.413Z" },
{ url = "https://files.pythonhosted.org/packages/4e/b1/c79468184310de09d75095ed1314b839eb2f72df71097db9d1404a1b2717/protobuf-6.33.5-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190", size = 324638, upload-time = "2026-01-29T21:51:26.423Z" },
{ url = "https://files.pythonhosted.org/packages/c5/f5/65d838092fd01c44d16037953fd4c2cc851e783de9b8f02b27ec4ffd906f/protobuf-6.33.5-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd", size = 339411, upload-time = "2026-01-29T21:51:27.446Z" },
{ url = "https://files.pythonhosted.org/packages/9b/53/a9443aa3ca9ba8724fdfa02dd1887c1bcd8e89556b715cfbacca6b63dbec/protobuf-6.33.5-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0", size = 323465, upload-time = "2026-01-29T21:51:28.925Z" },
{ url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" },
{ url = "https://files.pythonhosted.org/packages/84/f3/1fba73eeffafc998a25d59703b63f8be4fe8a5cb12eaff7386a0ba0f7125/proto_plus-1.27.2-py3-none-any.whl", hash = "sha256:6432f75893d3b9e70b9c412f1d2f03f65b11fb164b793d14ae2ca01821d22718", size = 50450, upload-time = "2026-03-26T22:13:42.927Z" },
]
[[package]]
name = "protobuf"
version = "5.29.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/57/394a763c103e0edf87f0938dafcd918d53b4c011dfc5c8ae80f3b0452dbb/protobuf-5.29.6.tar.gz", hash = "sha256:da9ee6a5424b6b30fd5e45c5ea663aef540ca95f9ad99d1e887e819cdf9b8723", size = 425623, upload-time = "2026-02-04T22:54:40.584Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/88/9ee58ff7863c479d6f8346686d4636dd4c415b0cbeed7a6a7d0617639c2a/protobuf-5.29.6-cp310-abi3-win32.whl", hash = "sha256:62e8a3114992c7c647bce37dcc93647575fc52d50e48de30c6fcb28a6a291eb1", size = 423357, upload-time = "2026-02-04T22:54:25.805Z" },
{ url = "https://files.pythonhosted.org/packages/1c/66/2dc736a4d576847134fb6d80bd995c569b13cdc7b815d669050bf0ce2d2c/protobuf-5.29.6-cp310-abi3-win_amd64.whl", hash = "sha256:7e6ad413275be172f67fdee0f43484b6de5a904cc1c3ea9804cb6fe2ff366eda", size = 435175, upload-time = "2026-02-04T22:54:28.592Z" },
{ url = "https://files.pythonhosted.org/packages/06/db/49b05966fd208ae3f44dcd33837b6243b4915c57561d730a43f881f24dea/protobuf-5.29.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:b5a169e664b4057183a34bdc424540e86eea47560f3c123a0d64de4e137f9269", size = 418619, upload-time = "2026-02-04T22:54:30.266Z" },
{ url = "https://files.pythonhosted.org/packages/b7/d7/48cbf6b0c3c39761e47a99cb483405f0fde2be22cf00d71ef316ce52b458/protobuf-5.29.6-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:a8866b2cff111f0f863c1b3b9e7572dc7eaea23a7fae27f6fc613304046483e6", size = 320284, upload-time = "2026-02-04T22:54:31.782Z" },
{ url = "https://files.pythonhosted.org/packages/e3/dd/cadd6ec43069247d91f6345fa7a0d2858bef6af366dbd7ba8f05d2c77d3b/protobuf-5.29.6-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:e3387f44798ac1106af0233c04fb8abf543772ff241169946f698b3a9a3d3ab9", size = 320478, upload-time = "2026-02-04T22:54:32.909Z" },
{ url = "https://files.pythonhosted.org/packages/5a/cb/e3065b447186cb70aa65acc70c86baf482d82bf75625bf5a2c4f6919c6a3/protobuf-5.29.6-py3-none-any.whl", hash = "sha256:6b9edb641441b2da9fa8f428760fc136a49cf97a52076010cf22a2ff73438a86", size = 173126, upload-time = "2026-02-04T22:54:39.462Z" },
]
[[package]]
name = "pyasn1"
version = "0.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" },
]
[[package]]
name = "pyasn1-modules"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
]
[[package]]
@@ -1091,6 +1526,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
]
[[package]]
name = "pydantic-settings"
version = "2.13.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
@@ -1159,6 +1608,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
]
[[package]]
name = "pypdf"
version = "6.9.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/31/83/691bdb309306232362503083cb15777491045dd54f45393a317dc7d8082f/pypdf-6.9.2.tar.gz", hash = "sha256:7f850faf2b0d4ab936582c05da32c52214c2b089d61a316627b5bfb5b0dab46c", size = 5311837, upload-time = "2026-03-23T14:53:27.983Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a5/7e/c85f41243086a8fe5d1baeba527cb26a1918158a565932b41e0f7c0b32e9/pypdf-6.9.2-py3-none-any.whl", hash = "sha256:662cf29bcb419a36a1365232449624ab40b7c2d0cfc28e54f42eeecd1fd7e844", size = 333744, upload-time = "2026-03-23T14:53:26.573Z" },
]
[[package]]
name = "pyroaring"
version = "1.0.3"
@@ -1195,6 +1653,35 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/77/96/8dde074f1ad2a1c3d2091b22de80d1b3007824e649e06eeeebded83f4d48/pyroaring-1.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:9c0c856e8aa5606e8aed5f30201286e404fdc9093f81fefe82d2e79e67472bb2", size = 218775, upload-time = "2025-10-09T09:07:47.558Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
@@ -1207,6 +1694,46 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "python-docx"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "lxml" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256, upload-time = "2025-06-16T20:46:27.921Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
name = "python-json-logger"
version = "4.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f7/ff/3cc9165fd44106973cd7ac9facb674a65ed853494592541d339bdc9a30eb/python_json_logger-4.1.0.tar.gz", hash = "sha256:b396b9e3ed782b09ff9d6e4f1683d46c83ad0d35d2e407c09a9ebbf038f88195", size = 17573, upload-time = "2026-03-29T04:39:56.805Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/27/be/0631a861af4d1c875f096c07d34e9a63639560a717130e7a87cbc82b7e3f/python_json_logger-4.1.0-py3-none-any.whl", hash = "sha256:132994765cf75bf44554be9aa49b06ef2345d23661a96720262716438141b6b2", size = 15021, upload-time = "2026-03-29T04:39:55.266Z" },
]
[[package]]
name = "python-multipart"
version = "0.0.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" },
]
[[package]]
name = "pywin32"
version = "311"
@@ -1369,6 +1896,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]]
name = "soupsieve"
version = "2.8.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" },
]
[[package]]
name = "spglib"
version = "2.7.0"
@@ -1578,6 +2114,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "tzdata"
version = "2025.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" },
]
[[package]]
name = "uritemplate"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267, upload-time = "2025-06-02T15:12:06.318Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488, upload-time = "2025-06-02T15:12:03.405Z" },
]
[[package]]
name = "urllib3"
version = "2.6.3"