Compare commits
1
Commits
master
..
3ef49d1d15
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ef49d1d15 |
No files matched your search
+4
-13
@@ -16,8 +16,6 @@ OLLAMA_BASE_URL=http://127.0.0.1:11434
|
|||||||
# OLLAMA_MODEL=gpt-oss:20b
|
# OLLAMA_MODEL=gpt-oss:20b
|
||||||
# OLLAMA_MODEL_THINKING=gpt-oss:20b
|
# OLLAMA_MODEL_THINKING=gpt-oss:20b
|
||||||
# OLLAMA_MODEL_FAST=gemma4:latest
|
# OLLAMA_MODEL_FAST=gemma4:latest
|
||||||
# Vision (image + prompt). Must be a vision-capable tag. Default gemma4:latest.
|
|
||||||
# OLLAMA_MODEL_VISION=gemma4:latest
|
|
||||||
# OLLAMA_MODEL_UTILITY=llama3.2
|
# OLLAMA_MODEL_UTILITY=llama3.2
|
||||||
# OLLAMA_EMBED_MODEL=nomic-embed-text
|
# OLLAMA_EMBED_MODEL=nomic-embed-text
|
||||||
# OLLAMA_NUM_CTX_THINKING=16384
|
# OLLAMA_NUM_CTX_THINKING=16384
|
||||||
@@ -41,11 +39,8 @@ EMAIL_USE_TLS=true
|
|||||||
# Captcha (optional local)
|
# Captcha (optional local)
|
||||||
CAPTCHA_SECRET_KEY=
|
CAPTCHA_SECRET_KEY=
|
||||||
|
|
||||||
# Password self-serve sign-up (default true). Set false to close /user/create/.
|
# Self-serve sign-up (default false — set true to allow /user/create/)
|
||||||
ENABLE_ACCOUNT_REGISTRATION=true
|
ENABLE_ACCOUNT_REGISTRATION=false
|
||||||
# Browser SSO. Leave false until Google / Microsoft sign-in should be public.
|
|
||||||
# Client ids below can still be set for Drive linking.
|
|
||||||
ENABLE_SSO=false
|
|
||||||
|
|
||||||
# OAuth SSO — Google / Microsoft (#24). Leave blank to hide SSO buttons.
|
# OAuth SSO — Google / Microsoft (#24). Leave blank to hide SSO buttons.
|
||||||
# Redirect URIs (register in each IdP console):
|
# Redirect URIs (register in each IdP console):
|
||||||
@@ -88,12 +83,8 @@ STRIPE_WEBHOOK_SECRET=
|
|||||||
# Optional: pre-created Stripe Price ID for Founders. When empty, Checkout uses
|
# Optional: pre-created Stripe Price ID for Founders. When empty, Checkout uses
|
||||||
# SubscriptionPlan.price_cents / SUBSCRIPTION_PRICE_* ($10 USD / month Founders).
|
# SubscriptionPlan.price_cents / SUBSCRIPTION_PRICE_* ($10 USD / month Founders).
|
||||||
STRIPE_PRICE_ID=
|
STRIPE_PRICE_ID=
|
||||||
# Native store purchases (#78). iOS verifies the StoreKit signed transaction
|
# RevenueCat webhook Authorization bearer secret (store IAP).
|
||||||
# against Apple Root CA G3. Android calls the Play Developer API.
|
REVENUECAT_WEBHOOK_SECRET=
|
||||||
APPLE_IAP_BUNDLE_ID=ai.hesychia.chat
|
|
||||||
GOOGLE_PLAY_PACKAGE_NAME=ai.hesychia.chat
|
|
||||||
# Service-account JSON (one line) or a path to the JSON file. Empty disables Play confirm.
|
|
||||||
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON=
|
|
||||||
# Optional JSON map of store product id → plan slug, e.g.
|
# Optional JSON map of store product id → plan slug, e.g.
|
||||||
# REVENUECAT_PRODUCT_PLAN_MAP={"hesychia_founders_monthly":"founders"}
|
# REVENUECAT_PRODUCT_PLAN_MAP={"hesychia_founders_monthly":"founders"}
|
||||||
# SUBSCRIPTION_PRICE_AMOUNT_CENTS=1000
|
# SUBSCRIPTION_PRICE_AMOUNT_CENTS=1000
|
||||||
|
|||||||
+4
-12
@@ -37,8 +37,6 @@ OLLAMA_BASE_URL=http://10.0.0.128:11434
|
|||||||
OLLAMA_MODEL=gpt-oss:20b
|
OLLAMA_MODEL=gpt-oss:20b
|
||||||
OLLAMA_MODEL_THINKING=gpt-oss:20b
|
OLLAMA_MODEL_THINKING=gpt-oss:20b
|
||||||
OLLAMA_MODEL_FAST=gemma4:latest
|
OLLAMA_MODEL_FAST=gemma4:latest
|
||||||
# Vision (image + prompt). Must be a vision-capable tag.
|
|
||||||
OLLAMA_MODEL_VISION=gemma4:latest
|
|
||||||
OLLAMA_MODEL_UTILITY=llama3.2
|
OLLAMA_MODEL_UTILITY=llama3.2
|
||||||
OLLAMA_EMBED_MODEL=nomic-embed-text
|
OLLAMA_EMBED_MODEL=nomic-embed-text
|
||||||
OLLAMA_NUM_CTX_THINKING=16384
|
OLLAMA_NUM_CTX_THINKING=16384
|
||||||
@@ -61,13 +59,9 @@ EMAIL_USE_TLS=true
|
|||||||
# Captcha
|
# Captcha
|
||||||
CAPTCHA_SECRET_KEY=replace-with-captcha-secret
|
CAPTCHA_SECRET_KEY=replace-with-captcha-secret
|
||||||
|
|
||||||
# Password self-serve sign-up. Default true. Set false in
|
# Self-serve account registration (sign-up). Keep false until ready to open
|
||||||
# chat_backend_prod.env / chat_backend_beta.env to close public registration.
|
# public registration; set true in chat_backend_prod.env / chat_backend_beta.env.
|
||||||
# An explicit false in that secret overrides the code default.
|
ENABLE_ACCOUNT_REGISTRATION=false
|
||||||
ENABLE_ACCOUNT_REGISTRATION=true
|
|
||||||
# Browser SSO stays off. OAuth client ids below are for Drive linking until
|
|
||||||
# this is set true.
|
|
||||||
ENABLE_SSO=false
|
|
||||||
|
|
||||||
# OAuth SSO — Google / Microsoft (#24). Never commit real secrets.
|
# OAuth SSO — Google / Microsoft (#24). Never commit real secrets.
|
||||||
# Register redirect URIs:
|
# Register redirect URIs:
|
||||||
@@ -97,9 +91,7 @@ OAUTH_CALLBACK_BASE_URL=https://chatbackend.aimloperations.com
|
|||||||
STRIPE_SECRET_KEY=replace-with-stripe-secret-key
|
STRIPE_SECRET_KEY=replace-with-stripe-secret-key
|
||||||
STRIPE_PUBLISHABLE_KEY=replace-with-stripe-publishable-key
|
STRIPE_PUBLISHABLE_KEY=replace-with-stripe-publishable-key
|
||||||
STRIPE_WEBHOOK_SECRET=replace-with-stripe-webhook-secret
|
STRIPE_WEBHOOK_SECRET=replace-with-stripe-webhook-secret
|
||||||
APPLE_IAP_BUNDLE_ID=ai.hesychia.chat
|
REVENUECAT_WEBHOOK_SECRET=replace-with-revenuecat-webhook-auth-token
|
||||||
GOOGLE_PLAY_PACKAGE_NAME=ai.hesychia.chat
|
|
||||||
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON=replace-with-play-console-service-account-json
|
|
||||||
# REVENUECAT_PRODUCT_PLAN_MAP={"hesychia_founders_monthly":"founders"}
|
# REVENUECAT_PRODUCT_PLAN_MAP={"hesychia_founders_monthly":"founders"}
|
||||||
# Optional: pre-created Stripe Price ID. When empty, Checkout uses
|
# Optional: pre-created Stripe Price ID. When empty, Checkout uses
|
||||||
# SUBSCRIPTION_PRICE_* from settings.py ($10 USD / month by default).
|
# SUBSCRIPTION_PRICE_* from settings.py ($10 USD / month by default).
|
||||||
|
|||||||
+1
-2
@@ -9,8 +9,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends ca-certificates libpq5 \
|
&& apt-get install -y --no-install-recommends libpq5 \
|
||||||
&& update-ca-certificates \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||||
|
|||||||
@@ -97,12 +97,9 @@ with `COMPOSE_DATABASE_URL` if needed.
|
|||||||
| `SEARXNG_BASE_URL` | `http://127.0.0.1:8088` | yes if using SearxNG | Self-hosted SearxNG JSON API |
|
| `SEARXNG_BASE_URL` | `http://127.0.0.1:8088` | yes if using SearxNG | Self-hosted SearxNG JSON API |
|
||||||
| `EMAIL_HOST_*` | empty | yes (prod/beta) | SMTP2GO |
|
| `EMAIL_HOST_*` | empty | yes (prod/beta) | SMTP2GO |
|
||||||
| `CAPTCHA_SECRET_KEY` | empty | recommended | |
|
| `CAPTCHA_SECRET_KEY` | empty | recommended | |
|
||||||
| `ENABLE_ACCOUNT_REGISTRATION` | `true` | optional | Password self-serve sign-up. Set `false` to close it |
|
| `ENABLE_ACCOUNT_REGISTRATION` | `false` | optional | Self-serve sign-up; keep false until ready |
|
||||||
| `ENABLE_SSO` | `false` | optional | Google / Microsoft sign-in and sign-up. Drive linking does not need this |
|
|
||||||
| `STRIPE_SECRET_KEY` / `STRIPE_PUBLISHABLE_KEY` / `STRIPE_WEBHOOK_SECRET` | empty | yes for billing | Stripe API + webhook (`monetization` app) |
|
| `STRIPE_SECRET_KEY` / `STRIPE_PUBLISHABLE_KEY` / `STRIPE_WEBHOOK_SECRET` | empty | yes for billing | Stripe API + webhook (`monetization` app) |
|
||||||
| `APPLE_IAP_BUNDLE_ID` | `ai.hesychia.chat` | yes for iOS IAP | Must match the signed StoreKit transaction |
|
| `REVENUECAT_WEBHOOK_SECRET` | empty | yes for store IAP | RevenueCat webhook Authorization bearer |
|
||||||
| `GOOGLE_PLAY_PACKAGE_NAME` | `ai.hesychia.chat` | yes for Play IAP | Android application id |
|
|
||||||
| `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` | empty | yes for Play IAP | Play Console service-account JSON (or a file path) |
|
|
||||||
| `REVENUECAT_PRODUCT_PLAN_MAP` | empty JSON | optional | `{"product_id":"plan_slug"}` fallback map |
|
| `REVENUECAT_PRODUCT_PLAN_MAP` | empty JSON | optional | `{"product_id":"plan_slug"}` fallback map |
|
||||||
| `STRIPE_PRICE_ID` | empty | optional | Pre-created Price; else `$10/mo` from settings |
|
| `STRIPE_PRICE_ID` | empty | optional | Pre-created Price; else `$10/mo` from settings |
|
||||||
| `GOOGLE_OAUTH_CLIENT_ID` / `..._SECRET` | empty | for SSO/Drive | Also used for Drive linking (#47), incremental scopes |
|
| `GOOGLE_OAUTH_CLIENT_ID` / `..._SECRET` | empty | for SSO/Drive | Also used for Drive linking (#47), incremental scopes |
|
||||||
@@ -289,14 +286,10 @@ URLs: `/api/monetization/...` and alias `/api/finance/...`.
|
|||||||
(`POST /api/finance/portal/`). Local state syncs via
|
(`POST /api/finance/portal/`). Local state syncs via
|
||||||
`customer.subscription.updated` / `deleted` webhooks.
|
`customer.subscription.updated` / `deleted` webhooks.
|
||||||
|
|
||||||
**Native (Play / App Store):** the app buys through StoreKit or Play Billing,
|
**Native (Play / App Store):** RevenueCat webhooks at
|
||||||
then `POST /api/finance/store/confirm/` with the signed App Store transaction
|
`POST /api/finance/webhooks/revenuecat/` (Authorization bearer =
|
||||||
or the Play purchase token. The backend verifies it (Apple certificate chain,
|
`REVENUECAT_WEBHOOK_SECRET`) upsert Invoice/Payment ledger rows and sync
|
||||||
or Play Developer API `purchases.subscriptionsv2`) and upserts the ledger.
|
`UserSubscription` (`source=revenuecat`).
|
||||||
`source` is `app_store` or `play_store`. There is no store webhook. Renewals
|
|
||||||
are another confirm from the app; access ends when `current_period_end` passes.
|
|
||||||
`GET /api/finance/subscription/` includes `store_account_token` to pass into
|
|
||||||
the store purchase as `appAccountToken` / `obfuscatedAccountId`.
|
|
||||||
|
|
||||||
`GET /api/finance/subscription/` includes `cancel_at_period_end` and
|
`GET /api/finance/subscription/` includes `cancel_at_period_end` and
|
||||||
`current_period_end` for Account UI messaging.
|
`current_period_end` for Account UI messaging.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
|
import base64
|
||||||
import logging
|
import logging
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -13,7 +14,6 @@ from langchain_core.messages import HumanMessage, AIMessage
|
|||||||
from langchain_community.vectorstores import Chroma
|
from langchain_community.vectorstores import Chroma
|
||||||
from langchain_ollama import OllamaEmbeddings
|
from langchain_ollama import OllamaEmbeddings
|
||||||
from chat_backend.ollama_config import (
|
from chat_backend.ollama_config import (
|
||||||
ROLE_VISION,
|
|
||||||
ollama_embeddings_kwargs,
|
ollama_embeddings_kwargs,
|
||||||
ollama_model_for_role,
|
ollama_model_for_role,
|
||||||
resolve_chat_role,
|
resolve_chat_role,
|
||||||
@@ -38,7 +38,6 @@ from .services.title_generator import title_generator
|
|||||||
from .services.moderation_classifier import moderation_classifier, ModerationLabel
|
from .services.moderation_classifier import moderation_classifier, ModerationLabel
|
||||||
from .services.prompt_classifier.prompt_classifier import PromptClassifier, PromptType
|
from .services.prompt_classifier.prompt_classifier import PromptClassifier, PromptType
|
||||||
from .services.data_analysis_service import AsyncDataAnalysisService
|
from .services.data_analysis_service import AsyncDataAnalysisService
|
||||||
from .services.vision import AsyncVisionService, is_image_type, prepare_attachment
|
|
||||||
from .services.grounded_chat import prepare_grounded_chat
|
from .services.grounded_chat import prepare_grounded_chat
|
||||||
from .services.status_context import (
|
from .services.status_context import (
|
||||||
emit_status,
|
emit_status,
|
||||||
@@ -143,10 +142,7 @@ def get_messages(conversation_id, prompt, file_string: str = None, file_type: st
|
|||||||
prompt_instance.file_type = file_type
|
prompt_instance.file_type = file_type
|
||||||
prompt_instance.save()
|
prompt_instance.save()
|
||||||
|
|
||||||
for prompt_obj in (
|
for prompt_obj in Prompt.objects.filter(conversation__id=conversation_id):
|
||||||
Prompt.objects.filter(conversation__id=conversation_id)
|
|
||||||
.order_by("created", "id")
|
|
||||||
):
|
|
||||||
messages.append(
|
messages.append(
|
||||||
{
|
{
|
||||||
"content": prompt_obj.message,
|
"content": prompt_obj.message,
|
||||||
@@ -282,9 +278,7 @@ async def get_conversation_file_async(conversation_id):
|
|||||||
# Get the very first prompt in the conversation that has a file
|
# Get the very first prompt in the conversation that has a file
|
||||||
prompt_with_file = await Prompt.objects.filter(
|
prompt_with_file = await Prompt.objects.filter(
|
||||||
conversation_id=conversation_id
|
conversation_id=conversation_id
|
||||||
).exclude(file='').exclude(
|
).exclude(file='').order_by('created').afirst()
|
||||||
file_type__in=["png", "jpeg", "jpg", "webp", "gif"]
|
|
||||||
).order_by('created').afirst()
|
|
||||||
|
|
||||||
if prompt_with_file and prompt_with_file.file:
|
if prompt_with_file and prompt_with_file.file:
|
||||||
# Opening a DatabaseStorage file hits the DB, so read inside the thread.
|
# Opening a DatabaseStorage file hits the DB, so read inside the thread.
|
||||||
@@ -420,19 +414,29 @@ class ChatConsumerAgain(AsyncWebsocketConsumer):
|
|||||||
decoded_file = None
|
decoded_file = None
|
||||||
|
|
||||||
if file:
|
if file:
|
||||||
decoded_file, file_type, attachment_error = prepare_attachment(
|
decoded_file = base64.b64decode(file)
|
||||||
file, file_type
|
logger.debug(decoded_file)
|
||||||
)
|
# The `altered_message` should only be created if a file exists
|
||||||
if attachment_error:
|
# and you want to pass its content directly to the classifier.
|
||||||
await self.send_json_message(
|
# Here, we'll let the classifier decide based on the user's prompt
|
||||||
json.dumps(
|
# and then handle the file content separately.
|
||||||
{
|
altered_message = message
|
||||||
"type": "error",
|
if "csv" in file_type:
|
||||||
"content": attachment_error,
|
file_type = "csv"
|
||||||
}
|
#altered_message = f"{message}\n The file type is csv and the file contents are: {decoded_file}"
|
||||||
)
|
elif "xmlformats-officedocument" in file_type:
|
||||||
)
|
file_type = "xlsx"
|
||||||
return
|
#df = pd.read_excel(decoded_file)
|
||||||
|
#altered_message = f"{message}\n The file type is xlsx and the file contents are: {df}"
|
||||||
|
elif "word" in file_type:
|
||||||
|
file_type = "docx"
|
||||||
|
elif "pdf" in file_type:
|
||||||
|
file_type = "pdf"
|
||||||
|
elif "text" in file_type:
|
||||||
|
file_type = "txt"
|
||||||
|
#altered_message = f"{message}\n The file type is txt and the file contents are: {decoded_file}"
|
||||||
|
else:
|
||||||
|
file_type = "Not Sure"
|
||||||
|
|
||||||
logger.info(f'received: "{message}" for conversation {conversation_id}')
|
logger.info(f'received: "{message}" for conversation {conversation_id}')
|
||||||
|
|
||||||
@@ -472,15 +476,6 @@ class ChatConsumerAgain(AsyncWebsocketConsumer):
|
|||||||
decoded_file = input_dict.get("decoded_file")
|
decoded_file = input_dict.get("decoded_file")
|
||||||
file_type = input_dict.get("file_type")
|
file_type = input_dict.get("file_type")
|
||||||
|
|
||||||
if is_image_type(file_type) and decoded_file:
|
|
||||||
service = AsyncVisionService()
|
|
||||||
input_dict["_resolved_model"] = service.model_name
|
|
||||||
input_dict["_citations"] = []
|
|
||||||
await emit_status("reading_image")
|
|
||||||
return service.generate_response(
|
|
||||||
prompt_instance.message, decoded_file, file_type
|
|
||||||
)
|
|
||||||
|
|
||||||
# Feature Flag + plan gate: Image Generation
|
# Feature Flag + plan gate: Image Generation
|
||||||
if prompt_type == PromptType.IMAGE_GENERATION:
|
if prompt_type == PromptType.IMAGE_GENERATION:
|
||||||
if not getattr(settings, "ALLOW_IMAGE_GENERATION", False):
|
if not getattr(settings, "ALLOW_IMAGE_GENERATION", False):
|
||||||
@@ -608,9 +603,7 @@ class ChatConsumerAgain(AsyncWebsocketConsumer):
|
|||||||
# messages = messages[:-1] + [HumanMessage(content=altered_message)]
|
# messages = messages[:-1] + [HumanMessage(content=altered_message)]
|
||||||
# I'll add it to the input_dict if needed.
|
# I'll add it to the input_dict if needed.
|
||||||
|
|
||||||
resolved_model = ollama_model_for_role(
|
resolved_model = ollama_model_for_role(resolve_chat_role(model))
|
||||||
ROLE_VISION if is_image_type(file_type) else resolve_chat_role(model)
|
|
||||||
)
|
|
||||||
prompt_metric = await create_prompt_metric(
|
prompt_metric = await create_prompt_metric(
|
||||||
prompt_instance.id,
|
prompt_instance.id,
|
||||||
prompt_instance.message,
|
prompt_instance.message,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
|
import base64
|
||||||
import logging
|
import logging
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -28,7 +29,6 @@ from .services.title_generator import title_generator
|
|||||||
from .services.moderation_classifier import moderation_classifier, ModerationLabel
|
from .services.moderation_classifier import moderation_classifier, ModerationLabel
|
||||||
from .services.prompt_classifier.prompt_classifier import PromptClassifier, PromptType
|
from .services.prompt_classifier.prompt_classifier import PromptClassifier, PromptType
|
||||||
from .services.data_analysis_service import AsyncDataAnalysisService
|
from .services.data_analysis_service import AsyncDataAnalysisService
|
||||||
from .services.vision import AsyncVisionService, is_image_type, prepare_attachment
|
|
||||||
from .services.grounded_chat import prepare_grounded_chat
|
from .services.grounded_chat import prepare_grounded_chat
|
||||||
from .services.status_context import (
|
from .services.status_context import (
|
||||||
emit_status,
|
emit_status,
|
||||||
@@ -36,11 +36,7 @@ from .services.status_context import (
|
|||||||
set_status_emitter,
|
set_status_emitter,
|
||||||
)
|
)
|
||||||
from .services.ws_frames import citations_frame, status_frame
|
from .services.ws_frames import citations_frame, status_frame
|
||||||
from chat_backend.ollama_config import (
|
from chat_backend.ollama_config import ollama_model_for_role, resolve_chat_role
|
||||||
ROLE_VISION,
|
|
||||||
ollama_model_for_role,
|
|
||||||
resolve_chat_role,
|
|
||||||
)
|
|
||||||
from .utils import (
|
from .utils import (
|
||||||
TokenUsageCollector,
|
TokenUsageCollector,
|
||||||
aiter_text_chunks,
|
aiter_text_chunks,
|
||||||
@@ -126,10 +122,7 @@ def get_messages(conversation_id, prompt, file_string: str = None, file_type: st
|
|||||||
prompt_instance.file_type = file_type
|
prompt_instance.file_type = file_type
|
||||||
prompt_instance.save()
|
prompt_instance.save()
|
||||||
|
|
||||||
for prompt_obj in (
|
for prompt_obj in Prompt.objects.filter(conversation__id=conversation_id):
|
||||||
Prompt.objects.filter(conversation__id=conversation_id)
|
|
||||||
.order_by("created", "id")
|
|
||||||
):
|
|
||||||
messages.append(
|
messages.append(
|
||||||
{
|
{
|
||||||
"content": prompt_obj.message,
|
"content": prompt_obj.message,
|
||||||
@@ -209,9 +202,7 @@ async def get_conversation_file_async(conversation_id):
|
|||||||
try:
|
try:
|
||||||
prompt_with_file = await Prompt.objects.filter(
|
prompt_with_file = await Prompt.objects.filter(
|
||||||
conversation_id=conversation_id
|
conversation_id=conversation_id
|
||||||
).exclude(file='').exclude(
|
).exclude(file='').order_by('created').afirst()
|
||||||
file_type__in=["png", "jpeg", "jpg", "webp", "gif"]
|
|
||||||
).order_by('created').afirst()
|
|
||||||
|
|
||||||
if prompt_with_file and prompt_with_file.file:
|
if prompt_with_file and prompt_with_file.file:
|
||||||
# Opening a DatabaseStorage file hits the DB, so read inside the thread.
|
# Opening a DatabaseStorage file hits the DB, so read inside the thread.
|
||||||
@@ -259,10 +250,6 @@ async def classification_node(state: ChatState) -> ChatState:
|
|||||||
|
|
||||||
prompt_type = await PROMPT_CLASSIFIER.classify_async(msg)
|
prompt_type = await PROMPT_CLASSIFIER.classify_async(msg)
|
||||||
|
|
||||||
# Images are read by the vision model, not the spreadsheet parser.
|
|
||||||
if is_image_type(state.get("file_type")):
|
|
||||||
return {"prompt_type": PromptType.GENERAL_CHAT}
|
|
||||||
|
|
||||||
# Override logic
|
# Override logic
|
||||||
if decoded_file and (prompt_type == PromptType.DATA_ANALYSIS or 'analyze' in msg.lower() or 'data' in msg.lower()):
|
if decoded_file and (prompt_type == PromptType.DATA_ANALYSIS or 'analyze' in msg.lower() or 'data' in msg.lower()):
|
||||||
prompt_type = PromptType.DATA_ANALYSIS
|
prompt_type = PromptType.DATA_ANALYSIS
|
||||||
@@ -283,17 +270,6 @@ async def generation_node(state: ChatState) -> ChatState:
|
|||||||
decoded_file = state.get("decoded_file")
|
decoded_file = state.get("decoded_file")
|
||||||
file_type = state.get("file_type")
|
file_type = state.get("file_type")
|
||||||
|
|
||||||
if is_image_type(file_type) and decoded_file:
|
|
||||||
service = AsyncVisionService()
|
|
||||||
await emit_status("reading_image")
|
|
||||||
generator = service.generate_response(
|
|
||||||
prompt_instance.message, decoded_file, file_type
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"response_generator": generator,
|
|
||||||
"resolved_model": service.model_name,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Feature Flag + plan gate: Image Generation
|
# Feature Flag + plan gate: Image Generation
|
||||||
if prompt_type == PromptType.IMAGE_GENERATION:
|
if prompt_type == PromptType.IMAGE_GENERATION:
|
||||||
if not getattr(settings, "ALLOW_IMAGE_GENERATION", False):
|
if not getattr(settings, "ALLOW_IMAGE_GENERATION", False):
|
||||||
@@ -509,19 +485,13 @@ class ChatConsumerGraph(AsyncWebsocketConsumer):
|
|||||||
print("Conversation ID: ", conversation_id)
|
print("Conversation ID: ", conversation_id)
|
||||||
decoded_file = None
|
decoded_file = None
|
||||||
if file:
|
if file:
|
||||||
decoded_file, file_type, attachment_error = prepare_attachment(
|
decoded_file = base64.b64decode(file)
|
||||||
file, file_type
|
if "csv" in file_type: file_type = "csv"
|
||||||
)
|
elif "xmlformats-officedocument" in file_type: file_type = "xlsx"
|
||||||
if attachment_error:
|
elif "word" in file_type: file_type = "docx"
|
||||||
await self.send_json_message(
|
elif "pdf" in file_type: file_type = "pdf"
|
||||||
json.dumps(
|
elif "text" in file_type: file_type = "txt"
|
||||||
{
|
else: file_type = "Not Sure"
|
||||||
"type": "error",
|
|
||||||
"content": attachment_error,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Pre-fetch messages and file
|
# Pre-fetch messages and file
|
||||||
messages, prompt_instance = await get_messages(
|
messages, prompt_instance = await get_messages(
|
||||||
@@ -531,9 +501,7 @@ class ChatConsumerGraph(AsyncWebsocketConsumer):
|
|||||||
if not decoded_file:
|
if not decoded_file:
|
||||||
decoded_file, file_type = await get_conversation_file_async(conversation_id)
|
decoded_file, file_type = await get_conversation_file_async(conversation_id)
|
||||||
|
|
||||||
resolved_model = ollama_model_for_role(
|
resolved_model = ollama_model_for_role(resolve_chat_role(model))
|
||||||
ROLE_VISION if is_image_type(file_type) else resolve_chat_role(model)
|
|
||||||
)
|
|
||||||
prompt_metric = await create_prompt_metric(
|
prompt_metric = await create_prompt_metric(
|
||||||
prompt_instance.id,
|
prompt_instance.id,
|
||||||
prompt_instance.message,
|
prompt_instance.message,
|
||||||
@@ -588,7 +556,7 @@ class ChatConsumerGraph(AsyncWebsocketConsumer):
|
|||||||
citations = []
|
citations = []
|
||||||
final_model = resolved_model
|
final_model = resolved_model
|
||||||
|
|
||||||
if should_use_agent(message) and not is_image_type(file_type):
|
if should_use_agent(message):
|
||||||
try:
|
try:
|
||||||
await enforce_feature_gate(chat_user, "agentic_tasks")
|
await enforce_feature_gate(chat_user, "agentic_tasks")
|
||||||
except FeatureNotAllowed as exc:
|
except FeatureNotAllowed as exc:
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ from django.db import migrations, models
|
|||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("chat_backend", "0034_customuser_use_conversation_context"),
|
("chat_backend", "0033_agentrun_agentstep"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
def enable_same_chat_history(apps, schema_editor):
|
|
||||||
"""Turn on thread history for accounts still on the old default (#84).
|
|
||||||
|
|
||||||
The column defaulted to false, so an explicit opt-out cannot be told apart
|
|
||||||
from "never touched". Follow-ups were ignoring the open chat. Accounts that
|
|
||||||
still want one-shot answers can turn the setting off again.
|
|
||||||
"""
|
|
||||||
CustomUser = apps.get_model("chat_backend", "CustomUser")
|
|
||||||
CustomUser.objects.filter(use_conversation_context=False).update(
|
|
||||||
use_conversation_context=True
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("chat_backend", "0035_waitlistentry"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="customuser",
|
|
||||||
name="use_conversation_context",
|
|
||||||
field=models.BooleanField(
|
|
||||||
default=True,
|
|
||||||
help_text=(
|
|
||||||
"When enabled, earlier turns in the open chat are included "
|
|
||||||
"when answering a follow-up. Turn off to answer each message alone."
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.RunPython(enable_same_chat_history, migrations.RunPython.noop),
|
|
||||||
]
|
|
||||||
@@ -74,10 +74,10 @@ class CustomUser(AbstractUser):
|
|||||||
default=True, help_text="How the conversations should display"
|
default=True, help_text="How the conversations should display"
|
||||||
)
|
)
|
||||||
use_conversation_context = models.BooleanField(
|
use_conversation_context = models.BooleanField(
|
||||||
default=True,
|
default=False,
|
||||||
help_text=(
|
help_text=(
|
||||||
"When enabled, earlier turns in the open chat are included "
|
"When enabled, prior turns in the conversation are used as "
|
||||||
"when answering a follow-up. Turn off to answer each message alone."
|
"LLM/RAG context for a more tailored experience"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -525,7 +525,9 @@ class DriveConnection(TimeInfoBase):
|
|||||||
help_text="Required for company connections; optional for personal (#55).",
|
help_text="Required for company connections; optional for personal (#55).",
|
||||||
)
|
)
|
||||||
provider = models.CharField(max_length=32, choices=Provider.choices)
|
provider = models.CharField(max_length=32, choices=Provider.choices)
|
||||||
kind = models.CharField(max_length=16, choices=Kind.choices, default=Kind.PERSONAL)
|
kind = models.CharField(
|
||||||
|
max_length=16, choices=Kind.choices, default=Kind.PERSONAL
|
||||||
|
)
|
||||||
access_token = models.TextField(blank=True, default="")
|
access_token = models.TextField(blank=True, default="")
|
||||||
refresh_token = models.TextField(blank=True, default="")
|
refresh_token = models.TextField(blank=True, default="")
|
||||||
token_expires_at = models.DateTimeField(null=True, blank=True)
|
token_expires_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|||||||
@@ -459,8 +459,8 @@ def resolve_user_from_profile(
|
|||||||
upsert_identity(email_user, profile)
|
upsert_identity(email_user, profile)
|
||||||
return email_user, False
|
return email_user, False
|
||||||
|
|
||||||
# New account path. Password registration does not enable SSO sign-up.
|
# New account path
|
||||||
allow_create = settings.ENABLE_ACCOUNT_REGISTRATION and settings.ENABLE_SSO
|
allow_create = settings.ENABLE_ACCOUNT_REGISTRATION
|
||||||
if intent == "signup" and not allow_create:
|
if intent == "signup" and not allow_create:
|
||||||
raise OAuthError("registration_disabled", "Account registration is disabled.")
|
raise OAuthError("registration_disabled", "Account registration is disabled.")
|
||||||
if intent == "login" and not allow_create:
|
if intent == "login" and not allow_create:
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ ROLE_EMBED = "embed"
|
|||||||
# independent plan steps concurrently on a smaller/cheaper model.
|
# independent plan steps concurrently on a smaller/cheaper model.
|
||||||
ROLE_ORCHESTRATOR = "orchestrator"
|
ROLE_ORCHESTRATOR = "orchestrator"
|
||||||
ROLE_SUBAGENT = "subagent"
|
ROLE_SUBAGENT = "subagent"
|
||||||
# Image + prompt turns (#128). Never falls back to the text chat model.
|
|
||||||
ROLE_VISION = "vision"
|
|
||||||
|
|
||||||
_VALID_ROLES = {
|
_VALID_ROLES = {
|
||||||
ROLE_THINKING,
|
ROLE_THINKING,
|
||||||
@@ -27,7 +25,6 @@ _VALID_ROLES = {
|
|||||||
ROLE_EMBED,
|
ROLE_EMBED,
|
||||||
ROLE_ORCHESTRATOR,
|
ROLE_ORCHESTRATOR,
|
||||||
ROLE_SUBAGENT,
|
ROLE_SUBAGENT,
|
||||||
ROLE_VISION,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -59,7 +56,6 @@ def ollama_model_for_role(role: str) -> str:
|
|||||||
ROLE_EMBED: "OLLAMA_EMBED_MODEL",
|
ROLE_EMBED: "OLLAMA_EMBED_MODEL",
|
||||||
ROLE_ORCHESTRATOR: "OLLAMA_MODEL_ORCHESTRATOR",
|
ROLE_ORCHESTRATOR: "OLLAMA_MODEL_ORCHESTRATOR",
|
||||||
ROLE_SUBAGENT: "OLLAMA_MODEL_SUBAGENT",
|
ROLE_SUBAGENT: "OLLAMA_MODEL_SUBAGENT",
|
||||||
ROLE_VISION: "OLLAMA_MODEL_VISION",
|
|
||||||
}[role]
|
}[role]
|
||||||
role_default = {
|
role_default = {
|
||||||
ROLE_THINKING: "gpt-oss:20b",
|
ROLE_THINKING: "gpt-oss:20b",
|
||||||
@@ -68,15 +64,14 @@ def ollama_model_for_role(role: str) -> str:
|
|||||||
ROLE_EMBED: "nomic-embed-text",
|
ROLE_EMBED: "nomic-embed-text",
|
||||||
ROLE_ORCHESTRATOR: "gpt-oss:20b",
|
ROLE_ORCHESTRATOR: "gpt-oss:20b",
|
||||||
ROLE_SUBAGENT: "llama3.2",
|
ROLE_SUBAGENT: "llama3.2",
|
||||||
ROLE_VISION: "gemma4:latest",
|
|
||||||
}[role]
|
}[role]
|
||||||
|
|
||||||
configured = getattr(settings, role_setting, None)
|
configured = getattr(settings, role_setting, None)
|
||||||
if configured:
|
if configured:
|
||||||
return configured
|
return configured
|
||||||
|
|
||||||
# Embeddings and vision must never silently fall back to a text chat model.
|
# Embeddings must never silently fall back to a chat model (#62).
|
||||||
if role in (ROLE_EMBED, ROLE_VISION):
|
if role == ROLE_EMBED:
|
||||||
return role_default
|
return role_default
|
||||||
|
|
||||||
legacy = getattr(settings, "OLLAMA_MODEL", None)
|
legacy = getattr(settings, "OLLAMA_MODEL", None)
|
||||||
@@ -99,8 +94,6 @@ def ollama_num_ctx_for_role(role: str) -> int:
|
|||||||
return int(getattr(settings, "OLLAMA_NUM_CTX_SUBAGENT", 8192) or 8192)
|
return int(getattr(settings, "OLLAMA_NUM_CTX_SUBAGENT", 8192) or 8192)
|
||||||
if role == ROLE_ORCHESTRATOR:
|
if role == ROLE_ORCHESTRATOR:
|
||||||
return int(getattr(settings, "OLLAMA_NUM_CTX_ORCHESTRATOR", 16384) or 16384)
|
return int(getattr(settings, "OLLAMA_NUM_CTX_ORCHESTRATOR", 16384) or 16384)
|
||||||
if role == ROLE_VISION:
|
|
||||||
return int(getattr(settings, "OLLAMA_NUM_CTX_VISION", 8192) or 8192)
|
|
||||||
return int(getattr(settings, "OLLAMA_NUM_CTX_THINKING", 16384) or 16384)
|
return int(getattr(settings, "OLLAMA_NUM_CTX_THINKING", 16384) or 16384)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -257,7 +257,6 @@ class PromptSerializer(serializers.ModelSerializer):
|
|||||||
tokens_in = serializers.SerializerMethodField()
|
tokens_in = serializers.SerializerMethodField()
|
||||||
tokens_out = serializers.SerializerMethodField()
|
tokens_out = serializers.SerializerMethodField()
|
||||||
feedback = serializers.SerializerMethodField()
|
feedback = serializers.SerializerMethodField()
|
||||||
image = serializers.SerializerMethodField()
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Prompt
|
model = Prompt
|
||||||
@@ -270,10 +269,8 @@ class PromptSerializer(serializers.ModelSerializer):
|
|||||||
"tokens_out",
|
"tokens_out",
|
||||||
"citations",
|
"citations",
|
||||||
"feedback",
|
"feedback",
|
||||||
"file_type",
|
|
||||||
"image",
|
|
||||||
)
|
)
|
||||||
read_only_fields = ("citations", "feedback", "file_type", "image")
|
read_only_fields = ("citations", "feedback")
|
||||||
|
|
||||||
def _token_pair(self, obj):
|
def _token_pair(self, obj):
|
||||||
cache = self.context.setdefault("_prompt_token_cache", {})
|
cache = self.context.setdefault("_prompt_token_cache", {})
|
||||||
@@ -317,11 +314,6 @@ class PromptSerializer(serializers.ModelSerializer):
|
|||||||
self.context["_prompt_feedback_by_id"] = by_prompt
|
self.context["_prompt_feedback_by_id"] = by_prompt
|
||||||
return by_prompt.get(obj.id)
|
return by_prompt.get(obj.id)
|
||||||
|
|
||||||
def get_image(self, obj):
|
|
||||||
from chat_backend.services.vision import image_base64
|
|
||||||
|
|
||||||
return image_base64(obj)
|
|
||||||
|
|
||||||
def validate_message(self, value: str) -> str:
|
def validate_message(self, value: str) -> str:
|
||||||
if value is None or not str(value).strip():
|
if value is None or not str(value).strip():
|
||||||
raise serializers.ValidationError("Message text cannot be empty.")
|
raise serializers.ValidationError("Message text cannot be empty.")
|
||||||
|
|||||||
@@ -13,18 +13,12 @@ ASSISTANT_SYSTEM_PROMPT = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
GROUNDED_ANSWER_INSTRUCTIONS = (
|
GROUNDED_ANSWER_INSTRUCTIONS = (
|
||||||
"You may have numbered live sources and the conversation so far. "
|
"You have been given numbered live sources. Answer ONLY from those sources. "
|
||||||
"Use the conversation for follow-ups: explain a previous answer, reformat it "
|
"Cite source indexes inline like [1] or [2]. "
|
||||||
"(including a table of items already named), and reply to lines such as "
|
"If the sources do not settle the question, say so explicitly — do not fill "
|
||||||
'"I don\'t know, why?". Do not say that list or answer is missing when it '
|
"gaps from memory or training data. "
|
||||||
"is already in the conversation. "
|
|
||||||
"Cite source indexes inline like [1] or [2] only for facts taken from live sources. "
|
|
||||||
"If a new external fact (price, rating, date, news) is in neither the conversation "
|
|
||||||
"nor the sources, say that specific fact is unavailable, and still complete the "
|
|
||||||
"part the conversation already answers. "
|
|
||||||
"When sources conflict, prefer the most recent dated source. "
|
"When sources conflict, prefer the most recent dated source. "
|
||||||
"Never invent a date, number, name, or event that is in neither the conversation "
|
"Never state a date, number, name, or event that does not appear in the sources."
|
||||||
"nor the sources."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
RETRIEVAL_FAILED_MESSAGE = (
|
RETRIEVAL_FAILED_MESSAGE = (
|
||||||
|
|||||||
@@ -88,8 +88,8 @@ async def prepare_grounded_chat(
|
|||||||
``on_status(stage, detail)`` emits live activity frames (#96) when provided;
|
``on_status(stage, detail)`` emits live activity frames (#96) when provided;
|
||||||
otherwise uses the context-bound emitter from :mod:`status_context`.
|
otherwise uses the context-bound emitter from :mod:`status_context`.
|
||||||
|
|
||||||
``use_conversation_context`` gates prior-turn history in the LLM prompt.
|
``use_conversation_context`` gates prior-turn history in the LLM prompt
|
||||||
The account setting defaults on so follow-ups see this chat (#84).
|
(#33); default off (opt-in).
|
||||||
"""
|
"""
|
||||||
gen_kwargs = {"use_conversation_context": use_conversation_context}
|
gen_kwargs = {"use_conversation_context": use_conversation_context}
|
||||||
internet = getattr(settings, "ALLOW_INTERNET_ACCESS", False)
|
internet = getattr(settings, "ALLOW_INTERNET_ACCESS", False)
|
||||||
@@ -104,9 +104,7 @@ async def prepare_grounded_chat(
|
|||||||
)
|
)
|
||||||
|
|
||||||
await _emit(on_status, "evaluating")
|
await _emit(on_status, "evaluating")
|
||||||
# Prior turns exist once the current user message is not the only row.
|
decision = await grounding_decider.decide_async(message)
|
||||||
has_history = bool(use_conversation_context) and len(messages) > 1
|
|
||||||
decision = await grounding_decider.decide_async(message, has_history=has_history)
|
|
||||||
|
|
||||||
if not decision.needs_retrieval:
|
if not decision.needs_retrieval:
|
||||||
await _emit(on_status, "refining")
|
await _emit(on_status, "refining")
|
||||||
|
|||||||
@@ -43,28 +43,6 @@ _TEMPORAL_PATTERNS = (
|
|||||||
_TEMPORAL_RE = re.compile("|".join(_TEMPORAL_PATTERNS), re.IGNORECASE)
|
_TEMPORAL_RE = re.compile("|".join(_TEMPORAL_PATTERNS), re.IGNORECASE)
|
||||||
_YEAR_RE = re.compile(r"\b((?:19|20)\d{2})\b")
|
_YEAR_RE = re.compile(r"\b((?:19|20)\d{2})\b")
|
||||||
|
|
||||||
# Pure follow-ups should reuse the open chat, not a fresh web search (#84).
|
|
||||||
_PURE_FOLLOWUP_RE = re.compile(
|
|
||||||
r"("
|
|
||||||
r"\bi don'?t know\b"
|
|
||||||
r"|^\s*why\b"
|
|
||||||
r"|\bwhy\s*\??\s*$"
|
|
||||||
r"|\bwhat do you mean\b"
|
|
||||||
r"|\bexplain\b"
|
|
||||||
r"|\b(that|this|the|your) (list|joke|answer|setup|punchline)\b"
|
|
||||||
r"|\bmake (that|this|it|them)\b"
|
|
||||||
r"|\bput (that|this|it|them)\b"
|
|
||||||
r"|\bin a table\b"
|
|
||||||
r"|\binto a table\b"
|
|
||||||
r")",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
# These still need live sources even when the sentence points at earlier turns.
|
|
||||||
_NEW_EXTERNAL_FACT_RE = re.compile(
|
|
||||||
r"\b(price|prices|rating|ratings|cost|costs|review|reviews|cheapest)\b",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class GroundingDecision:
|
class GroundingDecision:
|
||||||
@@ -129,28 +107,6 @@ message). Example: "did Taylor Swift get married" →
|
|||||||
source="prepass",
|
source="prepass",
|
||||||
)
|
)
|
||||||
|
|
||||||
def followup_prepass(
|
|
||||||
self, prompt: str, *, has_history: bool
|
|
||||||
) -> GroundingDecision | None:
|
|
||||||
"""Skip web search when the line only continues the open chat (#84)."""
|
|
||||||
if not has_history:
|
|
||||||
return None
|
|
||||||
text = (prompt or "").strip()
|
|
||||||
if not text or not _PURE_FOLLOWUP_RE.search(text):
|
|
||||||
return None
|
|
||||||
if _NEW_EXTERNAL_FACT_RE.search(text):
|
|
||||||
return None
|
|
||||||
temporal = self.temporal_prepass(text)
|
|
||||||
if temporal is not None and temporal.needs_retrieval:
|
|
||||||
return None
|
|
||||||
return GroundingDecision(
|
|
||||||
needs_retrieval=False,
|
|
||||||
reason="follow-up on the current conversation",
|
|
||||||
queries=[],
|
|
||||||
temporal=False,
|
|
||||||
source="prepass",
|
|
||||||
)
|
|
||||||
|
|
||||||
def _parse(self, raw: str, fallback_query: str) -> GroundingDecision:
|
def _parse(self, raw: str, fallback_query: str) -> GroundingDecision:
|
||||||
text = (raw or "").strip()
|
text = (raw or "").strip()
|
||||||
# Strip markdown fences if the small model wraps JSON.
|
# Strip markdown fences if the small model wraps JSON.
|
||||||
@@ -188,12 +144,7 @@ message). Example: "did Taylor Swift get married" →
|
|||||||
source="model",
|
source="model",
|
||||||
)
|
)
|
||||||
|
|
||||||
async def decide_async(
|
async def decide_async(self, prompt: str) -> GroundingDecision:
|
||||||
self, prompt: str, *, has_history: bool = False
|
|
||||||
) -> GroundingDecision:
|
|
||||||
follow = self.followup_prepass(prompt, has_history=has_history)
|
|
||||||
if follow is not None:
|
|
||||||
return follow
|
|
||||||
pre = self.temporal_prepass(prompt)
|
pre = self.temporal_prepass(prompt)
|
||||||
# Even on temporal prepass, ask the model for better queries when possible.
|
# Even on temporal prepass, ask the model for better queries when possible.
|
||||||
try:
|
try:
|
||||||
@@ -222,10 +173,7 @@ message). Example: "did Taylor Swift get married" →
|
|||||||
)
|
)
|
||||||
return decision
|
return decision
|
||||||
|
|
||||||
def decide(self, prompt: str, *, has_history: bool = False) -> GroundingDecision:
|
def decide(self, prompt: str) -> GroundingDecision:
|
||||||
follow = self.followup_prepass(prompt, has_history=has_history)
|
|
||||||
if follow is not None:
|
|
||||||
return follow
|
|
||||||
pre = self.temporal_prepass(prompt)
|
pre = self.temporal_prepass(prompt)
|
||||||
try:
|
try:
|
||||||
raw = self.chain.invoke({"prompt": prompt})
|
raw = self.chain.invoke({"prompt": prompt})
|
||||||
|
|||||||
@@ -173,12 +173,16 @@ Response:"""
|
|||||||
block (when grounded) is never trimmed.
|
block (when grounded) is never trimmed.
|
||||||
"""
|
"""
|
||||||
sources = self.sources_block or kwargs.get("sources_block", "") or ""
|
sources = self.sources_block or kwargs.get("sources_block", "") or ""
|
||||||
use_conversation_context = bool(kwargs.get("use_conversation_context", False))
|
use_conversation_context = bool(
|
||||||
|
kwargs.get("use_conversation_context", False)
|
||||||
|
)
|
||||||
history_text = ""
|
history_text = ""
|
||||||
if use_conversation_context:
|
if use_conversation_context:
|
||||||
reserved = (
|
reserved = (
|
||||||
estimate_tokens(ASSISTANT_SYSTEM_PROMPT)
|
estimate_tokens(ASSISTANT_SYSTEM_PROMPT)
|
||||||
+ estimate_tokens(GROUNDED_ANSWER_INSTRUCTIONS if self.grounded else "")
|
+ estimate_tokens(
|
||||||
|
GROUNDED_ANSWER_INSTRUCTIONS if self.grounded else ""
|
||||||
|
)
|
||||||
+ estimate_tokens(sources)
|
+ estimate_tokens(sources)
|
||||||
+ estimate_tokens(query)
|
+ estimate_tokens(query)
|
||||||
+ 256 # response headroom / instructions
|
+ 256 # response headroom / instructions
|
||||||
@@ -199,9 +203,8 @@ Response:"""
|
|||||||
and str(getattr(m, "content", "")).startswith("Live sources:")
|
and str(getattr(m, "content", "")).startswith("Live sources:")
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
# Current user turn is passed as {query}. Drop that text, not
|
# Exclude the latest user turn from history (it's in {query}).
|
||||||
# whichever row happens to be last.
|
prior = clean[:-1] if clean else []
|
||||||
prior = prior_turns(clean, query)
|
|
||||||
windowed = window_history(
|
windowed = window_history(
|
||||||
prior, budget_tokens=history_budget, reserved_tokens=0
|
prior, budget_tokens=history_budget, reserved_tokens=0
|
||||||
)
|
)
|
||||||
@@ -217,33 +220,6 @@ Response:"""
|
|||||||
yield chunk
|
yield chunk
|
||||||
|
|
||||||
|
|
||||||
def message_text(message) -> str:
|
|
||||||
text = getattr(message, "content", "") or ""
|
|
||||||
if not isinstance(text, str):
|
|
||||||
text = str(text)
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
def prior_turns(messages, query: str) -> list:
|
|
||||||
"""Drop the current user turn from history.
|
|
||||||
|
|
||||||
Match the query text, newest hit first, so an unordered transcript cannot
|
|
||||||
drop a different turn just because it sits at the end of the list (#84).
|
|
||||||
If the query is not in the list, keep every turn.
|
|
||||||
"""
|
|
||||||
if not messages:
|
|
||||||
return []
|
|
||||||
needle = (query or "").strip()
|
|
||||||
for index in range(len(messages) - 1, -1, -1):
|
|
||||||
message = messages[index]
|
|
||||||
if (
|
|
||||||
getattr(message, "type", "") == "human"
|
|
||||||
and message_text(message).strip() == needle
|
|
||||||
):
|
|
||||||
return list(messages[:index]) + list(messages[index + 1 :])
|
|
||||||
return list(messages)
|
|
||||||
|
|
||||||
|
|
||||||
def build_chat_service(
|
def build_chat_service(
|
||||||
*,
|
*,
|
||||||
model_name: str | None,
|
model_name: str | None,
|
||||||
|
|||||||
@@ -1,186 +0,0 @@
|
|||||||
"""Image attachments on a chat turn (chat_web_app#128).
|
|
||||||
|
|
||||||
Document uploads stay on the csv/xlsx/pdf path. An image is stored on the
|
|
||||||
prompt and sent to a vision-capable Ollama model with the user's text.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from chat_backend.ollama_config import (
|
|
||||||
ROLE_VISION,
|
|
||||||
ollama_base_url,
|
|
||||||
ollama_model_for_role,
|
|
||||||
ollama_num_ctx_for_role,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
MAX_IMAGE_BYTES = 8 * 1024 * 1024
|
|
||||||
|
|
||||||
IMAGE_MIME = {
|
|
||||||
"png": "image/png",
|
|
||||||
"jpeg": "image/jpeg",
|
|
||||||
"jpg": "image/jpeg",
|
|
||||||
"webp": "image/webp",
|
|
||||||
"gif": "image/gif",
|
|
||||||
}
|
|
||||||
|
|
||||||
_IMAGE_EXTENSIONS = frozenset(IMAGE_MIME)
|
|
||||||
|
|
||||||
|
|
||||||
class VisionChunk:
|
|
||||||
"""Stream piece that ``chunk_text`` and ``extract_token_usage`` both read."""
|
|
||||||
|
|
||||||
def __init__(self, text: str = "", info: dict | None = None):
|
|
||||||
self.content = text
|
|
||||||
self.generation_info = info or None
|
|
||||||
|
|
||||||
|
|
||||||
def canonical_file_type(file_type: str) -> str:
|
|
||||||
"""Map a browser MIME (or short extension) to the stored file_type.
|
|
||||||
|
|
||||||
Order matches the historical websocket normalizer so spreadsheets that
|
|
||||||
share the openxml MIME family still land on ``xlsx``.
|
|
||||||
"""
|
|
||||||
lowered = (file_type or "").lower()
|
|
||||||
if "csv" in lowered:
|
|
||||||
return "csv"
|
|
||||||
if "xmlformats-officedocument" in lowered:
|
|
||||||
return "xlsx"
|
|
||||||
if "word" in lowered:
|
|
||||||
return "docx"
|
|
||||||
if "pdf" in lowered:
|
|
||||||
return "pdf"
|
|
||||||
if "text" in lowered:
|
|
||||||
return "txt"
|
|
||||||
if "png" in lowered:
|
|
||||||
return "png"
|
|
||||||
if "jpeg" in lowered or "jpg" in lowered:
|
|
||||||
return "jpeg"
|
|
||||||
if "webp" in lowered:
|
|
||||||
return "webp"
|
|
||||||
if "gif" in lowered:
|
|
||||||
return "gif"
|
|
||||||
if lowered.startswith("image/"):
|
|
||||||
return "unsupported_image"
|
|
||||||
return "Not Sure"
|
|
||||||
|
|
||||||
|
|
||||||
def is_image_type(file_type: str | None) -> bool:
|
|
||||||
return (file_type or "").lower() in _IMAGE_EXTENSIONS
|
|
||||||
|
|
||||||
|
|
||||||
def image_mime(file_type: str | None) -> str:
|
|
||||||
return IMAGE_MIME.get((file_type or "").lower(), "application/octet-stream")
|
|
||||||
|
|
||||||
|
|
||||||
def prepare_attachment(file_b64: str, file_type: str) -> tuple[bytes, str, str | None]:
|
|
||||||
"""Decode an upload. ``error`` is a user-facing string when the image is rejected."""
|
|
||||||
try:
|
|
||||||
decoded = base64.b64decode(file_b64, validate=False)
|
|
||||||
except Exception:
|
|
||||||
return b"", "Not Sure", "Could not read the attached file."
|
|
||||||
|
|
||||||
canonical = canonical_file_type(file_type)
|
|
||||||
if canonical == "unsupported_image":
|
|
||||||
return (
|
|
||||||
decoded,
|
|
||||||
canonical,
|
|
||||||
"Unsupported image. Use PNG, JPEG, WebP, or GIF.",
|
|
||||||
)
|
|
||||||
if is_image_type(canonical) and len(decoded) > MAX_IMAGE_BYTES:
|
|
||||||
return (
|
|
||||||
decoded,
|
|
||||||
canonical,
|
|
||||||
"Image is too large. Maximum size is 8 MB.",
|
|
||||||
)
|
|
||||||
return decoded, canonical, None
|
|
||||||
|
|
||||||
|
|
||||||
def image_base64(prompt) -> str | None:
|
|
||||||
"""Base64 of a stored prompt image, or None for non-images."""
|
|
||||||
if not is_image_type(getattr(prompt, "file_type", None)):
|
|
||||||
return None
|
|
||||||
if not prompt.file_exists():
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
data = prompt.file.read()
|
|
||||||
except Exception:
|
|
||||||
logger.exception(
|
|
||||||
"failed to read prompt image prompt_id=%s", getattr(prompt, "id", None)
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
if not data:
|
|
||||||
return None
|
|
||||||
if isinstance(data, str):
|
|
||||||
data = data.encode()
|
|
||||||
return base64.b64encode(data).decode("ascii")
|
|
||||||
|
|
||||||
|
|
||||||
class AsyncVisionService:
|
|
||||||
"""Stream an answer from Ollama's chat API with one image."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.model_name = ollama_model_for_role(ROLE_VISION)
|
|
||||||
self.num_ctx = ollama_num_ctx_for_role(ROLE_VISION)
|
|
||||||
|
|
||||||
async def generate_response(self, query: str, image_bytes: bytes, file_type: str):
|
|
||||||
payload = {
|
|
||||||
"model": self.model_name,
|
|
||||||
"stream": True,
|
|
||||||
"options": {"num_ctx": self.num_ctx},
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": (
|
|
||||||
"The user attached an image. Answer their question using what "
|
|
||||||
"is visible in the image, including any text in it.\n\n"
|
|
||||||
f"{query}"
|
|
||||||
),
|
|
||||||
"images": [base64.b64encode(image_bytes).decode("ascii")],
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
url = f"{ollama_base_url().rstrip('/')}/api/chat"
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=httpx.Timeout(180.0)) as client:
|
|
||||||
async with client.stream("POST", url, json=payload) as response:
|
|
||||||
if response.status_code >= 400:
|
|
||||||
body = await response.aread()
|
|
||||||
logger.error(
|
|
||||||
"vision model rejected request status=%s body=%s",
|
|
||||||
response.status_code,
|
|
||||||
body[:500],
|
|
||||||
)
|
|
||||||
yield VisionChunk(
|
|
||||||
text="The vision model could not read that image."
|
|
||||||
)
|
|
||||||
return
|
|
||||||
async for line in response.aiter_lines():
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
event = json.loads(line)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
continue
|
|
||||||
message = event.get("message") or {}
|
|
||||||
text = message.get("content") or ""
|
|
||||||
if text:
|
|
||||||
yield VisionChunk(text=text)
|
|
||||||
if event.get("done"):
|
|
||||||
info = {
|
|
||||||
key: event[key]
|
|
||||||
for key in ("prompt_eval_count", "eval_count")
|
|
||||||
if key in event
|
|
||||||
}
|
|
||||||
if info:
|
|
||||||
yield VisionChunk(info=info)
|
|
||||||
except httpx.HTTPError:
|
|
||||||
logger.exception("vision request failed model=%s", self.model_name)
|
|
||||||
yield VisionChunk(text="The vision model could not read that image.")
|
|
||||||
@@ -23,7 +23,6 @@ STATUS_LABELS: dict[str, str] = {
|
|||||||
"reading_sources": "Reading sources",
|
"reading_sources": "Reading sources",
|
||||||
"retrieving_docs": "Searching your documents",
|
"retrieving_docs": "Searching your documents",
|
||||||
"analysing": "Analysing your file",
|
"analysing": "Analysing your file",
|
||||||
"reading_image": "Reading your image",
|
|
||||||
"refining": "Refining the answer",
|
"refining": "Refining the answer",
|
||||||
"writing": "", # tokens streaming — no label
|
"writing": "", # tokens streaming — no label
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import json
|
|
||||||
from datetime import timedelta
|
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
import json
|
||||||
|
|
||||||
from asgiref.sync import sync_to_async
|
from asgiref.sync import sync_to_async
|
||||||
from channels.testing import WebsocketCommunicator
|
from channels.testing import WebsocketCommunicator
|
||||||
from django.test import TestCase, TransactionTestCase, override_settings
|
from django.test import TestCase, TransactionTestCase, override_settings
|
||||||
from django.utils import timezone
|
|
||||||
from langchain_core.messages import AIMessage, HumanMessage
|
from langchain_core.messages import AIMessage, HumanMessage
|
||||||
from parameterized import parameterized
|
from parameterized import parameterized
|
||||||
|
|
||||||
@@ -49,7 +47,9 @@ class DatabaseHelperTestCase(TransactionTestCase):
|
|||||||
|
|
||||||
@parameterized.expand([("websocket", consumers), ("langgraph", consumers_graph)])
|
@parameterized.expand([("websocket", consumers), ("langgraph", consumers_graph)])
|
||||||
async def test_get_workspace(self, _name, module):
|
async def test_get_workspace(self, _name, module):
|
||||||
workspace = await module.get_workspace(self.conversation.id, user=self.user)
|
workspace = await module.get_workspace(
|
||||||
|
self.conversation.id, user=self.user
|
||||||
|
)
|
||||||
|
|
||||||
self.assertEqual(workspace.id, self.workspace.id)
|
self.assertEqual(workspace.id, self.workspace.id)
|
||||||
|
|
||||||
@@ -99,29 +99,6 @@ class DatabaseHelperTestCase(TransactionTestCase):
|
|||||||
[type(message) for message in messages], [AIMessage, HumanMessage]
|
[type(message) for message in messages], [AIMessage, HumanMessage]
|
||||||
)
|
)
|
||||||
|
|
||||||
@parameterized.expand([("websocket", consumers), ("langgraph", consumers_graph)])
|
|
||||||
async def test_get_messages_orders_by_created_not_insert_order(self, _name, module):
|
|
||||||
now = timezone.now()
|
|
||||||
await sync_to_async(Prompt.objects.create)(
|
|
||||||
conversation=self.conversation,
|
|
||||||
message="newer answer",
|
|
||||||
user_created=False,
|
|
||||||
created=now,
|
|
||||||
)
|
|
||||||
await sync_to_async(Prompt.objects.create)(
|
|
||||||
conversation=self.conversation,
|
|
||||||
message="older question",
|
|
||||||
user_created=True,
|
|
||||||
created=now - timedelta(minutes=5),
|
|
||||||
)
|
|
||||||
|
|
||||||
messages, _prompt = await module.get_messages(self.conversation.id, "follow up")
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
[message.content for message in messages],
|
|
||||||
["older question", "newer answer", "follow up"],
|
|
||||||
)
|
|
||||||
|
|
||||||
async def test_get_messages_stores_attachment_and_inlines_csv(self):
|
async def test_get_messages_stores_attachment_and_inlines_csv(self):
|
||||||
messages, prompt_instance = await consumers.get_messages(
|
messages, prompt_instance = await consumers.get_messages(
|
||||||
self.conversation.id, "analyze this", b"name,sales\nalice,10\n", "csv"
|
self.conversation.id, "analyze this", b"name,sales\nalice,10\n", "csv"
|
||||||
@@ -378,42 +355,6 @@ class GraphNodeTestCase(TransactionTestCase):
|
|||||||
self.prompt.message, b"a,b\n1,2\n", "csv"
|
self.prompt.message, b"a,b\n1,2\n", "csv"
|
||||||
)
|
)
|
||||||
|
|
||||||
async def test_generation_node_sends_images_to_vision(self):
|
|
||||||
with mock.patch.object(consumers_graph, "AsyncVisionService") as service:
|
|
||||||
service.return_value.model_name = "gemma4:latest"
|
|
||||||
service.return_value.generate_response.return_value = "generator"
|
|
||||||
|
|
||||||
result = await consumers_graph.generation_node(
|
|
||||||
self._state(
|
|
||||||
prompt_type=PromptType.DATA_ANALYSIS,
|
|
||||||
message="what does this say?",
|
|
||||||
decoded_file=b"\x89PNG",
|
|
||||||
file_type="png",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(result["response_generator"], "generator")
|
|
||||||
self.assertEqual(result["resolved_model"], "gemma4:latest")
|
|
||||||
service.return_value.generate_response.assert_called_once_with(
|
|
||||||
self.prompt.message, b"\x89PNG", "png"
|
|
||||||
)
|
|
||||||
|
|
||||||
async def test_attached_image_is_not_data_analysis(self):
|
|
||||||
with mock.patch.object(
|
|
||||||
consumers_graph.PROMPT_CLASSIFIER,
|
|
||||||
"classify_async",
|
|
||||||
mock.AsyncMock(return_value=PromptType.DATA_ANALYSIS),
|
|
||||||
):
|
|
||||||
result = await consumers_graph.classification_node(
|
|
||||||
self._state(
|
|
||||||
message="analyze this chart",
|
|
||||||
decoded_file=b"\x89PNG",
|
|
||||||
file_type="png",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(result["prompt_type"], PromptType.GENERAL_CHAT)
|
|
||||||
|
|
||||||
async def test_generation_node_routes_rag_prompts_with_workspace(self):
|
async def test_generation_node_routes_rag_prompts_with_workspace(self):
|
||||||
with mock.patch.object(consumers_graph, "AsyncRAGService") as service:
|
with mock.patch.object(consumers_graph, "AsyncRAGService") as service:
|
||||||
service.return_value.generate_response.return_value = "generator"
|
service.return_value.generate_response.return_value = "generator"
|
||||||
@@ -610,16 +551,13 @@ class WebSocketReceiveGuardTestCase(TransactionTestCase):
|
|||||||
connected, _ = await communicator.connect()
|
connected, _ = await communicator.connect()
|
||||||
self.assertTrue(connected)
|
self.assertTrue(connected)
|
||||||
|
|
||||||
with (
|
with mock.patch(
|
||||||
mock.patch(
|
|
||||||
"chat_backend.consumers.title_generator.generate_async",
|
"chat_backend.consumers.title_generator.generate_async",
|
||||||
new_callable=mock.AsyncMock,
|
new_callable=mock.AsyncMock,
|
||||||
) as title_chat,
|
) as title_chat, mock.patch(
|
||||||
mock.patch(
|
|
||||||
"chat_backend.consumers_graph.title_generator.generate_async",
|
"chat_backend.consumers_graph.title_generator.generate_async",
|
||||||
new_callable=mock.AsyncMock,
|
new_callable=mock.AsyncMock,
|
||||||
) as title_graph,
|
) as title_graph:
|
||||||
):
|
|
||||||
await communicator.send_json_to({"type": "ping", "email": "a@b.com"})
|
await communicator.send_json_to({"type": "ping", "email": "a@b.com"})
|
||||||
# No reply expected; give the event loop a tick.
|
# No reply expected; give the event loop a tick.
|
||||||
self.assertTrue(await communicator.receive_nothing(timeout=0.2))
|
self.assertTrue(await communicator.receive_nothing(timeout=0.2))
|
||||||
@@ -638,16 +576,13 @@ class WebSocketReceiveGuardTestCase(TransactionTestCase):
|
|||||||
connected, _ = await communicator.connect()
|
connected, _ = await communicator.connect()
|
||||||
self.assertTrue(connected)
|
self.assertTrue(connected)
|
||||||
|
|
||||||
with (
|
with mock.patch(
|
||||||
mock.patch(
|
|
||||||
"chat_backend.consumers.title_generator.generate_async",
|
"chat_backend.consumers.title_generator.generate_async",
|
||||||
new_callable=mock.AsyncMock,
|
new_callable=mock.AsyncMock,
|
||||||
) as title_chat,
|
) as title_chat, mock.patch(
|
||||||
mock.patch(
|
|
||||||
"chat_backend.consumers_graph.title_generator.generate_async",
|
"chat_backend.consumers_graph.title_generator.generate_async",
|
||||||
new_callable=mock.AsyncMock,
|
new_callable=mock.AsyncMock,
|
||||||
) as title_graph,
|
) as title_graph:
|
||||||
):
|
|
||||||
await communicator.send_json_to(
|
await communicator.send_json_to(
|
||||||
{"message": " ", "email": "a@b.com", "conversation_id": None}
|
{"message": " ", "email": "a@b.com", "conversation_id": None}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -86,25 +86,6 @@ class GroundingPrepassTestCase(SimpleTestCase):
|
|||||||
self.assertEqual(decision.source, "fail_open")
|
self.assertEqual(decision.source, "fail_open")
|
||||||
self.assertEqual(decision.queries, ["fallback query"])
|
self.assertEqual(decision.queries, ["fallback query"])
|
||||||
|
|
||||||
def test_pure_followup_skips_retrieval_without_calling_model(self):
|
|
||||||
self.decider.chain = mock.Mock()
|
|
||||||
decision = self.decider.decide("I don't know. Why?", has_history=True)
|
|
||||||
self.assertFalse(decision.needs_retrieval)
|
|
||||||
self.assertEqual(decision.reason, "follow-up on the current conversation")
|
|
||||||
self.decider.chain.invoke.assert_not_called()
|
|
||||||
|
|
||||||
def test_followup_without_history_does_not_short_circuit(self):
|
|
||||||
self.assertIsNone(
|
|
||||||
self.decider.followup_prepass("I don't know. Why?", has_history=False)
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_price_followup_still_allows_retrieval(self):
|
|
||||||
decision = self.decider.followup_prepass(
|
|
||||||
"Can you make that list for me and add the prices and ratings in a table",
|
|
||||||
has_history=True,
|
|
||||||
)
|
|
||||||
self.assertIsNone(decision)
|
|
||||||
|
|
||||||
def test_decide_fail_open_on_llm_error(self):
|
def test_decide_fail_open_on_llm_error(self):
|
||||||
self.decider.chain = mock.Mock()
|
self.decider.chain = mock.Mock()
|
||||||
self.decider.chain.invoke.side_effect = RuntimeError("ollama down")
|
self.decider.chain.invoke.side_effect = RuntimeError("ollama down")
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
from django.db import connection
|
|
||||||
from django.db.migrations.loader import MigrationLoader
|
|
||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
|
|
||||||
class MigrationGraphTests(TestCase):
|
|
||||||
def test_chat_backend_has_a_single_leaf(self):
|
|
||||||
loader = MigrationLoader(connection, ignore_no_migrations=True)
|
|
||||||
leaves = [
|
|
||||||
name for app, name in loader.graph.leaf_nodes() if app == "chat_backend"
|
|
||||||
]
|
|
||||||
self.assertEqual(leaves, ["0036_enable_conversation_context"])
|
|
||||||
|
|
||||||
node = loader.graph.nodes[("chat_backend", "0036_enable_conversation_context")]
|
|
||||||
parents = [name for app, name in node.dependencies if app == "chat_backend"]
|
|
||||||
self.assertEqual(parents, ["0035_waitlistentry"])
|
|
||||||
@@ -81,7 +81,7 @@ class CompanyAndUserTestCase(TestCase):
|
|||||||
self.assertFalse(user.deleted)
|
self.assertFalse(user.deleted)
|
||||||
self.assertFalse(user.has_signed_tos)
|
self.assertFalse(user.has_signed_tos)
|
||||||
self.assertTrue(user.conversation_order)
|
self.assertTrue(user.conversation_order)
|
||||||
self.assertTrue(user.use_conversation_context)
|
self.assertFalse(user.use_conversation_context)
|
||||||
|
|
||||||
|
|
||||||
class ConversationAndPromptTestCase(TestCase):
|
class ConversationAndPromptTestCase(TestCase):
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ OAUTH_SETTINGS = {
|
|||||||
"FRONTEND_BASE_URL": "http://frontend.test",
|
"FRONTEND_BASE_URL": "http://frontend.test",
|
||||||
"OAUTH_CALLBACK_BASE_URL": "http://backend.test",
|
"OAUTH_CALLBACK_BASE_URL": "http://backend.test",
|
||||||
"ENABLE_ACCOUNT_REGISTRATION": True,
|
"ENABLE_ACCOUNT_REGISTRATION": True,
|
||||||
"ENABLE_SSO": True,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -54,14 +53,6 @@ class PublicSettingsOAuthTestCase(APITestCase):
|
|||||||
self.assertTrue(response.data["oauth"]["google"])
|
self.assertTrue(response.data["oauth"]["google"])
|
||||||
self.assertTrue(response.data["oauth"]["microsoft"])
|
self.assertTrue(response.data["oauth"]["microsoft"])
|
||||||
|
|
||||||
@override_settings(ENABLE_SSO=False)
|
|
||||||
def test_hides_oauth_when_sso_disabled(self):
|
|
||||||
response = self.client.get(reverse("public_settings"))
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertTrue(response.data["enable_account_registration"])
|
|
||||||
self.assertFalse(response.data["oauth"]["google"])
|
|
||||||
self.assertFalse(response.data["oauth"]["microsoft"])
|
|
||||||
|
|
||||||
@override_settings(GOOGLE_OAUTH_CLIENT_ID="", GOOGLE_OAUTH_CLIENT_SECRET="")
|
@override_settings(GOOGLE_OAUTH_CLIENT_ID="", GOOGLE_OAUTH_CLIENT_SECRET="")
|
||||||
def test_hides_unconfigured_provider(self):
|
def test_hides_unconfigured_provider(self):
|
||||||
response = self.client.get(reverse("public_settings"))
|
response = self.client.get(reverse("public_settings"))
|
||||||
@@ -92,15 +83,6 @@ class OAuthStartTestCase(APITestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
@override_settings(ENABLE_SSO=False)
|
|
||||||
def test_login_and_signup_blocked_when_sso_disabled(self):
|
|
||||||
for intent in ("login", "signup"):
|
|
||||||
response = self.client.get(
|
|
||||||
reverse("oauth_start", kwargs={"provider": "google"}),
|
|
||||||
{"intent": intent},
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
|
||||||
|
|
||||||
@override_settings(ENABLE_ACCOUNT_REGISTRATION=False)
|
@override_settings(ENABLE_ACCOUNT_REGISTRATION=False)
|
||||||
def test_signup_intent_blocked_when_registration_disabled(self):
|
def test_signup_intent_blocked_when_registration_disabled(self):
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
@@ -198,17 +180,6 @@ class OAuthCallbackTestCase(APITestCase):
|
|||||||
params = parse_qs(urlparse(response["Location"]).query)
|
params = parse_qs(urlparse(response["Location"]).query)
|
||||||
self.assertEqual(params["error"], ["email_missing"])
|
self.assertEqual(params["error"], ["email_missing"])
|
||||||
|
|
||||||
@patch("chat_backend.views_oauth.exchange_code_for_profile")
|
|
||||||
@override_settings(ENABLE_SSO=False)
|
|
||||||
def test_callback_signup_rejected_when_sso_disabled(self, mock_exchange):
|
|
||||||
mock_exchange.return_value = _google_profile()
|
|
||||||
|
|
||||||
response = self._callback(intent="signup")
|
|
||||||
params = parse_qs(urlparse(response["Location"]).query)
|
|
||||||
self.assertEqual(params["error"], ["sso_disabled"])
|
|
||||||
self.assertEqual(CustomUser.objects.count(), 0)
|
|
||||||
mock_exchange.assert_not_called()
|
|
||||||
|
|
||||||
@patch("chat_backend.views_oauth.exchange_code_for_profile")
|
@patch("chat_backend.views_oauth.exchange_code_for_profile")
|
||||||
@override_settings(ENABLE_ACCOUNT_REGISTRATION=False)
|
@override_settings(ENABLE_ACCOUNT_REGISTRATION=False)
|
||||||
def test_callback_login_without_account_when_registration_off(self, mock_exchange):
|
def test_callback_login_without_account_when_registration_off(self, mock_exchange):
|
||||||
@@ -285,15 +256,6 @@ class OAuthStartDriveLinkTestCase(APITestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
@override_settings(ENABLE_SSO=False)
|
|
||||||
def test_link_drive_still_starts_when_sso_disabled(self):
|
|
||||||
self.client.force_authenticate(user=self.user)
|
|
||||||
response = self.client.get(
|
|
||||||
reverse("oauth_start", kwargs={"provider": "google"}),
|
|
||||||
{"intent": "link_drive"},
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_302_FOUND)
|
|
||||||
|
|
||||||
def test_link_drive_authenticated_redirects_with_drive_scope_and_state(self):
|
def test_link_drive_authenticated_redirects_with_drive_scope_and_state(self):
|
||||||
self.client.force_authenticate(user=self.user)
|
self.client.force_authenticate(user=self.user)
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
from django.test import SimpleTestCase
|
from django.test import SimpleTestCase
|
||||||
from langchain_core.messages import AIMessage, HumanMessage
|
from langchain_core.messages import AIMessage, HumanMessage
|
||||||
|
|
||||||
from chat_backend.services.assistant_identity import GROUNDED_ANSWER_INSTRUCTIONS
|
from chat_backend.services.llm_service import AsyncLLMService, SyncLLMService
|
||||||
from chat_backend.services.llm_service import (
|
|
||||||
AsyncLLMService,
|
|
||||||
SyncLLMService,
|
|
||||||
prior_turns,
|
|
||||||
)
|
|
||||||
from chat_backend.services.prompt_budget import format_history, window_history
|
from chat_backend.services.prompt_budget import format_history, window_history
|
||||||
|
|
||||||
from .fakes import FakeChain
|
from .fakes import FakeChain
|
||||||
@@ -41,7 +36,7 @@ class AsyncLLMServiceTestCase(SimpleTestCase):
|
|||||||
|
|
||||||
async def test_generate_response_sends_single_history_window(self):
|
async def test_generate_response_sends_single_history_window(self):
|
||||||
self.service.conversation_chain = FakeChain(chunks=["ok"])
|
self.service.conversation_chain = FakeChain(chunks=["ok"])
|
||||||
messages = conversation(4) + [HumanMessage(content="latest")]
|
messages = conversation(4) # 8 messages
|
||||||
|
|
||||||
async for _ in self.service.generate_response(
|
async for _ in self.service.generate_response(
|
||||||
messages, "latest", 1, use_conversation_context=True
|
messages, "latest", 1, use_conversation_context=True
|
||||||
@@ -52,33 +47,10 @@ class AsyncLLMServiceTestCase(SimpleTestCase):
|
|||||||
self.assertEqual(payload["query"], "latest")
|
self.assertEqual(payload["query"], "latest")
|
||||||
# Latest user turn is in {query}; history is prior turns only, once.
|
# Latest user turn is in {query}; history is prior turns only, once.
|
||||||
self.assertIn("history", payload)
|
self.assertIn("history", payload)
|
||||||
self.assertNotIn("User: latest", payload["history"])
|
|
||||||
self.assertIn("AI: answer 3", payload["history"])
|
|
||||||
self.assertNotIn("recent_conversation", payload)
|
self.assertNotIn("recent_conversation", payload)
|
||||||
self.assertNotIn("conversation", payload)
|
self.assertNotIn("conversation", payload)
|
||||||
self.assertLessEqual(len(payload["history"].splitlines()), 8)
|
# 8 messages → drop last (query) → 7 prior lines max in window.
|
||||||
|
self.assertLessEqual(len(payload["history"].splitlines()), 7)
|
||||||
async def test_history_drops_matching_turn_even_if_it_is_not_last(self):
|
|
||||||
self.service.conversation_chain = FakeChain(chunks=["ok"])
|
|
||||||
# Unordered transcript: current question is first, older turns follow.
|
|
||||||
messages = [
|
|
||||||
HumanMessage(content="I don't know. Why?"),
|
|
||||||
AIMessage(content="Why did the meditation instructor bring a ladder?"),
|
|
||||||
HumanMessage(content="Tell me a joke"),
|
|
||||||
]
|
|
||||||
|
|
||||||
async for _ in self.service.generate_response(
|
|
||||||
messages,
|
|
||||||
"I don't know. Why?",
|
|
||||||
1,
|
|
||||||
use_conversation_context=True,
|
|
||||||
):
|
|
||||||
pass
|
|
||||||
|
|
||||||
history = self.service.conversation_chain.calls[0]["history"]
|
|
||||||
self.assertNotIn("I don't know", history)
|
|
||||||
self.assertIn("Tell me a joke", history)
|
|
||||||
self.assertIn("meditation instructor", history)
|
|
||||||
|
|
||||||
async def test_generate_response_skips_history_when_context_disabled(self):
|
async def test_generate_response_skips_history_when_context_disabled(self):
|
||||||
self.service.conversation_chain = FakeChain(chunks=["ok"])
|
self.service.conversation_chain = FakeChain(chunks=["ok"])
|
||||||
@@ -101,9 +73,7 @@ class AsyncLLMServiceTestCase(SimpleTestCase):
|
|||||||
self.assertEqual(self.service.conversation_chain.calls[0]["history"], "")
|
self.assertEqual(self.service.conversation_chain.calls[0]["history"], "")
|
||||||
|
|
||||||
async def test_grounded_service_includes_sources(self):
|
async def test_grounded_service_includes_sources(self):
|
||||||
service = AsyncLLMService(
|
service = AsyncLLMService(grounded=True, sources_block='[1] "T" — x.com — undated')
|
||||||
grounded=True, sources_block='[1] "T" — x.com — undated'
|
|
||||||
)
|
|
||||||
service.conversation_chain = FakeChain(chunks=["ok"])
|
service.conversation_chain = FakeChain(chunks=["ok"])
|
||||||
|
|
||||||
async for _ in service.generate_response(
|
async for _ in service.generate_response(
|
||||||
@@ -120,24 +90,6 @@ class SyncLLMServiceTestCase(SimpleTestCase):
|
|||||||
self.assertIsNotNone(SyncLLMService())
|
self.assertIsNotNone(SyncLLMService())
|
||||||
|
|
||||||
|
|
||||||
class PriorTurnsTestCase(SimpleTestCase):
|
|
||||||
def test_grounded_instructions_keep_the_conversation(self):
|
|
||||||
self.assertIn("conversation", GROUNDED_ANSWER_INSTRUCTIONS.lower())
|
|
||||||
self.assertNotIn("Answer ONLY from those sources", GROUNDED_ANSWER_INSTRUCTIONS)
|
|
||||||
|
|
||||||
def test_prior_turns_drops_newest_matching_user_text(self):
|
|
||||||
messages = [
|
|
||||||
HumanMessage(content="I don't know. Why?"),
|
|
||||||
AIMessage(content="the joke"),
|
|
||||||
HumanMessage(content="Tell me a joke"),
|
|
||||||
]
|
|
||||||
kept = prior_turns(messages, "I don't know. Why?")
|
|
||||||
self.assertEqual(
|
|
||||||
[message.content for message in kept],
|
|
||||||
["the joke", "Tell me a joke"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PromptBudgetTestCase(SimpleTestCase):
|
class PromptBudgetTestCase(SimpleTestCase):
|
||||||
def test_window_history_drops_oldest_first(self):
|
def test_window_history_drops_oldest_first(self):
|
||||||
messages = [HumanMessage(content="x" * 40) for _ in range(10)]
|
messages = [HumanMessage(content="x" * 40) for _ in range(10)]
|
||||||
@@ -147,5 +99,7 @@ class PromptBudgetTestCase(SimpleTestCase):
|
|||||||
self.assertEqual(kept[-1].content, messages[-1].content)
|
self.assertEqual(kept[-1].content, messages[-1].content)
|
||||||
|
|
||||||
def test_format_history_labels_speakers(self):
|
def test_format_history_labels_speakers(self):
|
||||||
text = format_history([HumanMessage(content="hello"), AIMessage(content="hi")])
|
text = format_history(
|
||||||
|
[HumanMessage(content="hello"), AIMessage(content="hi")]
|
||||||
|
)
|
||||||
self.assertEqual(text, "User: hello\nAI: hi")
|
self.assertEqual(text, "User: hello\nAI: hi")
|
||||||
@@ -1,7 +1,4 @@
|
|||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
@@ -60,7 +57,7 @@ class ConversationPreferencesTestCase(APITestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertTrue(response.data["order"])
|
self.assertTrue(response.data["order"])
|
||||||
self.assertTrue(response.data["use_conversation_context"])
|
self.assertFalse(response.data["use_conversation_context"])
|
||||||
|
|
||||||
def test_post_toggles_and_persists_order(self):
|
def test_post_toggles_and_persists_order(self):
|
||||||
response = self.client.post(self.url)
|
response = self.client.post(self.url)
|
||||||
@@ -118,33 +115,8 @@ class ConversationDetailViewTestCase(APITestCase):
|
|||||||
[("hello", True), ("hi there", False)],
|
[("hello", True), ("hi there", False)],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_get_returns_prompts_oldest_first_even_if_inserted_newest_first(self):
|
|
||||||
now = timezone.now()
|
|
||||||
make_prompt(
|
|
||||||
self.conversation,
|
|
||||||
message="later",
|
|
||||||
user_created=False,
|
|
||||||
created=now,
|
|
||||||
)
|
|
||||||
make_prompt(
|
|
||||||
self.conversation,
|
|
||||||
message="earlier",
|
|
||||||
user_created=True,
|
|
||||||
created=now - timedelta(minutes=5),
|
|
||||||
)
|
|
||||||
|
|
||||||
response = self.client.get(self.url, {"conversation_id": self.conversation.id})
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertEqual(
|
|
||||||
[item["message"] for item in response.data],
|
|
||||||
["earlier", "later"],
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_get_rejects_other_users_conversation(self):
|
def test_get_rejects_other_users_conversation(self):
|
||||||
other = make_user(
|
other = make_user(email="other-tenant@example.com", company=make_company("Other"))
|
||||||
email="other-tenant@example.com", company=make_company("Other")
|
|
||||||
)
|
|
||||||
foreign = make_conversation(user=other)
|
foreign = make_conversation(user=other)
|
||||||
make_prompt(foreign, message="secret")
|
make_prompt(foreign, message="secret")
|
||||||
|
|
||||||
|
|||||||
@@ -115,27 +115,9 @@ class PublicSettingsTestCase(APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(response.data["enable_account_registration"], False)
|
self.assertEqual(response.data["enable_account_registration"], False)
|
||||||
|
|
||||||
def test_registration_on_and_sso_hidden_by_default(self):
|
|
||||||
response = self.client.get(reverse("public_settings"))
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertEqual(response.data["enable_account_registration"], True)
|
|
||||||
self.assertEqual(response.data["oauth"], {"google": False, "microsoft": False})
|
|
||||||
|
|
||||||
|
|
||||||
class UserCreateTestCase(APITestCase):
|
class UserCreateTestCase(APITestCase):
|
||||||
def test_registration_enabled_by_default(self):
|
def test_registration_disabled_by_default(self):
|
||||||
response = self.client.post(
|
|
||||||
reverse("create_user"),
|
|
||||||
{"email": "new@example.com", "password": "securepass1"},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
|
||||||
self.assertTrue(CustomUser.objects.filter(email="new@example.com").exists())
|
|
||||||
|
|
||||||
def test_registration_disabled_when_setting_off(self):
|
|
||||||
with self.settings(ENABLE_ACCOUNT_REGISTRATION=False):
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse("create_user"),
|
reverse("create_user"),
|
||||||
{"email": "new@example.com", "password": "securepass1"},
|
{"email": "new@example.com", "password": "securepass1"},
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
import base64
|
|
||||||
|
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
|
||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
from chat_backend.services.vision import (
|
|
||||||
canonical_file_type,
|
|
||||||
image_base64,
|
|
||||||
is_image_type,
|
|
||||||
prepare_attachment,
|
|
||||||
)
|
|
||||||
from chat_backend.tests.factories import make_conversation, make_prompt
|
|
||||||
|
|
||||||
|
|
||||||
class VisionAttachmentTestCase(TestCase):
|
|
||||||
def test_canonical_types(self):
|
|
||||||
self.assertEqual(canonical_file_type("image/png"), "png")
|
|
||||||
self.assertEqual(canonical_file_type("image/jpeg"), "jpeg")
|
|
||||||
self.assertEqual(canonical_file_type("image/jpg"), "jpeg")
|
|
||||||
self.assertEqual(canonical_file_type("image/webp"), "webp")
|
|
||||||
self.assertEqual(canonical_file_type("image/gif"), "gif")
|
|
||||||
self.assertEqual(canonical_file_type("text/plain"), "txt")
|
|
||||||
self.assertEqual(canonical_file_type("application/pdf"), "pdf")
|
|
||||||
self.assertEqual(canonical_file_type("image/bmp"), "unsupported_image")
|
|
||||||
self.assertEqual(canonical_file_type("image/svg+xml"), "unsupported_image")
|
|
||||||
|
|
||||||
def test_prepare_attachment_rejects_oversize_and_unknown_images(self):
|
|
||||||
png = base64.b64encode(b"\x89PNG").decode()
|
|
||||||
decoded, kind, error = prepare_attachment(png, "image/png")
|
|
||||||
self.assertEqual(decoded, b"\x89PNG")
|
|
||||||
self.assertEqual(kind, "png")
|
|
||||||
self.assertIsNone(error)
|
|
||||||
self.assertTrue(is_image_type(kind))
|
|
||||||
|
|
||||||
huge = base64.b64encode(b"x" * (8 * 1024 * 1024 + 1)).decode()
|
|
||||||
_, _, error = prepare_attachment(huge, "image/png")
|
|
||||||
self.assertIn("too large", error)
|
|
||||||
|
|
||||||
_, _, error = prepare_attachment(png, "image/bmp")
|
|
||||||
self.assertIn("Unsupported image", error)
|
|
||||||
|
|
||||||
def test_document_types_unchanged(self):
|
|
||||||
raw = base64.b64encode(b"a,b\n1,2\n").decode()
|
|
||||||
decoded, kind, error = prepare_attachment(raw, "text/csv")
|
|
||||||
self.assertEqual(kind, "csv")
|
|
||||||
self.assertIsNone(error)
|
|
||||||
self.assertEqual(decoded, b"a,b\n1,2\n")
|
|
||||||
self.assertFalse(is_image_type(kind))
|
|
||||||
|
|
||||||
def test_image_base64_reads_stored_png(self):
|
|
||||||
prompt = make_prompt(make_conversation())
|
|
||||||
prompt.file_type = "png"
|
|
||||||
prompt.file.save("shot.png", SimpleUploadedFile("shot.png", b"\x89PNG"))
|
|
||||||
prompt.save()
|
|
||||||
|
|
||||||
self.assertEqual(image_base64(prompt), base64.b64encode(b"\x89PNG").decode())
|
|
||||||
|
|
||||||
def test_image_base64_skips_documents(self):
|
|
||||||
prompt = make_prompt(make_conversation())
|
|
||||||
prompt.file_type = "csv"
|
|
||||||
prompt.file.save("data.csv", SimpleUploadedFile("data.csv", b"a,b"))
|
|
||||||
prompt.save()
|
|
||||||
|
|
||||||
self.assertIsNone(image_base64(prompt))
|
|
||||||
@@ -178,7 +178,7 @@ class PublicWaitlistView(APIView):
|
|||||||
|
|
||||||
|
|
||||||
class CustomUserCreate(APIView):
|
class CustomUserCreate(APIView):
|
||||||
"""Password self-serve registration. Gated by ENABLE_ACCOUNT_REGISTRATION."""
|
"""Self-serve registration. Gated by ENABLE_ACCOUNT_REGISTRATION (default off)."""
|
||||||
|
|
||||||
permission_classes = (permissions.AllowAny,)
|
permission_classes = (permissions.AllowAny,)
|
||||||
authentication_classes = ()
|
authentication_classes = ()
|
||||||
@@ -680,7 +680,7 @@ class ConversationDetailView(APIView):
|
|||||||
prompts = list(
|
prompts = list(
|
||||||
Prompt.objects.filter(
|
Prompt.objects.filter(
|
||||||
conversation__id=conversation_id, conversation__user=request.user
|
conversation__id=conversation_id, conversation__user=request.user
|
||||||
).order_by("created", "id")
|
)
|
||||||
)
|
)
|
||||||
serializer = PromptSerializer(
|
serializer = PromptSerializer(
|
||||||
prompts,
|
prompts,
|
||||||
@@ -734,7 +734,7 @@ class ConversationDetailView(APIView):
|
|||||||
messages = []
|
messages = []
|
||||||
for prompt_obj in Prompt.objects.filter(
|
for prompt_obj in Prompt.objects.filter(
|
||||||
conversation__id=conversation_id
|
conversation__id=conversation_id
|
||||||
).order_by("created", "id"):
|
):
|
||||||
messages.append(
|
messages.append(
|
||||||
{
|
{
|
||||||
"content": prompt_obj.message,
|
"content": prompt_obj.message,
|
||||||
|
|||||||
@@ -102,11 +102,6 @@ class OAuthStartView(APIView):
|
|||||||
},
|
},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
if intent in {"login", "signup"} and not settings.ENABLE_SSO:
|
|
||||||
return Response(
|
|
||||||
{"detail": "Single sign-on is not enabled."},
|
|
||||||
status=status.HTTP_403_FORBIDDEN,
|
|
||||||
)
|
|
||||||
if intent == "signup" and not settings.ENABLE_ACCOUNT_REGISTRATION:
|
if intent == "signup" and not settings.ENABLE_ACCOUNT_REGISTRATION:
|
||||||
return Response(
|
return Response(
|
||||||
{"detail": "Account registration is disabled."},
|
{"detail": "Account registration is disabled."},
|
||||||
@@ -189,8 +184,6 @@ class OAuthCallbackView(APIView):
|
|||||||
return self._handle_drive_link_callback(
|
return self._handle_drive_link_callback(
|
||||||
request, provider=provider, code=code, state_data=state_data
|
request, provider=provider, code=code, state_data=state_data
|
||||||
)
|
)
|
||||||
if not settings.ENABLE_SSO:
|
|
||||||
return _redirect_error("sso_disabled", "Single sign-on is not enabled.")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
redirect_uri = _callback_redirect_uri(request, provider)
|
redirect_uri = _callback_redirect_uri(request, provider)
|
||||||
@@ -268,7 +261,5 @@ class OAuthCallbackView(APIView):
|
|||||||
|
|
||||||
|
|
||||||
def oauth_public_flags() -> dict:
|
def oauth_public_flags() -> dict:
|
||||||
"""Feature flags for /public/settings/. SSO buttons stay hidden until ENABLE_SSO."""
|
"""Feature flags for /public/settings/."""
|
||||||
if not settings.ENABLE_SSO:
|
|
||||||
return {"google": False, "microsoft": False}
|
|
||||||
return configured_providers()
|
return configured_providers()
|
||||||
@@ -169,10 +169,6 @@ OLLAMA_NUM_CTX_UTILITY = int(env("OLLAMA_NUM_CTX_UTILITY", "4096") or "4096")
|
|||||||
# sub-agents run independent plan steps on a smaller/cheaper model.
|
# sub-agents run independent plan steps on a smaller/cheaper model.
|
||||||
OLLAMA_MODEL_ORCHESTRATOR = env("OLLAMA_MODEL_ORCHESTRATOR", "") or OLLAMA_MODEL
|
OLLAMA_MODEL_ORCHESTRATOR = env("OLLAMA_MODEL_ORCHESTRATOR", "") or OLLAMA_MODEL
|
||||||
OLLAMA_MODEL_SUBAGENT = env("OLLAMA_MODEL_SUBAGENT", "") or OLLAMA_MODEL_UTILITY
|
OLLAMA_MODEL_SUBAGENT = env("OLLAMA_MODEL_SUBAGENT", "") or OLLAMA_MODEL_UTILITY
|
||||||
# Vision turns (image + prompt). Must be a vision-capable Ollama tag.
|
|
||||||
# Empty falls back to gemma4:latest inside ollama_model_for_role, not OLLAMA_MODEL.
|
|
||||||
OLLAMA_MODEL_VISION = env("OLLAMA_MODEL_VISION", "") or ""
|
|
||||||
OLLAMA_NUM_CTX_VISION = int(env("OLLAMA_NUM_CTX_VISION", "8192") or "8192")
|
|
||||||
OLLAMA_NUM_CTX_ORCHESTRATOR = int(
|
OLLAMA_NUM_CTX_ORCHESTRATOR = int(
|
||||||
env("OLLAMA_NUM_CTX_ORCHESTRATOR", "16384") or "16384"
|
env("OLLAMA_NUM_CTX_ORCHESTRATOR", "16384") or "16384"
|
||||||
)
|
)
|
||||||
@@ -385,12 +381,9 @@ AGENT_FETCH_URL_MAX_BYTES = int(
|
|||||||
env("AGENT_FETCH_URL_MAX_BYTES", str(2 * 1024 * 1024)) or str(2 * 1024 * 1024)
|
env("AGENT_FETCH_URL_MAX_BYTES", str(2 * 1024 * 1024)) or str(2 * 1024 * 1024)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Password self-serve sign-up. On by default (#80). Set false in the
|
# Self-serve account registration (sign-up page). Default off — enable via
|
||||||
# control-node secret (chat_backend_<env>.env) to close public registration.
|
# control-node secret (chat_backend_<env>.env) when ready for public sign-up.
|
||||||
ENABLE_ACCOUNT_REGISTRATION = env_bool("ENABLE_ACCOUNT_REGISTRATION", True)
|
ENABLE_ACCOUNT_REGISTRATION = env_bool("ENABLE_ACCOUNT_REGISTRATION", False)
|
||||||
# Browser SSO (Google / Microsoft sign-in and sign-up). Off until explicitly
|
|
||||||
# enabled. OAuth client ids may still be set for Drive linking (#47).
|
|
||||||
ENABLE_SSO = env_bool("ENABLE_SSO", False)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# OAuth SSO (Google / Microsoft) — #24
|
# OAuth SSO (Google / Microsoft) — #24
|
||||||
@@ -415,20 +408,8 @@ STRIPE_WEBHOOK_SECRET = env("STRIPE_WEBHOOK_SECRET", "") or ""
|
|||||||
# price_data built from SUBSCRIPTION_PRICE_* below.
|
# price_data built from SUBSCRIPTION_PRICE_* below.
|
||||||
STRIPE_PRICE_ID = env("STRIPE_PRICE_ID", "") or ""
|
STRIPE_PRICE_ID = env("STRIPE_PRICE_ID", "") or ""
|
||||||
|
|
||||||
# Legacy RevenueCat webhook secret. Store grants no longer use that webhook.
|
# RevenueCat webhook Authorization bearer secret + optional product→plan map.
|
||||||
REVENUECAT_WEBHOOK_SECRET = env("REVENUECAT_WEBHOOK_SECRET", "") or ""
|
REVENUECAT_WEBHOOK_SECRET = env("REVENUECAT_WEBHOOK_SECRET", "") or ""
|
||||||
# Native store verification. Apple signed transactions chain to the bundled
|
|
||||||
# Apple Root CA G3 unless APPLE_IAP_ROOT_PEM adds another PEM.
|
|
||||||
APPLE_IAP_BUNDLE_ID = (
|
|
||||||
env("APPLE_IAP_BUNDLE_ID", "ai.hesychia.chat") or "ai.hesychia.chat"
|
|
||||||
)
|
|
||||||
APPLE_IAP_ROOT_PEM = env("APPLE_IAP_ROOT_PEM", "") or ""
|
|
||||||
GOOGLE_PLAY_PACKAGE_NAME = (
|
|
||||||
env("GOOGLE_PLAY_PACKAGE_NAME", "ai.hesychia.chat") or "ai.hesychia.chat"
|
|
||||||
)
|
|
||||||
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON = (
|
|
||||||
env("GOOGLE_PLAY_SERVICE_ACCOUNT_JSON", "") or ""
|
|
||||||
)
|
|
||||||
_rc_product_map_raw = env("REVENUECAT_PRODUCT_PLAN_MAP", "") or ""
|
_rc_product_map_raw = env("REVENUECAT_PRODUCT_PLAN_MAP", "") or ""
|
||||||
REVENUECAT_PRODUCT_PLAN_MAP: dict = {}
|
REVENUECAT_PRODUCT_PLAN_MAP: dict = {}
|
||||||
if _rc_product_map_raw.strip():
|
if _rc_product_map_raw.strip():
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
-----BEGIN CERTIFICATE-----
|
|
||||||
MIICQzCCAcmgAwIBAgIILcX8iNLFS5UwCgYIKoZIzj0EAwMwZzEbMBkGA1UEAwwS
|
|
||||||
QXBwbGUgUm9vdCBDQSAtIEczMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0aW9u
|
|
||||||
IEF1dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwHhcN
|
|
||||||
MTQwNDMwMTgxOTA2WhcNMzkwNDMwMTgxOTA2WjBnMRswGQYDVQQDDBJBcHBsZSBS
|
|
||||||
b290IENBIC0gRzMxJjAkBgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9y
|
|
||||||
aXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzB2MBAGByqGSM49
|
|
||||||
AgEGBSuBBAAiA2IABJjpLz1AcqTtkyJygRMc3RCV8cWjTnHcFBbZDuWmBSp3ZHtf
|
|
||||||
TjjTuxxEtX/1H7YyYl3J6YRbTzBPEVoA/VhYDKX1DyxNB0cTddqXl5dvMVztK517
|
|
||||||
IDvYuVTZXpmkOlEKMaNCMEAwHQYDVR0OBBYEFLuw3qFYM4iapIqZ3r6966/ayySr
|
|
||||||
MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2gA
|
|
||||||
MGUCMQCD6cHEFl4aXTQY2e3v9GwOAEZLuN+yRhHFD/3meoyhpmvOwgPUnPWTxnS4
|
|
||||||
at+qIxUCMG1mihDK1A3UT82NQz60imOlM27jbdoXt2QfyFMm+YhidDkLF1vLUagM
|
|
||||||
6BgD56KyKA==
|
|
||||||
-----END CERTIFICATE-----
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("finance", "0005_revenuecat_fields"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="invoice",
|
|
||||||
name="provider",
|
|
||||||
field=models.CharField(
|
|
||||||
choices=[
|
|
||||||
("stripe", "Stripe"),
|
|
||||||
("revenuecat", "RevenueCat"),
|
|
||||||
("app_store", "App Store"),
|
|
||||||
("play_store", "Play Store"),
|
|
||||||
],
|
|
||||||
default="stripe",
|
|
||||||
max_length=32,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="payment",
|
|
||||||
name="provider",
|
|
||||||
field=models.CharField(
|
|
||||||
choices=[
|
|
||||||
("stripe", "Stripe"),
|
|
||||||
("revenuecat", "RevenueCat"),
|
|
||||||
("app_store", "App Store"),
|
|
||||||
("play_store", "Play Store"),
|
|
||||||
],
|
|
||||||
default="stripe",
|
|
||||||
max_length=32,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="usersubscription",
|
|
||||||
name="source",
|
|
||||||
field=models.CharField(
|
|
||||||
choices=[
|
|
||||||
("none", "None"),
|
|
||||||
("stripe", "Stripe"),
|
|
||||||
("revenuecat", "RevenueCat"),
|
|
||||||
("app_store", "App Store"),
|
|
||||||
("play_store", "Play Store"),
|
|
||||||
("backer", "Backer"),
|
|
||||||
("admin", "Admin"),
|
|
||||||
],
|
|
||||||
default="none",
|
|
||||||
max_length=32,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -134,8 +134,6 @@ class UserSubscription(TimeInfoBase):
|
|||||||
NONE = "none", "None"
|
NONE = "none", "None"
|
||||||
STRIPE = "stripe", "Stripe"
|
STRIPE = "stripe", "Stripe"
|
||||||
REVENUECAT = "revenuecat", "RevenueCat"
|
REVENUECAT = "revenuecat", "RevenueCat"
|
||||||
APP_STORE = "app_store", "App Store"
|
|
||||||
PLAY_STORE = "play_store", "Play Store"
|
|
||||||
BACKER = "backer", "Backer"
|
BACKER = "backer", "Backer"
|
||||||
ADMIN = "admin", "Admin"
|
ADMIN = "admin", "Admin"
|
||||||
|
|
||||||
@@ -216,8 +214,6 @@ class Invoice(TimeInfoBase):
|
|||||||
class Provider(models.TextChoices):
|
class Provider(models.TextChoices):
|
||||||
STRIPE = "stripe", "Stripe"
|
STRIPE = "stripe", "Stripe"
|
||||||
REVENUECAT = "revenuecat", "RevenueCat"
|
REVENUECAT = "revenuecat", "RevenueCat"
|
||||||
APP_STORE = "app_store", "App Store"
|
|
||||||
PLAY_STORE = "play_store", "Play Store"
|
|
||||||
|
|
||||||
class Status(models.TextChoices):
|
class Status(models.TextChoices):
|
||||||
DRAFT = "draft", "Draft"
|
DRAFT = "draft", "Draft"
|
||||||
@@ -312,8 +308,6 @@ class Payment(TimeInfoBase):
|
|||||||
class Provider(models.TextChoices):
|
class Provider(models.TextChoices):
|
||||||
STRIPE = "stripe", "Stripe"
|
STRIPE = "stripe", "Stripe"
|
||||||
REVENUECAT = "revenuecat", "RevenueCat"
|
REVENUECAT = "revenuecat", "RevenueCat"
|
||||||
APP_STORE = "app_store", "App Store"
|
|
||||||
PLAY_STORE = "play_store", "Play Store"
|
|
||||||
|
|
||||||
class Status(models.TextChoices):
|
class Status(models.TextChoices):
|
||||||
PENDING = "pending", "Pending"
|
PENDING = "pending", "Pending"
|
||||||
|
|||||||
@@ -59,23 +59,8 @@ class PortalSessionSerializer(serializers.Serializer):
|
|||||||
return_url = serializers.URLField(required=False, allow_blank=False)
|
return_url = serializers.URLField(required=False, allow_blank=False)
|
||||||
|
|
||||||
|
|
||||||
class StorePurchaseConfirmSerializer(serializers.Serializer):
|
|
||||||
store = serializers.ChoiceField(choices=("app_store", "play_store"))
|
|
||||||
product_id = serializers.CharField(max_length=255)
|
|
||||||
transaction_id = serializers.CharField(
|
|
||||||
max_length=255, required=False, allow_blank=True
|
|
||||||
)
|
|
||||||
purchase_token = serializers.CharField(
|
|
||||||
max_length=4096, required=False, allow_blank=True
|
|
||||||
)
|
|
||||||
signed_transaction = serializers.CharField(required=False, allow_blank=True)
|
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionPlanSerializer(serializers.ModelSerializer):
|
class SubscriptionPlanSerializer(serializers.ModelSerializer):
|
||||||
features = serializers.SerializerMethodField()
|
features = serializers.SerializerMethodField()
|
||||||
store_product_id = serializers.CharField(
|
|
||||||
source="revenuecat_product_id", read_only=True
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = SubscriptionPlan
|
model = SubscriptionPlan
|
||||||
@@ -93,7 +78,6 @@ class SubscriptionPlanSerializer(serializers.ModelSerializer):
|
|||||||
"prompt_window_hours",
|
"prompt_window_hours",
|
||||||
"monthly_token_quota",
|
"monthly_token_quota",
|
||||||
"sort_order",
|
"sort_order",
|
||||||
"store_product_id",
|
|
||||||
]
|
]
|
||||||
read_only_fields = fields
|
read_only_fields = fields
|
||||||
|
|
||||||
|
|||||||
@@ -247,8 +247,6 @@ def needs_checkout(user) -> bool:
|
|||||||
UserSubscription.Source.ADMIN,
|
UserSubscription.Source.ADMIN,
|
||||||
UserSubscription.Source.STRIPE,
|
UserSubscription.Source.STRIPE,
|
||||||
UserSubscription.Source.REVENUECAT,
|
UserSubscription.Source.REVENUECAT,
|
||||||
UserSubscription.Source.APP_STORE,
|
|
||||||
UserSubscription.Source.PLAY_STORE,
|
|
||||||
):
|
):
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
@@ -459,9 +457,8 @@ def assign_plan_from_revenuecat(
|
|||||||
cancel_at_period_end: bool | None = None,
|
cancel_at_period_end: bool | None = None,
|
||||||
current_period_end=None,
|
current_period_end=None,
|
||||||
keep_existing_plan_if_unknown: bool = False,
|
keep_existing_plan_if_unknown: bool = False,
|
||||||
source: str | None = None,
|
|
||||||
) -> UserSubscription:
|
) -> UserSubscription:
|
||||||
"""Assign a catalog plan from a verified store purchase."""
|
"""Assign a catalog plan from a RevenueCat store purchase event."""
|
||||||
seed_subscription_plans(update_existing=False)
|
seed_subscription_plans(update_existing=False)
|
||||||
existing = (
|
existing = (
|
||||||
UserSubscription.objects.filter(user=user).select_related("plan").first()
|
UserSubscription.objects.filter(user=user).select_related("plan").first()
|
||||||
@@ -496,7 +493,7 @@ def assign_plan_from_revenuecat(
|
|||||||
sub = assign_plan(
|
sub = assign_plan(
|
||||||
user,
|
user,
|
||||||
plan=plan,
|
plan=plan,
|
||||||
source=source or UserSubscription.Source.REVENUECAT,
|
source=UserSubscription.Source.REVENUECAT,
|
||||||
status=status,
|
status=status,
|
||||||
revenuecat_original_transaction_id=revenuecat_original_transaction_id or "",
|
revenuecat_original_transaction_id=revenuecat_original_transaction_id or "",
|
||||||
log_auth_event=False,
|
log_auth_event=False,
|
||||||
|
|||||||
@@ -1,617 +0,0 @@
|
|||||||
"""Verify App Store and Play Store purchases and grant entitlements.
|
|
||||||
|
|
||||||
The mobile app completes the purchase with StoreKit or Play Billing, then
|
|
||||||
POSTs the signed transaction or purchase token. This module checks that
|
|
||||||
payload with Apple or Google and writes the subscription ledger. Store
|
|
||||||
webhooks are not accepted.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
import uuid
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timezone as dt_timezone
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
import jwt
|
|
||||||
import requests
|
|
||||||
from cryptography import x509
|
|
||||||
from cryptography.hazmat.primitives import hashes
|
|
||||||
from cryptography.hazmat.primitives.asymmetric import ec, padding
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import transaction
|
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
from monetization.models import Invoice, Payment, UserSubscription
|
|
||||||
from monetization.services.plans import (
|
|
||||||
assign_plan_from_revenuecat,
|
|
||||||
log_subscription_auth_event,
|
|
||||||
resolve_plan_from_revenuecat_product,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# uuid5(NAMESPACE_URL, this name). Do not change; clients receive the result.
|
|
||||||
_STORE_ACCOUNT_NAMESPACE = uuid.uuid5(
|
|
||||||
uuid.NAMESPACE_URL,
|
|
||||||
"https://chat.aimloperations.com/store-account",
|
|
||||||
)
|
|
||||||
_APP_STORE_CERT_OID = x509.ObjectIdentifier("1.2.840.113635.100.6.11.1")
|
|
||||||
_PLAY_SCOPE = "https://www.googleapis.com/auth/androidpublisher"
|
|
||||||
_BUNDLED_APPLE_ROOT = (
|
|
||||||
Path(__file__).resolve().parent.parent / "certs" / "apple_root_g3.pem"
|
|
||||||
)
|
|
||||||
|
|
||||||
_STORE_SOURCES = frozenset(
|
|
||||||
{
|
|
||||||
UserSubscription.Source.APP_STORE,
|
|
||||||
UserSubscription.Source.PLAY_STORE,
|
|
||||||
UserSubscription.Source.REVENUECAT,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class StoreNotConfiguredError(Exception):
|
|
||||||
"""Apple or Google credentials required to verify a purchase are missing."""
|
|
||||||
|
|
||||||
|
|
||||||
class StorePurchaseRejected(Exception):
|
|
||||||
"""The store payload is not an active purchase for this user."""
|
|
||||||
|
|
||||||
|
|
||||||
class StoreVerificationError(Exception):
|
|
||||||
"""The store API could not be reached."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class VerifiedStorePurchase:
|
|
||||||
source: str
|
|
||||||
store_label: str
|
|
||||||
product_id: str
|
|
||||||
transaction_id: str
|
|
||||||
original_transaction_id: str
|
|
||||||
expires_at: datetime | None
|
|
||||||
purchased_at: datetime | None
|
|
||||||
cancel_at_period_end: bool
|
|
||||||
status: str
|
|
||||||
price_cents: int
|
|
||||||
currency: str
|
|
||||||
account_token: str
|
|
||||||
|
|
||||||
|
|
||||||
def store_account_token(user_id: int) -> str:
|
|
||||||
"""Stable store account id for this user. Pass it to StoreKit and Play."""
|
|
||||||
return str(uuid.uuid5(_STORE_ACCOUNT_NAMESPACE, str(user_id)))
|
|
||||||
|
|
||||||
|
|
||||||
def expire_lapsed_store_subscription(sub: UserSubscription | None):
|
|
||||||
"""End store access once the verified period has passed.
|
|
||||||
|
|
||||||
Renewals arrive when the app confirms the store entitlement again.
|
|
||||||
"""
|
|
||||||
if sub is None or sub.source not in _STORE_SOURCES:
|
|
||||||
return sub
|
|
||||||
if sub.status != UserSubscription.Status.ACTIVE or not sub.current_period_end:
|
|
||||||
return sub
|
|
||||||
if sub.current_period_end > timezone.now():
|
|
||||||
return sub
|
|
||||||
prev_status = sub.status
|
|
||||||
sub.status = UserSubscription.Status.CANCELED
|
|
||||||
sub.cancel_at_period_end = False
|
|
||||||
sub.save(update_fields=["status", "cancel_at_period_end", "last_modified"])
|
|
||||||
if prev_status != UserSubscription.Status.CANCELED:
|
|
||||||
log_subscription_auth_event(
|
|
||||||
sub.user,
|
|
||||||
started=False,
|
|
||||||
detail=(
|
|
||||||
f"plan={sub.plan.slug if sub.plan_id else 'none'} "
|
|
||||||
f"source={sub.source} status={sub.status} "
|
|
||||||
"reason=store_period_ended"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return sub
|
|
||||||
|
|
||||||
|
|
||||||
def confirm_store_purchase(user, payload: dict[str, Any]) -> UserSubscription:
|
|
||||||
"""Verify one store purchase for the authenticated user and grant it."""
|
|
||||||
store = (payload.get("store") or "").strip().lower()
|
|
||||||
product_id = (payload.get("product_id") or "").strip()
|
|
||||||
if store == UserSubscription.Source.APP_STORE:
|
|
||||||
verified = verify_apple_signed_transaction(
|
|
||||||
signed_transaction=(payload.get("signed_transaction") or "").strip(),
|
|
||||||
product_id=product_id,
|
|
||||||
transaction_id=(payload.get("transaction_id") or "").strip(),
|
|
||||||
)
|
|
||||||
elif store == UserSubscription.Source.PLAY_STORE:
|
|
||||||
verified = verify_google_subscription(
|
|
||||||
product_id=product_id,
|
|
||||||
purchase_token=(payload.get("purchase_token") or "").strip(),
|
|
||||||
transaction_id=(payload.get("transaction_id") or "").strip(),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
raise StorePurchaseRejected("store must be app_store or play_store.")
|
|
||||||
if verified.product_id != product_id:
|
|
||||||
raise StorePurchaseRejected("Store product does not match this purchase.")
|
|
||||||
return grant_verified_store_purchase(user, verified)
|
|
||||||
|
|
||||||
|
|
||||||
def verify_apple_signed_transaction(
|
|
||||||
*,
|
|
||||||
signed_transaction: str,
|
|
||||||
product_id: str,
|
|
||||||
transaction_id: str = "",
|
|
||||||
) -> VerifiedStorePurchase:
|
|
||||||
"""Verify a StoreKit 2 signed transaction against Apple's root."""
|
|
||||||
if not signed_transaction:
|
|
||||||
raise StorePurchaseRejected("App Store purchases require signed_transaction.")
|
|
||||||
bundle_id = (getattr(settings, "APPLE_IAP_BUNDLE_ID", "") or "").strip()
|
|
||||||
if not bundle_id:
|
|
||||||
raise StoreNotConfiguredError("APPLE_IAP_BUNDLE_ID is not configured.")
|
|
||||||
roots = _load_apple_roots()
|
|
||||||
if not roots:
|
|
||||||
raise StoreNotConfiguredError("No Apple root certificate is configured.")
|
|
||||||
|
|
||||||
try:
|
|
||||||
header = jwt.get_unverified_header(signed_transaction)
|
|
||||||
except jwt.PyJWTError as exc:
|
|
||||||
raise StorePurchaseRejected("Invalid App Store signed transaction.") from exc
|
|
||||||
x5c = header.get("x5c") if isinstance(header, dict) else None
|
|
||||||
if not isinstance(x5c, list) or not x5c:
|
|
||||||
raise StorePurchaseRejected("App Store transaction is missing its certificate.")
|
|
||||||
try:
|
|
||||||
certs = [_load_der_cert(item) for item in x5c]
|
|
||||||
leaf = _trusted_leaf(certs, roots)
|
|
||||||
except StorePurchaseRejected:
|
|
||||||
raise
|
|
||||||
except Exception as exc:
|
|
||||||
raise StorePurchaseRejected("App Store certificate chain is not trusted.") from exc
|
|
||||||
_require_app_store_oid(leaf)
|
|
||||||
|
|
||||||
try:
|
|
||||||
claims = jwt.decode(
|
|
||||||
signed_transaction,
|
|
||||||
leaf.public_key(),
|
|
||||||
algorithms=["ES256"],
|
|
||||||
options={"verify_aud": False, "verify_exp": False},
|
|
||||||
)
|
|
||||||
except jwt.PyJWTError as exc:
|
|
||||||
raise StorePurchaseRejected("App Store transaction signature is invalid.") from exc
|
|
||||||
if not isinstance(claims, dict):
|
|
||||||
raise StorePurchaseRejected("App Store transaction payload is invalid.")
|
|
||||||
|
|
||||||
claim_bundle = str(claims.get("bundleId") or "")
|
|
||||||
if claim_bundle != bundle_id:
|
|
||||||
raise StorePurchaseRejected("App Store bundle id does not match this app.")
|
|
||||||
claim_product = str(claims.get("productId") or "")
|
|
||||||
if product_id and claim_product != product_id:
|
|
||||||
raise StorePurchaseRejected("App Store product does not match this purchase.")
|
|
||||||
claim_txn = str(claims.get("transactionId") or "")
|
|
||||||
if transaction_id and claim_txn and transaction_id != claim_txn:
|
|
||||||
raise StorePurchaseRejected("App Store transaction id does not match.")
|
|
||||||
if not claim_txn:
|
|
||||||
raise StorePurchaseRejected("App Store transaction id is missing.")
|
|
||||||
|
|
||||||
expires_at = _ms_to_dt(claims.get("expiresDate"))
|
|
||||||
purchased_at = _ms_to_dt(claims.get("purchaseDate"))
|
|
||||||
revoked = _ms_to_dt(claims.get("revocationDate")) is not None
|
|
||||||
now = timezone.now()
|
|
||||||
if revoked or (expires_at is not None and expires_at <= now):
|
|
||||||
status = UserSubscription.Status.CANCELED
|
|
||||||
else:
|
|
||||||
status = UserSubscription.Status.ACTIVE
|
|
||||||
|
|
||||||
price_cents = 0
|
|
||||||
raw_price = claims.get("price")
|
|
||||||
try:
|
|
||||||
if raw_price is not None:
|
|
||||||
price_cents = max(0, int(raw_price) // 10)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
price_cents = 0
|
|
||||||
currency = str(claims.get("currency") or "usd").lower()
|
|
||||||
original = str(claims.get("originalTransactionId") or claim_txn)
|
|
||||||
return VerifiedStorePurchase(
|
|
||||||
source=UserSubscription.Source.APP_STORE,
|
|
||||||
store_label="APP_STORE",
|
|
||||||
product_id=claim_product,
|
|
||||||
transaction_id=claim_txn,
|
|
||||||
original_transaction_id=original,
|
|
||||||
expires_at=expires_at,
|
|
||||||
purchased_at=purchased_at,
|
|
||||||
cancel_at_period_end=False,
|
|
||||||
status=status,
|
|
||||||
price_cents=price_cents,
|
|
||||||
currency=currency,
|
|
||||||
account_token=str(claims.get("appAccountToken") or ""),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def verify_google_subscription(
|
|
||||||
*,
|
|
||||||
product_id: str,
|
|
||||||
purchase_token: str,
|
|
||||||
transaction_id: str = "",
|
|
||||||
) -> VerifiedStorePurchase:
|
|
||||||
"""Verify a Play Billing purchase token with the Android Publisher API."""
|
|
||||||
if not purchase_token:
|
|
||||||
raise StorePurchaseRejected("Play Store purchases require purchase_token.")
|
|
||||||
if not product_id:
|
|
||||||
raise StorePurchaseRejected("Play Store purchases require product_id.")
|
|
||||||
body = fetch_google_subscription(purchase_token)
|
|
||||||
return _verified_from_google(
|
|
||||||
body,
|
|
||||||
product_id=product_id,
|
|
||||||
purchase_token=purchase_token,
|
|
||||||
transaction_id=transaction_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_google_subscription(purchase_token: str) -> dict[str, Any]:
|
|
||||||
"""GET purchases.subscriptionsv2 for this token. Tests patch this."""
|
|
||||||
package_name = (getattr(settings, "GOOGLE_PLAY_PACKAGE_NAME", "") or "").strip()
|
|
||||||
raw = (getattr(settings, "GOOGLE_PLAY_SERVICE_ACCOUNT_JSON", "") or "").strip()
|
|
||||||
if not package_name or not raw:
|
|
||||||
raise StoreNotConfiguredError(
|
|
||||||
"Google Play verification is not configured "
|
|
||||||
"(GOOGLE_PLAY_PACKAGE_NAME and GOOGLE_PLAY_SERVICE_ACCOUNT_JSON)."
|
|
||||||
)
|
|
||||||
info = _load_service_account_info(raw)
|
|
||||||
try:
|
|
||||||
access_token = _google_access_token(info)
|
|
||||||
url = (
|
|
||||||
"https://androidpublisher.googleapis.com/androidpublisher/v3/"
|
|
||||||
f"applications/{quote(package_name, safe='')}/purchases/"
|
|
||||||
f"subscriptionsv2/tokens/{quote(purchase_token, safe='')}"
|
|
||||||
)
|
|
||||||
response = requests.get(
|
|
||||||
url,
|
|
||||||
headers={"Authorization": f"Bearer {access_token}"},
|
|
||||||
timeout=15,
|
|
||||||
)
|
|
||||||
except (requests.RequestException, ValueError, OSError) as exc:
|
|
||||||
logger.exception("Play Store verification request failed")
|
|
||||||
raise StoreVerificationError("Play Store verification failed.") from exc
|
|
||||||
if response.status_code == 404:
|
|
||||||
raise StorePurchaseRejected("Play Store purchase was not found.")
|
|
||||||
if response.status_code >= 400:
|
|
||||||
logger.error(
|
|
||||||
"Play Store verification HTTP %s",
|
|
||||||
response.status_code,
|
|
||||||
)
|
|
||||||
if response.status_code >= 500:
|
|
||||||
raise StoreVerificationError("Play Store verification failed.")
|
|
||||||
raise StorePurchaseRejected("Play Store rejected this purchase token.")
|
|
||||||
try:
|
|
||||||
body = response.json()
|
|
||||||
except ValueError as exc:
|
|
||||||
raise StoreVerificationError("Play Store returned an invalid response.") from exc
|
|
||||||
if not isinstance(body, dict):
|
|
||||||
raise StoreVerificationError("Play Store returned an invalid response.")
|
|
||||||
return body
|
|
||||||
|
|
||||||
|
|
||||||
def _verified_from_google(
|
|
||||||
body: dict[str, Any],
|
|
||||||
*,
|
|
||||||
product_id: str,
|
|
||||||
purchase_token: str,
|
|
||||||
transaction_id: str,
|
|
||||||
) -> VerifiedStorePurchase:
|
|
||||||
line_items = body.get("lineItems") or []
|
|
||||||
match = None
|
|
||||||
if isinstance(line_items, list):
|
|
||||||
for item in line_items:
|
|
||||||
if isinstance(item, dict) and str(item.get("productId") or "") == product_id:
|
|
||||||
match = item
|
|
||||||
break
|
|
||||||
if match is None:
|
|
||||||
raise StorePurchaseRejected("Play Store product does not match this purchase.")
|
|
||||||
|
|
||||||
state = str(body.get("subscriptionState") or "")
|
|
||||||
expires_at = _parse_rfc3339(match.get("expiryTime"))
|
|
||||||
now = timezone.now()
|
|
||||||
expired = expires_at is not None and expires_at <= now
|
|
||||||
cancel_at_period_end = False
|
|
||||||
if state in {"SUBSCRIPTION_STATE_PENDING", "SUBSCRIPTION_STATE_PENDING_PURCHASE_CANCELED"}:
|
|
||||||
raise StorePurchaseRejected("Play Store purchase is still pending.")
|
|
||||||
if state in {"SUBSCRIPTION_STATE_ACTIVE", "SUBSCRIPTION_STATE_IN_GRACE_PERIOD"} and not expired:
|
|
||||||
status = UserSubscription.Status.ACTIVE
|
|
||||||
elif state == "SUBSCRIPTION_STATE_CANCELED" and not expired:
|
|
||||||
status = UserSubscription.Status.ACTIVE
|
|
||||||
cancel_at_period_end = True
|
|
||||||
elif state == "SUBSCRIPTION_STATE_ON_HOLD":
|
|
||||||
status = UserSubscription.Status.PAST_DUE
|
|
||||||
else:
|
|
||||||
status = UserSubscription.Status.CANCELED
|
|
||||||
|
|
||||||
auto_renew = (match.get("autoRenewingPlan") or {}) if isinstance(match, dict) else {}
|
|
||||||
if isinstance(auto_renew, dict) and auto_renew.get("autoRenewEnabled") is False and not expired:
|
|
||||||
if status == UserSubscription.Status.ACTIVE:
|
|
||||||
cancel_at_period_end = True
|
|
||||||
|
|
||||||
order_id = str(match.get("latestSuccessfulOrderId") or transaction_id or purchase_token)
|
|
||||||
identifiers = body.get("externalAccountIdentifiers") or {}
|
|
||||||
account_token = ""
|
|
||||||
if isinstance(identifiers, dict):
|
|
||||||
account_token = str(identifiers.get("obfuscatedExternalAccountId") or "")
|
|
||||||
price_cents, currency = _google_price(auto_renew if isinstance(auto_renew, dict) else {})
|
|
||||||
return VerifiedStorePurchase(
|
|
||||||
source=UserSubscription.Source.PLAY_STORE,
|
|
||||||
store_label="PLAY_STORE",
|
|
||||||
product_id=product_id,
|
|
||||||
transaction_id=order_id,
|
|
||||||
original_transaction_id=purchase_token,
|
|
||||||
expires_at=expires_at,
|
|
||||||
purchased_at=None,
|
|
||||||
cancel_at_period_end=cancel_at_period_end,
|
|
||||||
status=status,
|
|
||||||
price_cents=price_cents,
|
|
||||||
currency=currency,
|
|
||||||
account_token=account_token,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@transaction.atomic
|
|
||||||
def grant_verified_store_purchase(user, verified: VerifiedStorePurchase) -> UserSubscription:
|
|
||||||
plan = resolve_plan_from_revenuecat_product(verified.product_id)
|
|
||||||
if plan is None:
|
|
||||||
raise StorePurchaseRejected("This store product is not mapped to a plan.")
|
|
||||||
|
|
||||||
expected = store_account_token(user.pk)
|
|
||||||
if verified.account_token and verified.account_token.lower() != expected.lower():
|
|
||||||
raise StorePurchaseRejected("Purchase account token does not match this user.")
|
|
||||||
|
|
||||||
original = verified.original_transaction_id
|
|
||||||
if original:
|
|
||||||
taken = (
|
|
||||||
UserSubscription.objects.select_for_update()
|
|
||||||
.filter(revenuecat_original_transaction_id=original)
|
|
||||||
.exclude(user=user)
|
|
||||||
.exists()
|
|
||||||
)
|
|
||||||
if taken:
|
|
||||||
raise StorePurchaseRejected(
|
|
||||||
"This purchase is already linked to another account."
|
|
||||||
)
|
|
||||||
|
|
||||||
sub = assign_plan_from_revenuecat(
|
|
||||||
user,
|
|
||||||
plan_slug=plan.slug,
|
|
||||||
product_id=verified.product_id,
|
|
||||||
revenuecat_original_transaction_id=original,
|
|
||||||
status=verified.status,
|
|
||||||
cancel_at_period_end=verified.cancel_at_period_end,
|
|
||||||
current_period_end=verified.expires_at,
|
|
||||||
keep_existing_plan_if_unknown=False,
|
|
||||||
source=verified.source,
|
|
||||||
)
|
|
||||||
|
|
||||||
if verified.status != UserSubscription.Status.ACTIVE:
|
|
||||||
return sub
|
|
||||||
|
|
||||||
amount = verified.price_cents or plan.price_cents
|
|
||||||
currency = (verified.currency or plan.currency or "usd").lower()
|
|
||||||
event_id = f"{verified.source}:{verified.transaction_id}"
|
|
||||||
invoice, _created = Invoice.objects.update_or_create(
|
|
||||||
revenuecat_event_id=event_id,
|
|
||||||
defaults={
|
|
||||||
"user": user,
|
|
||||||
"company": getattr(user, "company", None),
|
|
||||||
"provider": verified.source,
|
|
||||||
"status": Invoice.Status.PAID,
|
|
||||||
"currency": currency,
|
|
||||||
"amount_due": amount,
|
|
||||||
"amount_paid": amount,
|
|
||||||
"period_start": verified.purchased_at,
|
|
||||||
"period_end": verified.expires_at,
|
|
||||||
"revenuecat_store": verified.store_label,
|
|
||||||
"description": f"{plan.name} — {verified.store_label}",
|
|
||||||
"hosted_invoice_url": "",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
Payment.objects.update_or_create(
|
|
||||||
revenuecat_transaction_id=verified.transaction_id,
|
|
||||||
defaults={
|
|
||||||
"user": user,
|
|
||||||
"company": getattr(user, "company", None),
|
|
||||||
"invoice": invoice,
|
|
||||||
"provider": verified.source,
|
|
||||||
"status": Payment.Status.SUCCEEDED,
|
|
||||||
"currency": currency,
|
|
||||||
"amount": amount,
|
|
||||||
"paid_at": verified.purchased_at or timezone.now(),
|
|
||||||
"failure_message": "",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return sub
|
|
||||||
|
|
||||||
|
|
||||||
def _load_apple_roots() -> list[x509.Certificate]:
|
|
||||||
pems: list[str] = []
|
|
||||||
extra = (getattr(settings, "APPLE_IAP_ROOT_PEM", "") or "").strip()
|
|
||||||
if extra:
|
|
||||||
pems.append(extra)
|
|
||||||
if _BUNDLED_APPLE_ROOT.is_file():
|
|
||||||
pems.append(_BUNDLED_APPLE_ROOT.read_text(encoding="utf-8"))
|
|
||||||
roots: list[x509.Certificate] = []
|
|
||||||
for pem in pems:
|
|
||||||
try:
|
|
||||||
roots.append(x509.load_pem_x509_certificate(pem.encode("utf-8")))
|
|
||||||
except ValueError:
|
|
||||||
logger.error("Ignoring an unreadable Apple root certificate")
|
|
||||||
return roots
|
|
||||||
|
|
||||||
|
|
||||||
def _load_der_cert(value: Any) -> x509.Certificate:
|
|
||||||
if not isinstance(value, str) or not value.strip():
|
|
||||||
raise StorePurchaseRejected("App Store certificate is missing.")
|
|
||||||
try:
|
|
||||||
der = base64.b64decode(value)
|
|
||||||
return x509.load_der_x509_certificate(der)
|
|
||||||
except (ValueError, TypeError) as exc:
|
|
||||||
raise StorePurchaseRejected("App Store certificate is invalid.") from exc
|
|
||||||
|
|
||||||
|
|
||||||
def _trusted_leaf(
|
|
||||||
certs: list[x509.Certificate],
|
|
||||||
roots: list[x509.Certificate],
|
|
||||||
) -> x509.Certificate:
|
|
||||||
if not certs:
|
|
||||||
raise StorePurchaseRejected("App Store certificate chain is empty.")
|
|
||||||
now = datetime.now(dt_timezone.utc)
|
|
||||||
for cert in certs:
|
|
||||||
if cert.not_valid_before_utc > now or cert.not_valid_after_utc < now:
|
|
||||||
raise StorePurchaseRejected("App Store certificate is not currently valid.")
|
|
||||||
for index in range(len(certs) - 1):
|
|
||||||
_verify_signed_by(certs[index], certs[index + 1])
|
|
||||||
if certs[index].issuer != certs[index + 1].subject:
|
|
||||||
raise StorePurchaseRejected("App Store certificate issuer mismatch.")
|
|
||||||
last = certs[-1]
|
|
||||||
root_fps = {root.fingerprint(hashes.SHA256()) for root in roots}
|
|
||||||
if last.fingerprint(hashes.SHA256()) in root_fps:
|
|
||||||
return certs[0]
|
|
||||||
for root in roots:
|
|
||||||
try:
|
|
||||||
_verify_signed_by(last, root)
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
if last.issuer == root.subject:
|
|
||||||
return certs[0]
|
|
||||||
raise StorePurchaseRejected("App Store certificate chain is not trusted.")
|
|
||||||
|
|
||||||
|
|
||||||
def _verify_signed_by(child: x509.Certificate, issuer: x509.Certificate) -> None:
|
|
||||||
public_key = issuer.public_key()
|
|
||||||
signature_hash = child.signature_hash_algorithm
|
|
||||||
if signature_hash is None:
|
|
||||||
raise StorePurchaseRejected("App Store certificate has no signature hash.")
|
|
||||||
if isinstance(public_key, ec.EllipticCurvePublicKey):
|
|
||||||
public_key.verify(
|
|
||||||
child.signature,
|
|
||||||
child.tbs_certificate_bytes,
|
|
||||||
ec.ECDSA(signature_hash),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
public_key.verify(
|
|
||||||
child.signature,
|
|
||||||
child.tbs_certificate_bytes,
|
|
||||||
padding.PKCS1v15(),
|
|
||||||
signature_hash,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _require_app_store_oid(leaf: x509.Certificate) -> None:
|
|
||||||
try:
|
|
||||||
leaf.extensions.get_extension_for_oid(_APP_STORE_CERT_OID)
|
|
||||||
except x509.ExtensionNotFound as exc:
|
|
||||||
raise StorePurchaseRejected(
|
|
||||||
"Signed transaction certificate is not an App Store certificate."
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
|
|
||||||
def _google_access_token(info: dict[str, Any]) -> str:
|
|
||||||
"""OAuth JWT bearer grant for a Play Console service account."""
|
|
||||||
client_email = info.get("client_email")
|
|
||||||
private_key = info.get("private_key")
|
|
||||||
token_uri = info.get("token_uri") or "https://oauth2.googleapis.com/token"
|
|
||||||
if not client_email or not private_key:
|
|
||||||
raise StoreNotConfiguredError(
|
|
||||||
"GOOGLE_PLAY_SERVICE_ACCOUNT_JSON is missing client_email or private_key."
|
|
||||||
)
|
|
||||||
now = int(time.time())
|
|
||||||
assertion = jwt.encode(
|
|
||||||
{
|
|
||||||
"iss": client_email,
|
|
||||||
"scope": _PLAY_SCOPE,
|
|
||||||
"aud": token_uri,
|
|
||||||
"iat": now,
|
|
||||||
"exp": now + 3600,
|
|
||||||
},
|
|
||||||
private_key,
|
|
||||||
algorithm="RS256",
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
response = requests.post(
|
|
||||||
token_uri,
|
|
||||||
data={
|
|
||||||
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
||||||
"assertion": assertion,
|
|
||||||
},
|
|
||||||
timeout=15,
|
|
||||||
)
|
|
||||||
except requests.RequestException as exc:
|
|
||||||
raise StoreVerificationError("Play Store verification failed.") from exc
|
|
||||||
if response.status_code >= 400:
|
|
||||||
raise StoreVerificationError("Play Store authentication failed.")
|
|
||||||
try:
|
|
||||||
token = response.json().get("access_token")
|
|
||||||
except ValueError as exc:
|
|
||||||
raise StoreVerificationError("Play Store authentication failed.") from exc
|
|
||||||
if not token:
|
|
||||||
raise StoreVerificationError("Play Store authentication failed.")
|
|
||||||
return str(token)
|
|
||||||
|
|
||||||
|
|
||||||
def _load_service_account_info(raw: str) -> dict[str, Any]:
|
|
||||||
text = raw
|
|
||||||
if not raw.lstrip().startswith("{"):
|
|
||||||
path = Path(raw)
|
|
||||||
if path.is_file():
|
|
||||||
text = path.read_text(encoding="utf-8")
|
|
||||||
try:
|
|
||||||
info = json.loads(text)
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
raise StoreNotConfiguredError(
|
|
||||||
"GOOGLE_PLAY_SERVICE_ACCOUNT_JSON is not valid JSON."
|
|
||||||
) from exc
|
|
||||||
if not isinstance(info, dict):
|
|
||||||
raise StoreNotConfiguredError(
|
|
||||||
"GOOGLE_PLAY_SERVICE_ACCOUNT_JSON must be a JSON object."
|
|
||||||
)
|
|
||||||
return info
|
|
||||||
|
|
||||||
|
|
||||||
def _ms_to_dt(value: Any) -> datetime | None:
|
|
||||||
if value is None or value == "":
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
ms = int(value)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
if ms <= 0:
|
|
||||||
return None
|
|
||||||
return datetime.fromtimestamp(ms / 1000.0, tz=dt_timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_rfc3339(value: Any) -> datetime | None:
|
|
||||||
if not value or not isinstance(value, str):
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
if parsed.tzinfo is None:
|
|
||||||
return parsed.replace(tzinfo=dt_timezone.utc)
|
|
||||||
return parsed
|
|
||||||
|
|
||||||
|
|
||||||
def _google_price(auto_renew: dict[str, Any]) -> tuple[int, str]:
|
|
||||||
price = auto_renew.get("recurringPrice") if isinstance(auto_renew, dict) else None
|
|
||||||
if not isinstance(price, dict):
|
|
||||||
return 0, "usd"
|
|
||||||
currency = str(price.get("currencyCode") or "usd").lower()
|
|
||||||
try:
|
|
||||||
units = int(price.get("units") or 0)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
units = 0
|
|
||||||
try:
|
|
||||||
nanos = int(price.get("nanos") or 0)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
nanos = 0
|
|
||||||
cents = units * 100 + nanos // 10_000_000
|
|
||||||
return max(0, cents), currency
|
|
||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase, override_settings
|
||||||
|
from django.urls import reverse
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from chat_backend.tests.factories import make_user
|
from chat_backend.tests.factories import make_user
|
||||||
from monetization.models import Invoice, Payment, SubscriptionPlan, UserSubscription
|
from monetization.models import Invoice, Payment, SubscriptionPlan, UserSubscription
|
||||||
@@ -126,3 +129,50 @@ class RevenueCatDispatchTests(TestCase):
|
|||||||
self.assertEqual(sub.status, UserSubscription.Status.CANCELED)
|
self.assertEqual(sub.status, UserSubscription.Status.CANCELED)
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(REVENUECAT_WEBHOOK_SECRET="test-rc-secret")
|
||||||
|
class RevenueCatWebhookViewTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
seed_subscription_plans(update_existing=False)
|
||||||
|
self.client = APIClient()
|
||||||
|
self.url = reverse("finance_revenuecat_webhook")
|
||||||
|
self.user = make_user(email="rcview@example.com")
|
||||||
|
founders = SubscriptionPlan.objects.get(slug="founders")
|
||||||
|
founders.revenuecat_product_id = "hesychia_founders_monthly"
|
||||||
|
founders.save(update_fields=["revenuecat_product_id", "last_modified"])
|
||||||
|
|
||||||
|
def test_missing_secret_config(self):
|
||||||
|
with override_settings(REVENUECAT_WEBHOOK_SECRET=""):
|
||||||
|
response = self.client.post(
|
||||||
|
self.url, {"event": _rc_event()}, format="json"
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE)
|
||||||
|
|
||||||
|
def test_unauthorized(self):
|
||||||
|
response = self.client.post(
|
||||||
|
self.url,
|
||||||
|
{"event": _rc_event(app_user_id=str(self.user.pk))},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION="Bearer wrong",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_success(self):
|
||||||
|
response = self.client.post(
|
||||||
|
self.url,
|
||||||
|
{"api_version": "1.0", "event": _rc_event(app_user_id=str(self.user.pk))},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION="Bearer test-rc-secret",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertTrue(
|
||||||
|
UserSubscription.objects.filter(
|
||||||
|
user=self.user,
|
||||||
|
source=UserSubscription.Source.REVENUECAT,
|
||||||
|
status=UserSubscription.Status.ACTIVE,
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
Invoice.objects.filter(
|
||||||
|
user=self.user, provider=Invoice.Provider.REVENUECAT
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
@@ -1,336 +0,0 @@
|
|||||||
"""StoreKit and Play Billing confirmation. No store webhooks."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
from datetime import datetime, timedelta, timezone as dt_timezone
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import jwt
|
|
||||||
from cryptography import x509
|
|
||||||
from cryptography.hazmat.primitives import hashes, serialization
|
|
||||||
from cryptography.hazmat.primitives.asymmetric import ec
|
|
||||||
from cryptography.x509.oid import NameOID
|
|
||||||
from django.test import TestCase, override_settings
|
|
||||||
from django.urls import reverse
|
|
||||||
from django.utils import timezone
|
|
||||||
from rest_framework import status
|
|
||||||
from rest_framework.test import APITestCase
|
|
||||||
|
|
||||||
from chat_backend.tests.factories import make_user
|
|
||||||
from monetization.models import Invoice, Payment, SubscriptionPlan, UserSubscription
|
|
||||||
from monetization.services.plans import seed_subscription_plans
|
|
||||||
from monetization.services.store_purchases import (
|
|
||||||
StoreNotConfiguredError,
|
|
||||||
StorePurchaseRejected,
|
|
||||||
confirm_store_purchase,
|
|
||||||
store_account_token,
|
|
||||||
)
|
|
||||||
|
|
||||||
_APP_STORE_OID = x509.ObjectIdentifier("1.2.840.113635.100.6.11.1")
|
|
||||||
_PRODUCT = "hesychia_founders_monthly"
|
|
||||||
|
|
||||||
|
|
||||||
def _cert(*, subject_cn, subject_key, issuer_cert, issuer_key, is_ca, app_store=False):
|
|
||||||
now = datetime.now(dt_timezone.utc)
|
|
||||||
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, subject_cn)])
|
|
||||||
issuer_name = issuer_cert.subject if issuer_cert is not None else name
|
|
||||||
builder = (
|
|
||||||
x509.CertificateBuilder()
|
|
||||||
.subject_name(name)
|
|
||||||
.issuer_name(issuer_name)
|
|
||||||
.public_key(subject_key.public_key())
|
|
||||||
.serial_number(x509.random_serial_number())
|
|
||||||
.not_valid_before(now - timedelta(days=1))
|
|
||||||
.not_valid_after(now + timedelta(days=30))
|
|
||||||
)
|
|
||||||
if is_ca:
|
|
||||||
builder = builder.add_extension(
|
|
||||||
x509.BasicConstraints(ca=True, path_length=None),
|
|
||||||
critical=True,
|
|
||||||
)
|
|
||||||
if app_store:
|
|
||||||
builder = builder.add_extension(
|
|
||||||
x509.UnrecognizedExtension(_APP_STORE_OID, b"\x05\x00"),
|
|
||||||
critical=False,
|
|
||||||
)
|
|
||||||
signer = issuer_key if issuer_key is not None else subject_key
|
|
||||||
return builder.sign(signer, hashes.SHA256())
|
|
||||||
|
|
||||||
|
|
||||||
def _chain():
|
|
||||||
root_key = ec.generate_private_key(ec.SECP256R1())
|
|
||||||
leaf_key = ec.generate_private_key(ec.SECP256R1())
|
|
||||||
root = _cert(
|
|
||||||
subject_cn="Test Apple Root",
|
|
||||||
subject_key=root_key,
|
|
||||||
issuer_cert=None,
|
|
||||||
issuer_key=None,
|
|
||||||
is_ca=True,
|
|
||||||
)
|
|
||||||
leaf = _cert(
|
|
||||||
subject_cn="Test StoreKit",
|
|
||||||
subject_key=leaf_key,
|
|
||||||
issuer_cert=root,
|
|
||||||
issuer_key=root_key,
|
|
||||||
is_ca=False,
|
|
||||||
app_store=True,
|
|
||||||
)
|
|
||||||
root_pem = root.public_bytes(serialization.Encoding.PEM).decode("ascii")
|
|
||||||
return leaf_key, leaf, root, root_pem
|
|
||||||
|
|
||||||
|
|
||||||
def _b64_der(cert) -> str:
|
|
||||||
der = cert.public_bytes(serialization.Encoding.DER)
|
|
||||||
return base64.b64encode(der).decode("ascii")
|
|
||||||
|
|
||||||
|
|
||||||
def _signed_transaction(leaf_key, leaf, root, claims):
|
|
||||||
return jwt.encode(
|
|
||||||
claims,
|
|
||||||
leaf_key,
|
|
||||||
algorithm="ES256",
|
|
||||||
headers={"x5c": [_b64_der(leaf), _b64_der(root)]},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _future_ms(days=30) -> int:
|
|
||||||
moment = datetime.now(dt_timezone.utc) + timedelta(days=days)
|
|
||||||
return int(moment.timestamp() * 1000)
|
|
||||||
|
|
||||||
|
|
||||||
class AppleConfirmTests(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
seed_subscription_plans(update_existing=False)
|
|
||||||
self.user = make_user(email="apple@example.com")
|
|
||||||
founders = SubscriptionPlan.objects.get(slug="founders")
|
|
||||||
founders.revenuecat_product_id = _PRODUCT
|
|
||||||
founders.save(update_fields=["revenuecat_product_id", "last_modified"])
|
|
||||||
self.leaf_key, self.leaf, self.root, self.root_pem = _chain()
|
|
||||||
|
|
||||||
def _claims(self, **overrides):
|
|
||||||
base = {
|
|
||||||
"bundleId": "ai.hesychia.chat",
|
|
||||||
"productId": _PRODUCT,
|
|
||||||
"transactionId": "2000000000001",
|
|
||||||
"originalTransactionId": "2000000000001",
|
|
||||||
"appAccountToken": store_account_token(self.user.pk),
|
|
||||||
"purchaseDate": _future_ms(days=-1),
|
|
||||||
"expiresDate": _future_ms(days=30),
|
|
||||||
"price": 10000,
|
|
||||||
"currency": "USD",
|
|
||||||
}
|
|
||||||
base.update(overrides)
|
|
||||||
return base
|
|
||||||
|
|
||||||
def _confirm(self, claims):
|
|
||||||
token = _signed_transaction(self.leaf_key, self.leaf, self.root, claims)
|
|
||||||
with override_settings(
|
|
||||||
APPLE_IAP_BUNDLE_ID="ai.hesychia.chat",
|
|
||||||
APPLE_IAP_ROOT_PEM=self.root_pem,
|
|
||||||
):
|
|
||||||
return confirm_store_purchase(
|
|
||||||
self.user,
|
|
||||||
{
|
|
||||||
"store": "app_store",
|
|
||||||
"product_id": _PRODUCT,
|
|
||||||
"transaction_id": claims["transactionId"],
|
|
||||||
"signed_transaction": token,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_signed_transaction_grants_plan(self):
|
|
||||||
sub = self._confirm(self._claims())
|
|
||||||
self.assertEqual(sub.source, UserSubscription.Source.APP_STORE)
|
|
||||||
self.assertEqual(sub.status, UserSubscription.Status.ACTIVE)
|
|
||||||
self.assertEqual(sub.plan.slug, "founders")
|
|
||||||
self.assertEqual(sub.revenuecat_original_transaction_id, "2000000000001")
|
|
||||||
invoice = Invoice.objects.get(user=self.user)
|
|
||||||
self.assertEqual(invoice.provider, Invoice.Provider.APP_STORE)
|
|
||||||
self.assertEqual(invoice.amount_paid, 1000)
|
|
||||||
self.assertEqual(invoice.revenuecat_store, "APP_STORE")
|
|
||||||
payment = Payment.objects.get(user=self.user)
|
|
||||||
self.assertEqual(payment.provider, Payment.Provider.APP_STORE)
|
|
||||||
self.assertEqual(payment.status, Payment.Status.SUCCEEDED)
|
|
||||||
|
|
||||||
def test_repeat_confirm_is_idempotent(self):
|
|
||||||
claims = self._claims()
|
|
||||||
self._confirm(claims)
|
|
||||||
self._confirm(claims)
|
|
||||||
self.assertEqual(Invoice.objects.filter(user=self.user).count(), 1)
|
|
||||||
self.assertEqual(Payment.objects.filter(user=self.user).count(), 1)
|
|
||||||
|
|
||||||
def test_wrong_account_token_rejected(self):
|
|
||||||
with self.assertRaises(StorePurchaseRejected):
|
|
||||||
self._confirm(self._claims(appAccountToken="00000000-0000-0000-0000-000000000000"))
|
|
||||||
|
|
||||||
def test_untrusted_chain_rejected(self):
|
|
||||||
token = _signed_transaction(
|
|
||||||
self.leaf_key, self.leaf, self.root, self._claims()
|
|
||||||
)
|
|
||||||
with override_settings(APPLE_IAP_BUNDLE_ID="ai.hesychia.chat", APPLE_IAP_ROOT_PEM=""):
|
|
||||||
with self.assertRaises(StorePurchaseRejected):
|
|
||||||
confirm_store_purchase(
|
|
||||||
self.user,
|
|
||||||
{
|
|
||||||
"store": "app_store",
|
|
||||||
"product_id": _PRODUCT,
|
|
||||||
"signed_transaction": token,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_revoked_transaction_cancels(self):
|
|
||||||
sub = self._confirm(self._claims(revocationDate=_future_ms(days=-1)))
|
|
||||||
self.assertEqual(sub.status, UserSubscription.Status.CANCELED)
|
|
||||||
self.assertFalse(Payment.objects.filter(user=self.user).exists())
|
|
||||||
|
|
||||||
|
|
||||||
class PlayConfirmTests(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
seed_subscription_plans(update_existing=False)
|
|
||||||
self.user = make_user(email="play@example.com")
|
|
||||||
founders = SubscriptionPlan.objects.get(slug="founders")
|
|
||||||
founders.revenuecat_product_id = _PRODUCT
|
|
||||||
founders.save(update_fields=["revenuecat_product_id", "last_modified"])
|
|
||||||
|
|
||||||
def _body(self, **overrides):
|
|
||||||
expiry = (datetime.now(dt_timezone.utc) + timedelta(days=30)).strftime(
|
|
||||||
"%Y-%m-%dT%H:%M:%SZ"
|
|
||||||
)
|
|
||||||
body = {
|
|
||||||
"subscriptionState": "SUBSCRIPTION_STATE_ACTIVE",
|
|
||||||
"lineItems": [
|
|
||||||
{
|
|
||||||
"productId": _PRODUCT,
|
|
||||||
"expiryTime": expiry,
|
|
||||||
"latestSuccessfulOrderId": "GPA.1234-5678",
|
|
||||||
"autoRenewingPlan": {
|
|
||||||
"autoRenewEnabled": True,
|
|
||||||
"recurringPrice": {
|
|
||||||
"currencyCode": "USD",
|
|
||||||
"units": "10",
|
|
||||||
"nanos": 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"externalAccountIdentifiers": {
|
|
||||||
"obfuscatedExternalAccountId": store_account_token(self.user.pk),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
body.update(overrides)
|
|
||||||
return body
|
|
||||||
|
|
||||||
@patch("monetization.services.store_purchases.fetch_google_subscription")
|
|
||||||
def test_play_token_grants_plan(self, mock_fetch):
|
|
||||||
mock_fetch.return_value = self._body()
|
|
||||||
sub = confirm_store_purchase(
|
|
||||||
self.user,
|
|
||||||
{
|
|
||||||
"store": "play_store",
|
|
||||||
"product_id": _PRODUCT,
|
|
||||||
"purchase_token": "token-abc",
|
|
||||||
"transaction_id": "GPA.1234-5678",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self.assertEqual(sub.source, UserSubscription.Source.PLAY_STORE)
|
|
||||||
self.assertEqual(sub.status, UserSubscription.Status.ACTIVE)
|
|
||||||
self.assertEqual(sub.revenuecat_original_transaction_id, "token-abc")
|
|
||||||
self.assertFalse(sub.cancel_at_period_end)
|
|
||||||
invoice = Invoice.objects.get(user=self.user)
|
|
||||||
self.assertEqual(invoice.provider, Invoice.Provider.PLAY_STORE)
|
|
||||||
self.assertEqual(invoice.amount_paid, 1000)
|
|
||||||
|
|
||||||
@patch("monetization.services.store_purchases.fetch_google_subscription")
|
|
||||||
def test_canceled_but_unexpired_keeps_access(self, mock_fetch):
|
|
||||||
mock_fetch.return_value = self._body(
|
|
||||||
subscriptionState="SUBSCRIPTION_STATE_CANCELED"
|
|
||||||
)
|
|
||||||
sub = confirm_store_purchase(
|
|
||||||
self.user,
|
|
||||||
{
|
|
||||||
"store": "play_store",
|
|
||||||
"product_id": _PRODUCT,
|
|
||||||
"purchase_token": "token-cancel",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self.assertEqual(sub.status, UserSubscription.Status.ACTIVE)
|
|
||||||
self.assertTrue(sub.cancel_at_period_end)
|
|
||||||
|
|
||||||
@patch("monetization.services.store_purchases.fetch_google_subscription")
|
|
||||||
def test_token_already_linked_to_someone_else(self, mock_fetch):
|
|
||||||
other = make_user(email="other@example.com")
|
|
||||||
UserSubscription.objects.create(
|
|
||||||
user=other,
|
|
||||||
plan=SubscriptionPlan.objects.get(slug="founders"),
|
|
||||||
status=UserSubscription.Status.ACTIVE,
|
|
||||||
source=UserSubscription.Source.PLAY_STORE,
|
|
||||||
revenuecat_original_transaction_id="token-taken",
|
|
||||||
)
|
|
||||||
mock_fetch.return_value = self._body()
|
|
||||||
with self.assertRaises(StorePurchaseRejected):
|
|
||||||
confirm_store_purchase(
|
|
||||||
self.user,
|
|
||||||
{
|
|
||||||
"store": "play_store",
|
|
||||||
"product_id": _PRODUCT,
|
|
||||||
"purchase_token": "token-taken",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_missing_play_credentials(self):
|
|
||||||
with override_settings(GOOGLE_PLAY_SERVICE_ACCOUNT_JSON=""):
|
|
||||||
with self.assertRaises(StoreNotConfiguredError):
|
|
||||||
confirm_store_purchase(
|
|
||||||
self.user,
|
|
||||||
{
|
|
||||||
"store": "play_store",
|
|
||||||
"product_id": _PRODUCT,
|
|
||||||
"purchase_token": "token-abc",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class StoreConfirmApiTests(APITestCase):
|
|
||||||
def setUp(self):
|
|
||||||
seed_subscription_plans(update_existing=False)
|
|
||||||
self.user = make_user(email="api@example.com")
|
|
||||||
self.client.force_authenticate(user=self.user)
|
|
||||||
self.url = reverse("finance_store_confirm")
|
|
||||||
|
|
||||||
def test_requires_auth(self):
|
|
||||||
self.client.force_authenticate(user=None)
|
|
||||||
response = self.client.post(self.url, {}, format="json")
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
|
||||||
|
|
||||||
@patch("monetization.views.confirm_store_purchase")
|
|
||||||
def test_rejected_purchase_is_400(self, mock_confirm):
|
|
||||||
mock_confirm.side_effect = StorePurchaseRejected("nope")
|
|
||||||
response = self.client.post(
|
|
||||||
self.url,
|
|
||||||
{
|
|
||||||
"store": "play_store",
|
|
||||||
"product_id": _PRODUCT,
|
|
||||||
"purchase_token": "token",
|
|
||||||
},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
def test_lapsed_store_period_ends_on_read(self):
|
|
||||||
plan = SubscriptionPlan.objects.get(slug="founders")
|
|
||||||
UserSubscription.objects.create(
|
|
||||||
user=self.user,
|
|
||||||
plan=plan,
|
|
||||||
status=UserSubscription.Status.ACTIVE,
|
|
||||||
source=UserSubscription.Source.APP_STORE,
|
|
||||||
current_period_end=timezone.now() - timedelta(days=1),
|
|
||||||
)
|
|
||||||
response = self.client.get(reverse("finance_subscription_me"))
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertEqual(response.data["status"], "canceled")
|
|
||||||
self.assertTrue(response.data["needs_checkout"])
|
|
||||||
self.assertEqual(
|
|
||||||
response.data["store_account_token"],
|
|
||||||
store_account_token(self.user.pk),
|
|
||||||
)
|
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from monetization.views import (
|
from monetization.views import (
|
||||||
ConfirmStorePurchaseView,
|
|
||||||
CreateBillingPortalSessionView,
|
CreateBillingPortalSessionView,
|
||||||
CreateCheckoutSessionView,
|
CreateCheckoutSessionView,
|
||||||
InvoiceListView,
|
InvoiceListView,
|
||||||
PaymentListView,
|
PaymentListView,
|
||||||
PlanListView,
|
PlanListView,
|
||||||
|
RevenueCatWebhookView,
|
||||||
StripeWebhookView,
|
StripeWebhookView,
|
||||||
SubscriptionMeView,
|
SubscriptionMeView,
|
||||||
)
|
)
|
||||||
@@ -42,14 +42,14 @@ urlpatterns = [
|
|||||||
SubscriptionMeView.as_view(),
|
SubscriptionMeView.as_view(),
|
||||||
name="finance_subscription_me",
|
name="finance_subscription_me",
|
||||||
),
|
),
|
||||||
path(
|
|
||||||
"store/confirm/",
|
|
||||||
ConfirmStorePurchaseView.as_view(),
|
|
||||||
name="finance_store_confirm",
|
|
||||||
),
|
|
||||||
path(
|
path(
|
||||||
"webhooks/stripe/",
|
"webhooks/stripe/",
|
||||||
StripeWebhookView.as_view(),
|
StripeWebhookView.as_view(),
|
||||||
name="finance_stripe_webhook",
|
name="finance_stripe_webhook",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"webhooks/revenuecat/",
|
||||||
|
RevenueCatWebhookView.as_view(),
|
||||||
|
name="finance_revenuecat_webhook",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
@@ -12,7 +12,6 @@ from monetization.serializers import (
|
|||||||
InvoiceSerializer,
|
InvoiceSerializer,
|
||||||
PaymentSerializer,
|
PaymentSerializer,
|
||||||
PortalSessionSerializer,
|
PortalSessionSerializer,
|
||||||
StorePurchaseConfirmSerializer,
|
|
||||||
SubscriptionPlanSerializer,
|
SubscriptionPlanSerializer,
|
||||||
)
|
)
|
||||||
from monetization.services.plans import (
|
from monetization.services.plans import (
|
||||||
@@ -21,13 +20,10 @@ from monetization.services.plans import (
|
|||||||
seed_subscription_plans,
|
seed_subscription_plans,
|
||||||
)
|
)
|
||||||
from monetization.services.quotas import get_usage_snapshot
|
from monetization.services.quotas import get_usage_snapshot
|
||||||
from monetization.services.store_purchases import (
|
from monetization.services.revenuecat import (
|
||||||
StoreNotConfiguredError,
|
RevenueCatWebhookAuthError,
|
||||||
StorePurchaseRejected,
|
dispatch_revenuecat_event,
|
||||||
StoreVerificationError,
|
verify_revenuecat_authorization,
|
||||||
confirm_store_purchase,
|
|
||||||
expire_lapsed_store_subscription,
|
|
||||||
store_account_token,
|
|
||||||
)
|
)
|
||||||
from monetization.services.stripe import (
|
from monetization.services.stripe import (
|
||||||
StripeNotConfiguredError,
|
StripeNotConfiguredError,
|
||||||
@@ -198,7 +194,6 @@ class SubscriptionMeView(APIView):
|
|||||||
sub = request.user.subscription
|
sub = request.user.subscription
|
||||||
except UserSubscription.DoesNotExist:
|
except UserSubscription.DoesNotExist:
|
||||||
sub = None
|
sub = None
|
||||||
sub = expire_lapsed_store_subscription(sub)
|
|
||||||
|
|
||||||
usage = get_usage_snapshot(request.user)
|
usage = get_usage_snapshot(request.user)
|
||||||
payload = {
|
payload = {
|
||||||
@@ -218,52 +213,11 @@ class SubscriptionMeView(APIView):
|
|||||||
if sub and sub.current_period_end
|
if sub and sub.current_period_end
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
"store_account_token": store_account_token(request.user.pk),
|
|
||||||
"usage": usage.to_dict(),
|
"usage": usage.to_dict(),
|
||||||
}
|
}
|
||||||
return Response(payload)
|
return Response(payload)
|
||||||
|
|
||||||
|
|
||||||
class ConfirmStorePurchaseView(APIView):
|
|
||||||
"""Verify a StoreKit or Play Billing purchase and grant the plan.
|
|
||||||
|
|
||||||
The app calls this after the native store sheet succeeds. There is no
|
|
||||||
store webhook.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def post(self, request):
|
|
||||||
serializer = StorePurchaseConfirmSerializer(data=request.data)
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
try:
|
|
||||||
sub = confirm_store_purchase(request.user, serializer.validated_data)
|
|
||||||
except StorePurchaseRejected as exc:
|
|
||||||
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
except StoreNotConfiguredError as exc:
|
|
||||||
return Response(
|
|
||||||
{"detail": str(exc)},
|
|
||||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
||||||
)
|
|
||||||
except StoreVerificationError as exc:
|
|
||||||
logger.exception("Store purchase verification failed")
|
|
||||||
return Response({"detail": str(exc)}, status=status.HTTP_502_BAD_GATEWAY)
|
|
||||||
|
|
||||||
return Response(
|
|
||||||
{
|
|
||||||
"source": sub.source,
|
|
||||||
"status": sub.status,
|
|
||||||
"plan_slug": sub.plan.slug if sub.plan_id else None,
|
|
||||||
"cancel_at_period_end": bool(sub.cancel_at_period_end),
|
|
||||||
"current_period_end": (
|
|
||||||
sub.current_period_end.isoformat()
|
|
||||||
if sub.current_period_end
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
"store_account_token": store_account_token(request.user.pk),
|
|
||||||
},
|
|
||||||
status=status.HTTP_200_OK,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class StripeWebhookView(APIView):
|
class StripeWebhookView(APIView):
|
||||||
"""Verify Stripe signatures and upsert local invoice/payment rows."""
|
"""Verify Stripe signatures and upsert local invoice/payment rows."""
|
||||||
|
|
||||||
@@ -312,3 +266,50 @@ class StripeWebhookView(APIView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return Response({"received": True}, status=status.HTTP_200_OK)
|
return Response({"received": True}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
class RevenueCatWebhookView(APIView):
|
||||||
|
"""Verify RevenueCat Authorization and upsert subscription + ledger rows."""
|
||||||
|
|
||||||
|
permission_classes = (permissions.AllowAny,)
|
||||||
|
authentication_classes = ()
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
webhook_secret = settings.REVENUECAT_WEBHOOK_SECRET
|
||||||
|
if not webhook_secret:
|
||||||
|
logger.error("REVENUECAT_WEBHOOK_SECRET is not configured")
|
||||||
|
return Response(
|
||||||
|
{"detail": "Webhook secret not configured"},
|
||||||
|
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
verify_revenuecat_authorization(
|
||||||
|
authorization_header=request.META.get("HTTP_AUTHORIZATION"),
|
||||||
|
expected_secret=webhook_secret,
|
||||||
|
)
|
||||||
|
except RevenueCatWebhookAuthError as exc:
|
||||||
|
return Response(
|
||||||
|
{"detail": str(exc)},
|
||||||
|
status=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = request.data
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return Response(
|
||||||
|
{"detail": "Invalid payload"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
dispatch_revenuecat_event(payload)
|
||||||
|
except Exception:
|
||||||
|
event = payload.get("event") if isinstance(payload, dict) else {}
|
||||||
|
event_id = event.get("id") if isinstance(event, dict) else None
|
||||||
|
logger.exception("Error handling RevenueCat event %s", event_id)
|
||||||
|
return Response(
|
||||||
|
{"detail": "Webhook handler error"},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({"received": True}, status=status.HTTP_200_OK)
|
||||||
@@ -497,11 +497,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "certifi"
|
name = "certifi"
|
||||||
version = "2026.10.23"
|
version = "2026.7.22"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/fd/11/b524cbd9bbdaca58608e4cf4053fd463417549e1959fa8d3aa18319f9555/certifi-2026.10.23.tar.gz", hash = "sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd", size = 136225, upload-time = "2026-10-23T11:36:16.478Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1", size = 135082, upload-time = "2026-10-23T11:36:14.763Z" },
|
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
Reference in new issue
Block a user