mirror of
http://88.130.71.182:3000/BlitTech/contexta_be.git
synced 2026-06-12 23:23:21 +00:00
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:
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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
34
app/logging_config.py
Normal 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)
|
||||
39
app/main.py
39
app/main.py
@@ -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
|
||||
|
||||
221
app/models.py
221
app/models.py
@@ -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
555
app/routers/admin.py
Normal 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(),
|
||||
)
|
||||
@@ -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
287
app/routers/appointments.py
Normal 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])
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
167
app/routers/campaigns.py
Normal 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}
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")),
|
||||
)
|
||||
@@ -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])
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
46
app/services/storage.py
Normal 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
|
||||
@@ -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)
|
||||
@@ -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 = '×';
|
||||
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('/'))
|
||||
|
||||
42
migrations/001_user_profiles.sql
Normal file
42
migrations/001_user_profiles.sql
Normal 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>';
|
||||
8
migrations/002_messages_confidence.sql
Normal file
8
migrations/002_messages_confidence.sql
Normal 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;
|
||||
13
migrations/003_stripe_idempotency.sql
Normal file
13
migrations/003_stripe_idempotency.sql
Normal 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);
|
||||
@@ -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
3
pytest.ini
Normal file
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
testpaths = tests
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
97
supabase_migration_features.sql
Normal file
97
supabase_migration_features.sql
Normal 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
0
tests/__init__.py
Normal file
77
tests/conftest.py
Normal file
77
tests/conftest.py
Normal 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
94
tests/test_admin.py
Normal 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
255
tests/test_analytics.py
Normal 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
554
tests/test_appointments.py
Normal 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
119
tests/test_auth.py
Normal 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
81
tests/test_billing.py
Normal 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
453
tests/test_campaigns.py
Normal 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
258
tests/test_channels.py
Normal 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
289
tests/test_chat.py
Normal 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
90
tests/test_chatbots.py
Normal 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
285
tests/test_config_plans.py
Normal 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
267
tests/test_documents.py
Normal 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
428
tests/test_inbox.py
Normal 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
405
tests/test_leads.py
Normal 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
212
tests/test_marketplace.py
Normal 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
107
tests/test_models.py
Normal 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
118
tests/test_rag.py
Normal 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
125
tests/test_upload.py
Normal 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
151
tests/test_widget.py
Normal 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
580
uv.lock
generated
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user