{}", obj.masked_api_key)
+
+
+admin.site.register(UserRecommendationPreferenceProfile)
diff --git a/backend/server/integrations/migrations/0007_userapikey_userrecommendationpreferenceprofile.py b/backend/server/integrations/migrations/0007_userapikey_userrecommendationpreferenceprofile.py
new file mode 100644
index 00000000..785d825c
--- /dev/null
+++ b/backend/server/integrations/migrations/0007_userapikey_userrecommendationpreferenceprofile.py
@@ -0,0 +1,75 @@
+# Generated by Django 5.2.12 on 2026-03-08
+
+import django.db.models.deletion
+import uuid
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("integrations", "0006_alter_wandererintegration_token"),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="UserRecommendationPreferenceProfile",
+ fields=[
+ (
+ "id",
+ models.UUIDField(
+ default=uuid.uuid4,
+ editable=False,
+ primary_key=True,
+ serialize=False,
+ unique=True,
+ ),
+ ),
+ ("cuisines", models.TextField(blank=True, null=True)),
+ ("interests", models.JSONField(blank=True, default=list)),
+ ("trip_style", models.CharField(blank=True, max_length=120, null=True)),
+ ("notes", models.TextField(blank=True, null=True)),
+ ("created_at", models.DateTimeField(auto_now_add=True)),
+ ("updated_at", models.DateTimeField(auto_now=True)),
+ (
+ "user",
+ models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="recommendation_profile",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ ),
+ migrations.CreateModel(
+ name="UserAPIKey",
+ fields=[
+ (
+ "id",
+ models.UUIDField(
+ default=uuid.uuid4,
+ editable=False,
+ primary_key=True,
+ serialize=False,
+ unique=True,
+ ),
+ ),
+ ("provider", models.CharField(max_length=100)),
+ ("encrypted_api_key", models.TextField()),
+ ("created_at", models.DateTimeField(auto_now_add=True)),
+ ("updated_at", models.DateTimeField(auto_now=True)),
+ (
+ "user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="api_keys",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ options={
+ "unique_together": {("user", "provider")},
+ },
+ ),
+ ]
diff --git a/backend/server/integrations/models.py b/backend/server/integrations/models.py
index 79dc837e..0029308d 100644
--- a/backend/server/integrations/models.py
+++ b/backend/server/integrations/models.py
@@ -1,23 +1,52 @@
from django.db import models
from django.contrib.auth import get_user_model
import uuid
+from django.conf import settings
+from cryptography.fernet import Fernet, InvalidToken
User = get_user_model()
+
+class EncryptionConfigurationError(Exception):
+ pass
+
+
+def get_field_fernet() -> Fernet:
+ key = getattr(settings, "FIELD_ENCRYPTION_KEY", None)
+ if not key:
+ raise EncryptionConfigurationError(
+ "FIELD_ENCRYPTION_KEY is not configured. API key storage is unavailable."
+ )
+
+ key_bytes = key.encode() if isinstance(key, str) else key
+ try:
+ return Fernet(key_bytes)
+ except (TypeError, ValueError) as exc:
+ raise EncryptionConfigurationError(
+ "FIELD_ENCRYPTION_KEY is invalid. Provide a valid Fernet key."
+ ) from exc
+
+
class ImmichIntegration(models.Model):
server_url = models.CharField(max_length=255)
api_key = models.CharField(max_length=255)
- user = models.ForeignKey(
- User, on_delete=models.CASCADE)
- copy_locally = models.BooleanField(default=True, help_text="Copy image to local storage, instead of just linking to the remote URL.")
- id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
+ user = models.ForeignKey(User, on_delete=models.CASCADE)
+ copy_locally = models.BooleanField(
+ default=True,
+ help_text="Copy image to local storage, instead of just linking to the remote URL.",
+ )
+ id = models.UUIDField(
+ default=uuid.uuid4, editable=False, unique=True, primary_key=True
+ )
def __str__(self):
- return self.user.username + ' - ' + self.server_url
-
+ return self.user.username + " - " + self.server_url
+
+
class StravaToken(models.Model):
user = models.ForeignKey(
- User, on_delete=models.CASCADE, related_name='strava_tokens')
+ User, on_delete=models.CASCADE, related_name="strava_tokens"
+ )
access_token = models.CharField(max_length=255)
refresh_token = models.CharField(max_length=255)
expires_at = models.BigIntegerField() # Unix timestamp
@@ -25,18 +54,73 @@ class StravaToken(models.Model):
scope = models.CharField(max_length=255, null=True, blank=True)
updated_at = models.DateTimeField(auto_now=True)
+
class WandererIntegration(models.Model):
server_url = models.CharField(max_length=255)
username = models.CharField(max_length=255)
user = models.ForeignKey(
- User, on_delete=models.CASCADE, related_name='wanderer_integrations')
+ User, on_delete=models.CASCADE, related_name="wanderer_integrations"
+ )
token = models.CharField(null=True, blank=True)
token_expiry = models.DateTimeField(null=True, blank=True)
- id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
+ id = models.UUIDField(
+ default=uuid.uuid4, editable=False, unique=True, primary_key=True
+ )
def __str__(self):
- return self.user.username + ' - ' + self.server_url
-
+ return self.user.username + " - " + self.server_url
+
class Meta:
verbose_name = "Wanderer Integration"
- verbose_name_plural = "Wanderer Integrations"
\ No newline at end of file
+ verbose_name_plural = "Wanderer Integrations"
+
+
+class UserAPIKey(models.Model):
+ id = models.UUIDField(
+ default=uuid.uuid4, editable=False, unique=True, primary_key=True
+ )
+ user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="api_keys")
+ provider = models.CharField(max_length=100)
+ encrypted_api_key = models.TextField()
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ unique_together = ("user", "provider")
+
+ def set_api_key(self, value: str) -> None:
+ if value is None:
+ raise ValueError("API key cannot be None")
+ fernet = get_field_fernet()
+ self.encrypted_api_key = fernet.encrypt(value.encode()).decode()
+
+ def get_api_key(self) -> str | None:
+ if not self.encrypted_api_key:
+ return None
+ fernet = get_field_fernet()
+ try:
+ return fernet.decrypt(self.encrypted_api_key.encode()).decode()
+ except (InvalidToken, ValueError):
+ return None
+
+ @property
+ def masked_api_key(self) -> str:
+ plain = self.get_api_key() or ""
+ if len(plain) <= 6:
+ return "*" * len(plain)
+ return f"{plain[:3]}{'*' * (len(plain) - 6)}{plain[-3:]}"
+
+
+class UserRecommendationPreferenceProfile(models.Model):
+ id = models.UUIDField(
+ default=uuid.uuid4, editable=False, unique=True, primary_key=True
+ )
+ user = models.OneToOneField(
+ User, on_delete=models.CASCADE, related_name="recommendation_profile"
+ )
+ cuisines = models.TextField(blank=True, null=True)
+ interests = models.JSONField(default=list, blank=True)
+ trip_style = models.CharField(max_length=120, blank=True, null=True)
+ notes = models.TextField(blank=True, null=True)
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
diff --git a/backend/server/integrations/serializers.py b/backend/server/integrations/serializers.py
index cc92d211..5ae98598 100644
--- a/backend/server/integrations/serializers.py
+++ b/backend/server/integrations/serializers.py
@@ -1,13 +1,82 @@
-from .models import ImmichIntegration
+from .models import (
+ EncryptionConfigurationError,
+ ImmichIntegration,
+ UserAPIKey,
+ UserRecommendationPreferenceProfile,
+)
from rest_framework import serializers
+
class ImmichIntegrationSerializer(serializers.ModelSerializer):
class Meta:
model = ImmichIntegration
- fields = '__all__'
- read_only_fields = ['id', 'user']
+ fields = "__all__"
+ read_only_fields = ["id", "user"]
def to_representation(self, instance):
representation = super().to_representation(instance)
- representation.pop('user', None)
+ representation.pop("user", None)
return representation
+
+
+class UserAPIKeySerializer(serializers.ModelSerializer):
+ api_key = serializers.CharField(write_only=True, required=True, allow_blank=False)
+ masked_api_key = serializers.CharField(read_only=True)
+
+ class Meta:
+ model = UserAPIKey
+ fields = [
+ "id",
+ "provider",
+ "api_key",
+ "masked_api_key",
+ "created_at",
+ "updated_at",
+ ]
+ read_only_fields = ["id", "masked_api_key", "created_at", "updated_at"]
+
+ def validate_provider(self, value):
+ return (value or "").strip().lower()
+
+ def create(self, validated_data):
+ api_key = validated_data.pop("api_key")
+ user = self.context["request"].user
+ instance = UserAPIKey(user=user, **validated_data)
+ try:
+ instance.set_api_key(api_key)
+ except EncryptionConfigurationError as exc:
+ raise serializers.ValidationError({"api_key": str(exc)}) from exc
+ instance.save()
+ return instance
+
+ def update(self, instance, validated_data):
+ api_key = validated_data.pop("api_key", None)
+ for attr, value in validated_data.items():
+ setattr(instance, attr, value)
+ if api_key is not None:
+ try:
+ instance.set_api_key(api_key)
+ except EncryptionConfigurationError as exc:
+ raise serializers.ValidationError({"api_key": str(exc)}) from exc
+ instance.save()
+ return instance
+
+ def to_representation(self, instance):
+ representation = super().to_representation(instance)
+ representation.pop("api_key", None)
+ return representation
+
+
+class UserRecommendationPreferenceProfileSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = UserRecommendationPreferenceProfile
+ fields = [
+ "id",
+ "cuisines",
+ "interests",
+ "trip_style",
+ "notes",
+ "created_at",
+ "updated_at",
+ ]
+ read_only_fields = ["id", "created_at", "updated_at"]
diff --git a/backend/server/integrations/tests.py b/backend/server/integrations/tests.py
index 7ce503c2..00b1fd63 100644
--- a/backend/server/integrations/tests.py
+++ b/backend/server/integrations/tests.py
@@ -1,3 +1,53 @@
-from django.test import TestCase
+from unittest.mock import patch
-# Create your tests here.
+from django.contrib.auth import get_user_model
+from django.test import override_settings
+from rest_framework.test import APITestCase
+
+
+User = get_user_model()
+
+
+class UserAPIKeyConfigurationTests(APITestCase):
+ def setUp(self):
+ self.user = User.objects.create_user(
+ username="api-key-user",
+ email="apikey@example.com",
+ password="password123",
+ )
+ self.client.force_authenticate(user=self.user)
+
+ @override_settings(FIELD_ENCRYPTION_KEY="")
+ def test_api_key_endpoint_missing_encryption_key_is_graceful(self):
+ response = self.client.get("/api/integrations/api-keys/")
+ self.assertEqual(response.status_code, 503)
+ self.assertIn("FIELD_ENCRYPTION_KEY", response.json().get("detail", ""))
+
+ @override_settings(FIELD_ENCRYPTION_KEY="invalid-key")
+ def test_api_key_endpoint_invalid_encryption_key_is_graceful(self):
+ response = self.client.get("/api/integrations/api-keys/")
+ self.assertEqual(response.status_code, 503)
+ self.assertIn("invalid", response.json().get("detail", "").lower())
+
+ @override_settings(FIELD_ENCRYPTION_KEY="")
+ @patch("adventures.views.recommendations_view.requests.get")
+ def test_google_photo_uses_graceful_fallback_when_user_key_unreadable(
+ self, mock_requests_get
+ ):
+ from integrations.models import UserAPIKey
+
+ # Legacy/bad row exists but cannot be decrypted due to missing key.
+ UserAPIKey.objects.create(
+ user=self.user,
+ provider="google_maps",
+ encrypted_api_key="not-a-valid-fernet-token",
+ )
+
+ response = self.client.get(
+ "/api/recommendations/google-photo/?photo_name=places/abc/photos/def"
+ )
+
+ # Should fail gracefully as misconfigured key path, not crash (500).
+ self.assertEqual(response.status_code, 400)
+ self.assertIn("not configured", response.json().get("error", "").lower())
+ mock_requests_get.assert_not_called()
diff --git a/backend/server/integrations/urls.py b/backend/server/integrations/urls.py
index b3994dbe..181bebca 100644
--- a/backend/server/integrations/urls.py
+++ b/backend/server/integrations/urls.py
@@ -1,15 +1,27 @@
from integrations.views import *
from django.urls import path, include
from rest_framework.routers import DefaultRouter
-from integrations.views import IntegrationView, StravaIntegrationView, WandererIntegrationViewSet
+from integrations.views import (
+ IntegrationView,
+ StravaIntegrationView,
+ WandererIntegrationViewSet,
+ UserAPIKeyViewSet,
+ UserRecommendationPreferenceProfileViewSet,
+)
# Create the router and register the ViewSet
router = DefaultRouter()
-router.register(r'immich', ImmichIntegrationView, basename='immich')
-router.register(r'', IntegrationView, basename='integrations')
-router.register(r'immich', ImmichIntegrationViewSet, basename='immich_viewset')
-router.register(r'strava', StravaIntegrationView, basename='strava')
-router.register(r'wanderer', WandererIntegrationViewSet, basename='wanderer')
+router.register(r"immich", ImmichIntegrationView, basename="immich")
+router.register(r"", IntegrationView, basename="integrations")
+router.register(r"immich", ImmichIntegrationViewSet, basename="immich_viewset")
+router.register(r"strava", StravaIntegrationView, basename="strava")
+router.register(r"wanderer", WandererIntegrationViewSet, basename="wanderer")
+router.register(r"api-keys", UserAPIKeyViewSet, basename="user-api-keys")
+router.register(
+ r"recommendation-preferences",
+ UserRecommendationPreferenceProfileViewSet,
+ basename="user-recommendation-preferences",
+)
# Include the router URLs
urlpatterns = [
diff --git a/backend/server/integrations/views/__init__.py b/backend/server/integrations/views/__init__.py
index 9c727de4..f757b06d 100644
--- a/backend/server/integrations/views/__init__.py
+++ b/backend/server/integrations/views/__init__.py
@@ -1,4 +1,6 @@
from .immich_view import ImmichIntegrationView, ImmichIntegrationViewSet
from .integration_view import IntegrationView
from .strava_view import StravaIntegrationView
-from .wanderer_view import WandererIntegrationViewSet
\ No newline at end of file
+from .wanderer_view import WandererIntegrationViewSet
+from .user_api_key_view import UserAPIKeyViewSet
+from .recommendation_profile_view import UserRecommendationPreferenceProfileViewSet
diff --git a/backend/server/integrations/views/integration_view.py b/backend/server/integrations/views/integration_view.py
index 7a391bdc..c8c3c810 100644
--- a/backend/server/integrations/views/integration_view.py
+++ b/backend/server/integrations/views/integration_view.py
@@ -3,40 +3,77 @@ from rest_framework.response import Response
from rest_framework import viewsets, status
from rest_framework.permissions import IsAuthenticated
from django.utils import timezone
-from integrations.models import ImmichIntegration, StravaToken, WandererIntegration
+from integrations.models import (
+ EncryptionConfigurationError,
+ ImmichIntegration,
+ StravaToken,
+ WandererIntegration,
+ UserAPIKey,
+ get_field_fernet,
+)
from django.conf import settings
class IntegrationView(viewsets.ViewSet):
permission_classes = [IsAuthenticated]
+
def list(self, request):
"""
RESTful GET method for listing all integrations.
"""
immich_integrations = ImmichIntegration.objects.filter(user=request.user)
- google_map_integration = settings.GOOGLE_MAPS_API_KEY != ''
- strava_integration_global = settings.STRAVA_CLIENT_ID != '' and settings.STRAVA_CLIENT_SECRET != ''
+ google_map_integration = (
+ settings.GOOGLE_MAPS_API_KEY != ""
+ or UserAPIKey.objects.filter(
+ user=request.user,
+ provider="google_maps",
+ ).exists()
+ )
+ strava_integration_global = (
+ settings.STRAVA_CLIENT_ID != "" and settings.STRAVA_CLIENT_SECRET != ""
+ )
strava_integration_user = StravaToken.objects.filter(user=request.user).exists()
- wanderer_integration = WandererIntegration.objects.filter(user=request.user).exists()
+ wanderer_integration = WandererIntegration.objects.filter(
+ user=request.user
+ ).exists()
is_wanderer_expired = False
if wanderer_integration:
- token_expiry = WandererIntegration.objects.filter(user=request.user).first().token_expiry
+ token_expiry = (
+ WandererIntegration.objects.filter(user=request.user)
+ .first()
+ .token_expiry
+ )
if token_expiry and token_expiry < timezone.now():
is_wanderer_expired = True
+ api_key_status = {
+ "enabled": UserAPIKey.objects.filter(user=request.user).exists(),
+ "available": True,
+ "error": None,
+ }
+ try:
+ get_field_fernet()
+ except EncryptionConfigurationError as exc:
+ api_key_status = {
+ "enabled": False,
+ "available": False,
+ "error": str(exc),
+ }
+
return Response(
{
- 'immich': immich_integrations.exists(),
- 'google_maps': google_map_integration,
- 'strava': {
- 'global': strava_integration_global,
- 'user': strava_integration_user
+ "immich": immich_integrations.exists(),
+ "google_maps": google_map_integration,
+ "api_keys": api_key_status,
+ "strava": {
+ "global": strava_integration_global,
+ "user": strava_integration_user,
+ },
+ "wanderer": {
+ "exists": wanderer_integration,
+ "expired": is_wanderer_expired,
},
- 'wanderer': {
- 'exists': wanderer_integration,
- 'expired': is_wanderer_expired
- }
},
- status=status.HTTP_200_OK
+ status=status.HTTP_200_OK,
)
diff --git a/backend/server/integrations/views/recommendation_profile_view.py b/backend/server/integrations/views/recommendation_profile_view.py
new file mode 100644
index 00000000..468a1e65
--- /dev/null
+++ b/backend/server/integrations/views/recommendation_profile_view.py
@@ -0,0 +1,43 @@
+from rest_framework import status, viewsets
+from rest_framework.permissions import IsAuthenticated
+from rest_framework.response import Response
+
+from integrations.models import UserRecommendationPreferenceProfile
+from integrations.serializers import UserRecommendationPreferenceProfileSerializer
+
+
+class UserRecommendationPreferenceProfileViewSet(viewsets.ModelViewSet):
+ serializer_class = UserRecommendationPreferenceProfileSerializer
+ permission_classes = [IsAuthenticated]
+
+ def get_queryset(self):
+ return UserRecommendationPreferenceProfile.objects.filter(
+ user=self.request.user
+ )
+
+ def list(self, request, *args, **kwargs):
+ instance = self.get_queryset().first()
+ if not instance:
+ return Response([], status=status.HTTP_200_OK)
+ serializer = self.get_serializer(instance)
+ return Response([serializer.data], status=status.HTTP_200_OK)
+
+ def perform_create(self, serializer):
+ existing = UserRecommendationPreferenceProfile.objects.filter(
+ user=self.request.user
+ ).first()
+ if existing:
+ for field, value in serializer.validated_data.items():
+ setattr(existing, field, value)
+ existing.save()
+ self._upserted_instance = existing
+ return
+
+ self._upserted_instance = serializer.save(user=self.request.user)
+
+ def create(self, request, *args, **kwargs):
+ serializer = self.get_serializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+ self.perform_create(serializer)
+ output = self.get_serializer(self._upserted_instance)
+ return Response(output.data, status=status.HTTP_200_OK)
diff --git a/backend/server/integrations/views/user_api_key_view.py b/backend/server/integrations/views/user_api_key_view.py
new file mode 100644
index 00000000..258b4dca
--- /dev/null
+++ b/backend/server/integrations/views/user_api_key_view.py
@@ -0,0 +1,33 @@
+from rest_framework import viewsets
+from rest_framework.exceptions import APIException
+from rest_framework.permissions import IsAuthenticated
+
+from integrations.models import (
+ EncryptionConfigurationError,
+ UserAPIKey,
+ get_field_fernet,
+)
+from integrations.serializers import UserAPIKeySerializer
+
+
+class APIKeyConfigurationError(APIException):
+ status_code = 503
+ default_detail = (
+ "API key storage is unavailable due to server encryption configuration."
+ )
+ default_code = "api_key_encryption_unavailable"
+
+
+class UserAPIKeyViewSet(viewsets.ModelViewSet):
+ serializer_class = UserAPIKeySerializer
+ permission_classes = [IsAuthenticated]
+
+ def initial(self, request, *args, **kwargs):
+ try:
+ get_field_fernet()
+ except EncryptionConfigurationError as exc:
+ raise APIKeyConfigurationError(detail=str(exc)) from exc
+ return super().initial(request, *args, **kwargs)
+
+ def get_queryset(self):
+ return UserAPIKey.objects.filter(user=self.request.user).order_by("provider")
diff --git a/backend/server/main/settings.py b/backend/server/main/settings.py
index 8f9ce25b..fdd79d4d 100644
--- a/backend/server/main/settings.py
+++ b/backend/server/main/settings.py
@@ -28,88 +28,88 @@ BASE_DIR = os.path.dirname(os.path.dirname(__file__))
# Core Security & Debug
# ---------------------------------------------------------------------------
# SECURITY WARNING: keep the secret key used in production secret!
-SECRET_KEY = getenv('SECRET_KEY')
+SECRET_KEY = getenv("SECRET_KEY")
# SECURITY WARNING: don't run with debug turned on in production!
-DEBUG = getenv('DEBUG', 'true').lower() == 'true'
+DEBUG = getenv("DEBUG", "true").lower() == "true"
# ALLOWED_HOSTS = [
# 'localhost',
# '127.0.0.1',
# 'server'
# ]
-ALLOWED_HOSTS = ['*'] # In production, restrict to known hosts.
+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',
- 'django.contrib.gis',
+ "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",
+ "mcp_server",
+ "django.contrib.gis",
# 'achievements', # Not done yet, will be added later in a future update
- 'widget_tweaks',
- 'slippers',
-
+ "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',
+ "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
+ "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'
+ROOT_URLCONF = "main.urls"
# WSGI_APPLICATION = 'demo.wsgi.application'
@@ -118,6 +118,7 @@ ROOT_URLCONF = 'main.urls'
# ---------------------------------------------------------------------------
# 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:
@@ -126,16 +127,17 @@ def env(*keys, default=None):
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
+ "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
},
}
}
@@ -146,8 +148,8 @@ DATABASES = {
# ---------------------------------------------------------------------------
# Internationalization
# ---------------------------------------------------------------------------
-LANGUAGE_CODE = 'en-us'
-TIME_ZONE = 'UTC'
+LANGUAGE_CODE = "en-us"
+TIME_ZONE = "UTC"
USE_I18N = True
USE_L10N = True
USE_TZ = True
@@ -156,20 +158,20 @@ 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('', '', '\'"'))
+unParsedFrontenedUrl = getenv("FRONTEND_URL", "http://localhost:3000")
+FRONTEND_URL = unParsedFrontenedUrl.translate(str.maketrans("", "", "'\""))
-SESSION_COOKIE_SAMESITE = 'Lax'
-SESSION_COOKIE_NAME = 'sessionid'
+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')
+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")
+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
@@ -181,15 +183,15 @@ else:
# ---------------------------------------------------------------------------
# Static & Media Files
# ---------------------------------------------------------------------------
-SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
+SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
BASE_DIR = Path(__file__).resolve().parent.parent
STATIC_ROOT = BASE_DIR / "staticfiles"
-STATIC_URL = '/static/'
+STATIC_URL = "/static/"
-MEDIA_URL = '/media/'
-MEDIA_ROOT = BASE_DIR / 'media' # Must match NGINX root for media serving
-STATICFILES_DIRS = [BASE_DIR / 'static']
+MEDIA_URL = "/media/"
+MEDIA_ROOT = BASE_DIR / "media" # Must match NGINX root for media serving
+STATICFILES_DIRS = [BASE_DIR / "static"]
STORAGES = {
"staticfiles": {
@@ -197,7 +199,7 @@ STORAGES = {
},
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
- }
+ },
}
SILENCED_SYSTEM_CHECKS = ["slippers.E001"]
@@ -207,15 +209,17 @@ SILENCED_SYSTEM_CHECKS = ["slippers.E001"]
# ---------------------------------------------------------------------------
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',
+ "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",
],
},
},
@@ -226,24 +230,29 @@ 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.')
+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'
+SOCIALACCOUNT_ALLOW_SIGNUP = (
+ getenv("SOCIALACCOUNT_ALLOW_SIGNUP", "false").lower() == "true"
+)
-AUTH_USER_MODEL = 'users.CustomUser'
-ACCOUNT_ADAPTER = 'users.adapters.CustomAccountAdapter'
+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'
+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_INVITE_FORM = "users.form_overrides.UseAdminInviteForm"
INVITATIONS_SIGNUP_REDIRECT_URL = f"{FRONTEND_URL}/signup"
HEADLESS_FRONTEND_URLS = {
@@ -256,34 +265,38 @@ HEADLESS_FRONTEND_URLS = {
}
AUTHENTICATION_BACKENDS = [
- 'users.backends.NoPasswordAuthBackend',
+ "users.backends.NoPasswordAuthBackend",
# 'allauth.account.auth_backends.AuthenticationBackend',
# 'django.contrib.auth.backends.ModelBackend',
]
-EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
+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'
+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.
+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'
+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 = 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'
@@ -299,63 +312,71 @@ else:
# Django REST Framework
# ---------------------------------------------------------------------------
REST_FRAMEWORK = {
- 'DEFAULT_AUTHENTICATION_CLASSES': (
- 'rest_framework.authentication.SessionAuthentication',
+ "DEFAULT_AUTHENTICATION_CLASSES": (
+ "rest_framework.authentication.SessionAuthentication",
),
- 'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema',
- 'DEFAULT_THROTTLE_CLASSES': [
- 'rest_framework.throttling.UserRateThrottle',
+ "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',
+ "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',
+ REST_FRAMEWORK["DEFAULT_RENDERER_CLASSES"] = (
+ "rest_framework.renderers.JSONRenderer",
+ "rest_framework.renderers.BrowsableAPIRenderer",
)
else:
- REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'] = (
- 'rest_framework.renderers.JSONRenderer',
+ 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_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'
+DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
LOGGING = {
- 'version': 1,
- 'disable_existing_loggers': False,
- 'handlers': {
- 'console': {
- 'class': 'logging.StreamHandler',
+ "version": 1,
+ "disable_existing_loggers": False,
+ "handlers": {
+ "console": {
+ "class": "logging.StreamHandler",
},
- 'file': {
- 'class': 'logging.FileHandler',
- 'filename': 'scheduler.log',
+ "file": {
+ "class": "logging.FileHandler",
+ "filename": "scheduler.log",
},
},
- 'root': {
- 'handlers': ['console', 'file'],
- 'level': 'INFO',
+ "root": {
+ "handlers": ["console", "file"],
+ "level": "INFO",
},
- 'loggers': {
- 'django': {
- 'handlers': ['console', 'file'],
- 'level': 'INFO',
- 'propagate': False,
+ "loggers": {
+ "django": {
+ "handlers": ["console", "file"],
+ "level": "INFO",
+ "propagate": False,
},
},
}
@@ -363,18 +384,28 @@ LOGGING = {
# ---------------------------------------------------------------------------
# Public URLs & Third-Party Integrations
# ---------------------------------------------------------------------------
-PUBLIC_URL = getenv('PUBLIC_URL', 'http://localhost:8000')
+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'
+VOYAGE_RELEASE_VERSION = "v0.12.0"
# https://github.com/dr5hn/countries-states-cities-database/tags
-COUNTRY_REGION_JSON_VERSION = 'v3.0'
+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')
\ No newline at end of file
+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", "")
+
+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",
+]
diff --git a/backend/server/main/urls.py b/backend/server/main/urls.py
index bc87bbe4..2a45622a 100644
--- a/backend/server/main/urls.py
+++ b/backend/server/main/urls.py
@@ -1,52 +1,100 @@
from django.urls import include, re_path, path
from django.contrib import admin
from django.views.generic import RedirectView, TemplateView
-from users.views import IsRegistrationDisabled, PublicUserListView, PublicUserDetailView, UserMetadataView, UpdateUserMetadataView, EnabledSocialProvidersView, DisablePasswordAuthenticationView
+from users.views import (
+ IsRegistrationDisabled,
+ PublicUserListView,
+ PublicUserDetailView,
+ UserMetadataView,
+ UpdateUserMetadataView,
+ EnabledSocialProvidersView,
+ DisablePasswordAuthenticationView,
+)
from .views import get_csrf_token, get_public_url, serve_protected_media
from drf_yasg.views import get_schema_view
from drf_yasg import openapi
+from mcp_server.views import MCPServerStreamableHttpView
+from django.conf import settings
+from django.utils.module_loading import import_string
schema_view = get_schema_view(
openapi.Info(
- title='API Docs',
- default_version='v1',
+ title="API Docs",
+ default_version="v1",
)
)
urlpatterns = [
- path('api/', include('adventures.urls')),
- path('api/', include('worldtravel.urls')),
+ path("api/", include("adventures.urls")),
+ path("api/", include("worldtravel.urls")),
+ path(
+ getattr(settings, "DJANGO_MCP_ENDPOINT", "api/mcp"),
+ MCPServerStreamableHttpView.as_view(
+ permission_classes=[
+ import_string(cls)
+ for cls in getattr(
+ settings,
+ "DJANGO_MCP_ENDPOINT_PERMISSION_CLASSES",
+ ["rest_framework.permissions.IsAuthenticated"],
+ )
+ ],
+ authentication_classes=[
+ import_string(cls)
+ for cls in getattr(
+ settings,
+ "DJANGO_MCP_AUTHENTICATION_CLASSES",
+ ["rest_framework.authentication.TokenAuthentication"],
+ )
+ ],
+ ),
+ name="mcp_server_streamable_http_endpoint",
+ ),
path("auth/", include("allauth.headless.urls")),
-
# Serve protected media files
- re_path(r'^media/(?P