Phase 1 - Configuration Infrastructure (WS1): - Add instance-level AI env vars (VOYAGE_AI_PROVIDER, VOYAGE_AI_MODEL, VOYAGE_AI_API_KEY) - Implement fallback chain: user key → instance key → error - Add UserAISettings model for per-user provider/model preferences - Enhance provider catalog with instance_configured and user_configured flags - Optimize provider catalog to avoid N+1 queries Phase 1 - User Preference Learning (WS2): - Add Travel Preferences tab to Settings page - Improve preference formatting in system prompt with emoji headers - Add multi-user preference aggregation for shared collections Phase 2 - Day-Level Suggestions Modal (WS3): - Create ItinerarySuggestionModal with 3-step flow (category → filters → results) - Add AI suggestions button to itinerary Add dropdown - Support restaurant, activity, event, and lodging categories - Backend endpoint POST /api/chat/suggestions/day/ with context-aware prompts Phase 3 - Collection-Level Chat Improvements (WS4): - Inject collection context (destination, dates) into chat system prompt - Add quick action buttons for common queries - Add 'Add to itinerary' button on search_places results - Update chat UI with travel-themed branding and improved tool result cards Phase 3 - Web Search Capability (WS5): - Add web_search agent tool using DuckDuckGo - Support location_context parameter for biased results - Handle rate limiting gracefully Phase 4 - Extensibility Architecture (WS6): - Implement decorator-based @agent_tool registry - Convert existing tools to use decorators - Add GET /api/chat/capabilities/ endpoint for tool discovery - Refactor execute_tool() to use registry pattern
418 lines
14 KiB
Python
418 lines
14 KiB
Python
"""
|
|
Voyage Server settings
|
|
|
|
Reference:
|
|
- Django settings: https://docs.djangoproject.com/en/stable/ref/settings/
|
|
"""
|
|
|
|
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
|
import os
|
|
from dotenv import load_dotenv
|
|
from os import getenv
|
|
from pathlib import Path
|
|
from urllib.parse import urlparse
|
|
from publicsuffix2 import get_sld
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Environment & Paths
|
|
# ---------------------------------------------------------------------------
|
|
# Load environment variables from .env file early so getenv works everywhere.
|
|
load_dotenv()
|
|
|
|
BASE_DIR = os.path.dirname(os.path.dirname(__file__))
|
|
|
|
# Quick-start development settings - unsuitable for production
|
|
# See Django deployment checklist for production hardening.
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Core Security & Debug
|
|
# ---------------------------------------------------------------------------
|
|
# SECURITY WARNING: keep the secret key used in production secret!
|
|
SECRET_KEY = getenv("SECRET_KEY")
|
|
|
|
# SECURITY WARNING: don't run with debug turned on in production!
|
|
DEBUG = getenv("DEBUG", "true").lower() == "true"
|
|
|
|
# ALLOWED_HOSTS = [
|
|
# 'localhost',
|
|
# '127.0.0.1',
|
|
# 'server'
|
|
# ]
|
|
ALLOWED_HOSTS = ["*"] # In production, restrict to known hosts.
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Installed Apps
|
|
# ---------------------------------------------------------------------------
|
|
INSTALLED_APPS = (
|
|
"allauth_ui",
|
|
"django.contrib.admin",
|
|
"django.contrib.auth",
|
|
"django.contrib.contenttypes",
|
|
"django.contrib.sessions",
|
|
"django.contrib.messages",
|
|
"django.contrib.staticfiles",
|
|
"django.contrib.sites",
|
|
"rest_framework",
|
|
"rest_framework.authtoken",
|
|
"allauth",
|
|
"allauth.account",
|
|
"allauth.mfa",
|
|
"allauth.headless",
|
|
"allauth.socialaccount",
|
|
"allauth.socialaccount.providers.github",
|
|
"allauth.socialaccount.providers.openid_connect",
|
|
"invitations",
|
|
"drf_yasg",
|
|
"djmoney",
|
|
"corsheaders",
|
|
"adventures",
|
|
"worldtravel",
|
|
"users",
|
|
"integrations",
|
|
"chat",
|
|
"mcp_server",
|
|
"django.contrib.gis",
|
|
# 'achievements', # Not done yet, will be added later in a future update
|
|
"widget_tweaks",
|
|
"slippers",
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Middleware
|
|
# ---------------------------------------------------------------------------
|
|
MIDDLEWARE = (
|
|
"whitenoise.middleware.WhiteNoiseMiddleware",
|
|
"adventures.middleware.XSessionTokenMiddleware",
|
|
"adventures.middleware.DisableCSRFForSessionTokenMiddleware",
|
|
"adventures.middleware.DisableCSRFForMobileLoginSignup",
|
|
"corsheaders.middleware.CorsMiddleware",
|
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
|
"django.middleware.common.CommonMiddleware",
|
|
"adventures.middleware.OverrideHostMiddleware",
|
|
"django.middleware.csrf.CsrfViewMiddleware",
|
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
|
"django.contrib.messages.middleware.MessageMiddleware",
|
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
|
"allauth.account.middleware.AccountMiddleware",
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Caching
|
|
# ---------------------------------------------------------------------------
|
|
CACHES = {
|
|
"default": {
|
|
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
|
|
"LOCATION": "127.0.0.1:11211",
|
|
"TIMEOUT": 60 * 60 * 24, # Optional: 1 day cache
|
|
}
|
|
}
|
|
|
|
# For backwards compatibility for Django 1.8
|
|
MIDDLEWARE_CLASSES = MIDDLEWARE
|
|
|
|
ROOT_URLCONF = "main.urls"
|
|
|
|
# WSGI_APPLICATION = 'demo.wsgi.application'
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Database
|
|
# ---------------------------------------------------------------------------
|
|
# Using legacy PG environment variables for compatibility with existing setups
|
|
|
|
|
|
def env(*keys, default=None):
|
|
"""Return the first non-empty environment variable from a list of keys."""
|
|
for key in keys:
|
|
value = os.getenv(key)
|
|
if value:
|
|
return value
|
|
return default
|
|
|
|
|
|
DATABASES = {
|
|
"default": {
|
|
"ENGINE": "django.contrib.gis.db.backends.postgis",
|
|
"NAME": env("PGDATABASE", "POSTGRES_DB"),
|
|
"USER": env("PGUSER", "POSTGRES_USER"),
|
|
"PASSWORD": env("PGPASSWORD", "POSTGRES_PASSWORD"),
|
|
"HOST": env("PGHOST", default="localhost"),
|
|
"PORT": int(env("PGPORT", default="5432")),
|
|
"OPTIONS": {
|
|
"sslmode": "prefer", # Prefer SSL, but allow non-SSL connections
|
|
},
|
|
}
|
|
}
|
|
|
|
# Internationalization
|
|
# https://docs.djangoproject.com/en/1.7/topics/i18n/
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Internationalization
|
|
# ---------------------------------------------------------------------------
|
|
LANGUAGE_CODE = "en-us"
|
|
TIME_ZONE = "UTC"
|
|
USE_I18N = True
|
|
USE_L10N = True
|
|
USE_TZ = True
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Frontend URL & Cookies
|
|
# ---------------------------------------------------------------------------
|
|
# Derive frontend URL from environment and configure cookie behavior.
|
|
unParsedFrontenedUrl = getenv("FRONTEND_URL", "http://localhost:3000")
|
|
FRONTEND_URL = unParsedFrontenedUrl.translate(str.maketrans("", "", "'\""))
|
|
|
|
SESSION_COOKIE_SAMESITE = "Lax"
|
|
SESSION_COOKIE_NAME = "sessionid"
|
|
|
|
# Secure cookies if frontend is served over HTTPS
|
|
SESSION_COOKIE_SECURE = FRONTEND_URL.startswith("https")
|
|
CSRF_COOKIE_SECURE = FRONTEND_URL.startswith("https")
|
|
|
|
# Dynamically determine cookie domain to support subdomains while avoiding IPs
|
|
hostname = urlparse(FRONTEND_URL).hostname
|
|
is_ip_address = hostname.replace(".", "").isdigit()
|
|
is_single_label = "." not in hostname # single-label hostnames (e.g., "localhost")
|
|
|
|
if is_ip_address or is_single_label:
|
|
SESSION_COOKIE_DOMAIN = None
|
|
else:
|
|
cookie_domain = get_sld(hostname)
|
|
SESSION_COOKIE_DOMAIN = f".{cookie_domain}" if cookie_domain else hostname
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Static & Media Files
|
|
# ---------------------------------------------------------------------------
|
|
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
|
|
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
STATIC_ROOT = BASE_DIR / "staticfiles"
|
|
STATIC_URL = "/static/"
|
|
|
|
MEDIA_URL = "/media/"
|
|
MEDIA_ROOT = BASE_DIR / "media" # Must match NGINX root for media serving
|
|
STATICFILES_DIRS = [BASE_DIR / "static"]
|
|
|
|
STORAGES = {
|
|
"staticfiles": {
|
|
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
|
},
|
|
"default": {
|
|
"BACKEND": "django.core.files.storage.FileSystemStorage",
|
|
},
|
|
}
|
|
|
|
SILENCED_SYSTEM_CHECKS = ["slippers.E001"]
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Templates
|
|
# ---------------------------------------------------------------------------
|
|
TEMPLATES = [
|
|
{
|
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
|
"DIRS": [
|
|
os.path.join(BASE_DIR, "templates"),
|
|
],
|
|
"APP_DIRS": True,
|
|
"OPTIONS": {
|
|
"context_processors": [
|
|
"django.template.context_processors.debug",
|
|
"django.template.context_processors.request",
|
|
"django.contrib.auth.context_processors.auth",
|
|
"django.contrib.messages.context_processors.messages",
|
|
],
|
|
},
|
|
},
|
|
]
|
|
|
|
ALLAUTH_UI_THEME = "dim"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Authentication & Accounts
|
|
# ---------------------------------------------------------------------------
|
|
DISABLE_REGISTRATION = getenv("DISABLE_REGISTRATION", "false").lower() == "true"
|
|
DISABLE_REGISTRATION_MESSAGE = getenv(
|
|
"DISABLE_REGISTRATION_MESSAGE",
|
|
"Registration is disabled. Please contact the administrator if you need an account.",
|
|
)
|
|
|
|
SOCIALACCOUNT_ALLOW_SIGNUP = (
|
|
getenv("SOCIALACCOUNT_ALLOW_SIGNUP", "false").lower() == "true"
|
|
)
|
|
|
|
AUTH_USER_MODEL = "users.CustomUser"
|
|
ACCOUNT_ADAPTER = "users.adapters.CustomAccountAdapter"
|
|
INVITATIONS_ADAPTER = ACCOUNT_ADAPTER
|
|
INVITATIONS_ACCEPT_INVITE_AFTER_SIGNUP = True
|
|
INVITATIONS_EMAIL_SUBJECT_PREFIX = "Voyage: "
|
|
SOCIALACCOUNT_ADAPTER = "users.adapters.CustomSocialAccountAdapter"
|
|
ACCOUNT_SIGNUP_FORM_CLASS = "users.form_overrides.CustomSignupForm"
|
|
|
|
SESSION_SAVE_EVERY_REQUEST = True
|
|
LOGIN_REDIRECT_URL = FRONTEND_URL # Redirect to frontend after login
|
|
|
|
SOCIALACCOUNT_LOGIN_ON_GET = True
|
|
INVITATIONS_INVITE_FORM = "users.form_overrides.UseAdminInviteForm"
|
|
INVITATIONS_SIGNUP_REDIRECT_URL = f"{FRONTEND_URL}/signup"
|
|
|
|
HEADLESS_FRONTEND_URLS = {
|
|
"account_confirm_email": f"{FRONTEND_URL}/user/verify-email/{{key}}",
|
|
"account_reset_password": f"{FRONTEND_URL}/user/reset-password",
|
|
"account_reset_password_from_key": f"{FRONTEND_URL}/user/reset-password/{{key}}",
|
|
"account_signup": f"{FRONTEND_URL}/signup",
|
|
# Fallback if handshake with provider fails and `next` URL is lost.
|
|
"socialaccount_login_error": f"{FRONTEND_URL}/account/provider/callback",
|
|
}
|
|
|
|
AUTHENTICATION_BACKENDS = [
|
|
"users.backends.NoPasswordAuthBackend",
|
|
# 'allauth.account.auth_backends.AuthenticationBackend',
|
|
# 'django.contrib.auth.backends.ModelBackend',
|
|
]
|
|
|
|
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
|
SITE_ID = 1
|
|
ACCOUNT_EMAIL_REQUIRED = True
|
|
ACCOUNT_UNIQUE_EMAIL = True
|
|
ACCOUNT_EMAIL_VERIFICATION = getenv(
|
|
"ACCOUNT_EMAIL_VERIFICATION", "none"
|
|
) # 'none', 'optional', 'mandatory'
|
|
|
|
SOCIALACCOUNT_EMAIL_AUTHENTICATION = True
|
|
SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = True # Auto-link by email
|
|
SOCIALACCOUNT_AUTO_SIGNUP = True # Allow auto-signup post adapter checks
|
|
|
|
FORCE_SOCIALACCOUNT_LOGIN = (
|
|
getenv("FORCE_SOCIALACCOUNT_LOGIN", "false").lower() == "true"
|
|
) # When true, only social login is allowed (no password login) and the login page will show only social providers or redirect directly to the first provider if only one is configured.
|
|
|
|
if getenv("EMAIL_BACKEND", "console") == "console":
|
|
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
|
else:
|
|
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
|
|
EMAIL_HOST = getenv("EMAIL_HOST")
|
|
EMAIL_USE_TLS = getenv("EMAIL_USE_TLS", "true").lower() == "true"
|
|
EMAIL_PORT = getenv("EMAIL_PORT", 587)
|
|
EMAIL_USE_SSL = getenv("EMAIL_USE_SSL", "false").lower() == "true"
|
|
EMAIL_HOST_USER = getenv("EMAIL_HOST_USER")
|
|
EMAIL_HOST_PASSWORD = getenv("EMAIL_HOST_PASSWORD")
|
|
DEFAULT_FROM_EMAIL = getenv("DEFAULT_FROM_EMAIL")
|
|
|
|
# EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
|
|
# EMAIL_HOST = 'smtp.resend.com'
|
|
# EMAIL_USE_TLS = False
|
|
# EMAIL_PORT = 2465
|
|
# EMAIL_USE_SSL = True
|
|
# EMAIL_HOST_USER = 'resend'
|
|
# EMAIL_HOST_PASSWORD = ''
|
|
# DEFAULT_FROM_EMAIL = 'mail@mail.user.com'
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Django REST Framework
|
|
# ---------------------------------------------------------------------------
|
|
REST_FRAMEWORK = {
|
|
"DEFAULT_AUTHENTICATION_CLASSES": (
|
|
"rest_framework.authentication.SessionAuthentication",
|
|
),
|
|
"DEFAULT_SCHEMA_CLASS": "rest_framework.schemas.coreapi.AutoSchema",
|
|
"DEFAULT_THROTTLE_CLASSES": [
|
|
"rest_framework.throttling.UserRateThrottle",
|
|
],
|
|
"DEFAULT_THROTTLE_RATES": {
|
|
"user": "1000/day",
|
|
"image_proxy": "60/minute",
|
|
},
|
|
}
|
|
|
|
if DEBUG:
|
|
REST_FRAMEWORK["DEFAULT_RENDERER_CLASSES"] = (
|
|
"rest_framework.renderers.JSONRenderer",
|
|
"rest_framework.renderers.BrowsableAPIRenderer",
|
|
)
|
|
else:
|
|
REST_FRAMEWORK["DEFAULT_RENDERER_CLASSES"] = (
|
|
"rest_framework.renderers.JSONRenderer",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CORS & CSRF
|
|
# ---------------------------------------------------------------------------
|
|
CORS_ALLOWED_ORIGINS = [
|
|
origin.strip()
|
|
for origin in getenv("CSRF_TRUSTED_ORIGINS", "http://localhost").split(",")
|
|
if origin.strip()
|
|
]
|
|
CSRF_TRUSTED_ORIGINS = [
|
|
origin.strip()
|
|
for origin in getenv("CSRF_TRUSTED_ORIGINS", "http://localhost").split(",")
|
|
if origin.strip()
|
|
]
|
|
CORS_ALLOW_CREDENTIALS = True
|
|
|
|
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Logging
|
|
# ---------------------------------------------------------------------------
|
|
LOGGING = {
|
|
"version": 1,
|
|
"disable_existing_loggers": False,
|
|
"handlers": {
|
|
"console": {
|
|
"class": "logging.StreamHandler",
|
|
},
|
|
"file": {
|
|
"class": "logging.FileHandler",
|
|
"filename": "scheduler.log",
|
|
},
|
|
},
|
|
"root": {
|
|
"handlers": ["console", "file"],
|
|
"level": "INFO",
|
|
},
|
|
"loggers": {
|
|
"django": {
|
|
"handlers": ["console", "file"],
|
|
"level": "INFO",
|
|
"propagate": False,
|
|
},
|
|
},
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public URLs & Third-Party Integrations
|
|
# ---------------------------------------------------------------------------
|
|
PUBLIC_URL = getenv("PUBLIC_URL", "http://localhost:8000")
|
|
|
|
# VOYAGE_CDN_URL = getenv('VOYAGE_CDN_URL', 'https://cdn.voyage.app')
|
|
|
|
# Major release version of Voyage, not including the patch version date.
|
|
VOYAGE_RELEASE_VERSION = "v0.12.0"
|
|
|
|
# https://github.com/dr5hn/countries-states-cities-database/tags
|
|
COUNTRY_REGION_JSON_VERSION = "v3.0"
|
|
|
|
# External service keys (do not hardcode secrets)
|
|
GOOGLE_MAPS_API_KEY = getenv("GOOGLE_MAPS_API_KEY", "")
|
|
STRAVA_CLIENT_ID = getenv("STRAVA_CLIENT_ID", "")
|
|
STRAVA_CLIENT_SECRET = getenv("STRAVA_CLIENT_SECRET", "")
|
|
OSRM_BASE_URL = getenv("OSRM_BASE_URL", "https://router.project-osrm.org")
|
|
|
|
FIELD_ENCRYPTION_KEY = getenv("FIELD_ENCRYPTION_KEY", "")
|
|
|
|
# Voyage AI Configuration
|
|
VOYAGE_AI_PROVIDER = getenv("VOYAGE_AI_PROVIDER", "openai")
|
|
VOYAGE_AI_MODEL = getenv("VOYAGE_AI_MODEL", "gpt-4o-mini")
|
|
VOYAGE_AI_API_KEY = getenv("VOYAGE_AI_API_KEY", "")
|
|
|
|
DJANGO_MCP_ENDPOINT = getenv("DJANGO_MCP_ENDPOINT", "api/mcp")
|
|
DJANGO_MCP_AUTHENTICATION_CLASSES = [
|
|
"rest_framework.authentication.TokenAuthentication",
|
|
]
|
|
DJANGO_MCP_ENDPOINT_PERMISSION_CLASSES = [
|
|
"rest_framework.permissions.IsAuthenticated",
|
|
]
|