feat: ship MVP itinerary optimization, weather, AI key prefs, and MCP tools

This commit is contained in:
2026-03-08 13:49:32 +00:00
parent 9eb0325c7a
commit 8c0637c518
25 changed files with 1888 additions and 511 deletions

View File

@@ -1,11 +1,45 @@
from django.contrib import admin
from allauth.account.decorators import secure_admin_login
from django.utils.html import format_html
from .models import ImmichIntegration, StravaToken, WandererIntegration
from .models import (
ImmichIntegration,
StravaToken,
WandererIntegration,
UserAPIKey,
UserRecommendationPreferenceProfile,
)
admin.autodiscover()
admin.site.login = secure_admin_login(admin.site.login)
admin.site.register(ImmichIntegration)
admin.site.register(StravaToken)
admin.site.register(WandererIntegration)
admin.site.register(WandererIntegration)
@admin.register(UserAPIKey)
class UserAPIKeyAdmin(admin.ModelAdmin):
list_display = ("user", "provider", "masked_value", "updated_at")
search_fields = ("user__username", "provider")
readonly_fields = (
"user",
"provider",
"masked_value",
"created_at",
"updated_at",
)
exclude = ("encrypted_api_key",)
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
@admin.display(description="API key")
def masked_value(self, obj):
return format_html("<code>{}</code>", obj.masked_api_key)
admin.site.register(UserRecommendationPreferenceProfile)

View File

@@ -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")},
},
),
]

View File

@@ -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"
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)

View File

@@ -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"]

View File

@@ -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()

View File

@@ -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 = [

View File

@@ -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
from .wanderer_view import WandererIntegrationViewSet
from .user_api_key_view import UserAPIKeyViewSet
from .recommendation_profile_view import UserRecommendationPreferenceProfileViewSet

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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")