fix: stabilize post-MVP travel-agent and itinerary workflows
This commit is contained in:
@@ -29,6 +29,9 @@ BACKEND_PORT=8016
|
|||||||
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
# FIELD_ENCRYPTION_KEY=replace_with_fernet_key
|
# FIELD_ENCRYPTION_KEY=replace_with_fernet_key
|
||||||
|
|
||||||
|
# Optional: custom MCP HTTP endpoint path (default: api/mcp)
|
||||||
|
# DJANGO_MCP_ENDPOINT=api/mcp
|
||||||
|
|
||||||
# Optional: disable registration
|
# Optional: disable registration
|
||||||
# https://adventurelog.app/docs/configuration/disable_registration.html
|
# https://adventurelog.app/docs/configuration/disable_registration.html
|
||||||
DISABLE_REGISTRATION=False
|
DISABLE_REGISTRATION=False
|
||||||
|
|||||||
24
README.md
24
README.md
@@ -108,6 +108,14 @@ Voyage aims to be simple, beautiful, and open to everyone — inheriting Adventu
|
|||||||
- Collaborators can view and edit shared itineraries (collections), making planning a breeze.
|
- Collaborators can view and edit shared itineraries (collections), making planning a breeze.
|
||||||
- **Customizable Themes** 🎨: Choose from 10 built-in themes including Light, Dark, Dim, Night, Forest, Aqua, Catppuccin Mocha, Aesthetic Light, Aesthetic Dark, and Northern Lights. Theme selection persists across sessions.
|
- **Customizable Themes** 🎨: Choose from 10 built-in themes including Light, Dark, Dim, Night, Forest, Aqua, Catppuccin Mocha, Aesthetic Light, Aesthetic Dark, and Northern Lights. Theme selection persists across sessions.
|
||||||
|
|
||||||
|
### Travel Agent (MCP)
|
||||||
|
|
||||||
|
Voyage provides an authenticated Travel Agent MCP endpoint for programmatic itinerary workflows (list collections, inspect itinerary details, create items, reorder timelines). See the guide: [`documentation/docs/guides/travel_agent.md`](documentation/docs/guides/travel_agent.md).
|
||||||
|
|
||||||
|
- Default MCP path: `api/mcp`
|
||||||
|
- Override MCP path with env var: `DJANGO_MCP_ENDPOINT`
|
||||||
|
- Get token from authenticated session: `GET /auth/mcp-token/` and use header `Authorization: Token <token>`
|
||||||
|
|
||||||
<!-- Roadmap -->
|
<!-- Roadmap -->
|
||||||
|
|
||||||
## 🧭 Roadmap
|
## 🧭 Roadmap
|
||||||
@@ -126,6 +134,22 @@ Contributions are always welcome!
|
|||||||
|
|
||||||
See `contributing.md` for ways to get started.
|
See `contributing.md` for ways to get started.
|
||||||
|
|
||||||
|
### Pre-upgrade backup
|
||||||
|
|
||||||
|
Before upgrading Voyage or running migrations, export a collections backup snapshot:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec server python manage.py export_collections_backup
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional custom output path:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec server python manage.py export_collections_backup --output /code/backups/collections_backup_pre_upgrade.json
|
||||||
|
```
|
||||||
|
|
||||||
|
This command exports `Collection` and `CollectionItineraryItem` data with timestamp and counts.
|
||||||
|
|
||||||
### Translation
|
### Translation
|
||||||
|
|
||||||
Voyage is available on [Weblate](https://hosted.weblate.org/projects/voyage/). If you would like to help translate Voyage into your language, please visit the link and contribute!
|
Voyage is available on [Weblate](https://hosted.weblate.org/projects/voyage/). If you would like to help translate Voyage into your language, please visit the link and contribute!
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ EMAIL_BACKEND='console'
|
|||||||
|
|
||||||
# GOOGLE_MAPS_API_KEY='key'
|
# GOOGLE_MAPS_API_KEY='key'
|
||||||
# OSRM_BASE_URL='https://router.project-osrm.org' # replace with self-host URL if needed (e.g. http://osrm:5000)
|
# OSRM_BASE_URL='https://router.project-osrm.org' # replace with self-host URL if needed (e.g. http://osrm:5000)
|
||||||
|
# DJANGO_MCP_ENDPOINT='api/mcp' # optional custom MCP HTTP endpoint path
|
||||||
|
|
||||||
# ACCOUNT_EMAIL_VERIFICATION='none' # 'none', 'optional', 'mandatory' # You can change this as needed for your environment
|
# ACCOUNT_EMAIL_VERIFICATION='none' # 'none', 'optional', 'mandatory' # You can change this as needed for your environment
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.core.serializers.json import DjangoJSONEncoder
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from adventures.models import Collection, CollectionItineraryItem
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = (
|
||||||
|
"Export Collection and CollectionItineraryItem data to a JSON backup "
|
||||||
|
"file before upgrades/migrations."
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
type=str,
|
||||||
|
help="Optional output file path (default: ./collections_backup_<timestamp>.json)",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
backup_timestamp = timezone.now()
|
||||||
|
timestamp = backup_timestamp.strftime("%Y%m%d_%H%M%S")
|
||||||
|
output_path = Path(
|
||||||
|
options.get("output") or f"collections_backup_{timestamp}.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
if output_path.parent and not output_path.parent.exists():
|
||||||
|
raise CommandError(f"Output directory does not exist: {output_path.parent}")
|
||||||
|
|
||||||
|
collections = list(
|
||||||
|
Collection.objects.values(
|
||||||
|
"id",
|
||||||
|
"user_id",
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"is_public",
|
||||||
|
"is_archived",
|
||||||
|
"start_date",
|
||||||
|
"end_date",
|
||||||
|
"link",
|
||||||
|
"primary_image_id",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
shared_with_map = {
|
||||||
|
str(collection.id): list(
|
||||||
|
collection.shared_with.values_list("id", flat=True)
|
||||||
|
)
|
||||||
|
for collection in Collection.objects.prefetch_related("shared_with")
|
||||||
|
}
|
||||||
|
for collection in collections:
|
||||||
|
collection["shared_with_ids"] = shared_with_map.get(
|
||||||
|
str(collection["id"]), []
|
||||||
|
)
|
||||||
|
|
||||||
|
itinerary_items = list(
|
||||||
|
CollectionItineraryItem.objects.select_related("content_type").values(
|
||||||
|
"id",
|
||||||
|
"collection_id",
|
||||||
|
"content_type_id",
|
||||||
|
"content_type__app_label",
|
||||||
|
"content_type__model",
|
||||||
|
"object_id",
|
||||||
|
"date",
|
||||||
|
"is_global",
|
||||||
|
"order",
|
||||||
|
"created_at",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
backup_payload = {
|
||||||
|
"backup_type": "collections_snapshot",
|
||||||
|
"timestamp": backup_timestamp.isoformat(),
|
||||||
|
"counts": {
|
||||||
|
"collections": len(collections),
|
||||||
|
"collection_itinerary_items": len(itinerary_items),
|
||||||
|
},
|
||||||
|
"collections": collections,
|
||||||
|
"collection_itinerary_items": itinerary_items,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with output_path.open("w", encoding="utf-8") as backup_file:
|
||||||
|
json.dump(backup_payload, backup_file, indent=2, cls=DjangoJSONEncoder)
|
||||||
|
except OSError as exc:
|
||||||
|
raise CommandError(f"Failed to write backup file: {exc}") from exc
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise CommandError(f"Failed to serialize backup data: {exc}") from exc
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
"Exported collections backup to "
|
||||||
|
f"{output_path} "
|
||||||
|
f"at {backup_timestamp.isoformat()} "
|
||||||
|
f"(collections: {len(collections)}, "
|
||||||
|
f"itinerary_items: {len(itinerary_items)})."
|
||||||
|
)
|
||||||
|
)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,34 @@
|
|||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import base64
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from pathlib import Path
|
||||||
from unittest.mock import Mock, patch
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
from django.core.management import call_command
|
||||||
|
from django.core.management.base import CommandError
|
||||||
|
from django.test import TestCase
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework.test import APIClient, APITestCase
|
from rest_framework.test import APIClient, APITestCase
|
||||||
|
|
||||||
|
from adventures.models import (
|
||||||
|
Collection,
|
||||||
|
CollectionItineraryItem,
|
||||||
|
ContentImage,
|
||||||
|
Lodging,
|
||||||
|
Note,
|
||||||
|
Transportation,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
class WeatherEndpointTests(APITestCase):
|
class WeatherViewTests(APITestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.user = User.objects.create_user(
|
self.user = User.objects.create_user(
|
||||||
username="weather-user",
|
username="weather-user",
|
||||||
@@ -35,11 +53,38 @@ class WeatherEndpointTests(APITestCase):
|
|||||||
self.assertEqual(response.status_code, 400)
|
self.assertEqual(response.status_code, 400)
|
||||||
self.assertIn("maximum", response.json().get("error", "").lower())
|
self.assertIn("maximum", response.json().get("error", "").lower())
|
||||||
|
|
||||||
@patch("adventures.views.weather_view.requests.get")
|
@patch("adventures.views.weather_view.WeatherViewSet._fetch_daily_temperature")
|
||||||
def test_daily_temperatures_future_date_returns_unavailable_without_external_call(
|
def test_daily_temperatures_future_date_reaches_fetch_path(
|
||||||
self, mock_requests_get
|
self, mock_fetch_temperature
|
||||||
):
|
):
|
||||||
future_date = (timezone.now().date() + timedelta(days=10)).isoformat()
|
future_date = (timezone.now().date() + timedelta(days=10)).isoformat()
|
||||||
|
mock_fetch_temperature.return_value = {
|
||||||
|
"date": future_date,
|
||||||
|
"available": True,
|
||||||
|
"temperature_c": 22.5,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/weather/daily-temperatures/",
|
||||||
|
{"days": [{"date": future_date, "latitude": 12.34, "longitude": 56.78}]},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json()["results"][0]["date"], future_date)
|
||||||
|
self.assertTrue(response.json()["results"][0]["available"])
|
||||||
|
self.assertEqual(response.json()["results"][0]["temperature_c"], 22.5)
|
||||||
|
mock_fetch_temperature.assert_called_once_with(future_date, 12.34, 56.78)
|
||||||
|
|
||||||
|
@patch("adventures.views.weather_view.requests.get")
|
||||||
|
def test_daily_temperatures_far_future_returns_unavailable_when_upstream_has_no_data(
|
||||||
|
self, mock_requests_get
|
||||||
|
):
|
||||||
|
future_date = (timezone.now().date() + timedelta(days=3650)).isoformat()
|
||||||
|
mocked_response = Mock()
|
||||||
|
mocked_response.raise_for_status.return_value = None
|
||||||
|
mocked_response.json.return_value = {"daily": {}}
|
||||||
|
mock_requests_get.return_value = mocked_response
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/weather/daily-temperatures/",
|
"/api/weather/daily-temperatures/",
|
||||||
@@ -52,7 +97,7 @@ class WeatherEndpointTests(APITestCase):
|
|||||||
response.json()["results"][0],
|
response.json()["results"][0],
|
||||||
{"date": future_date, "available": False, "temperature_c": None},
|
{"date": future_date, "available": False, "temperature_c": None},
|
||||||
)
|
)
|
||||||
mock_requests_get.assert_not_called()
|
self.assertEqual(mock_requests_get.call_count, 2)
|
||||||
|
|
||||||
@patch("adventures.views.weather_view.requests.get")
|
@patch("adventures.views.weather_view.requests.get")
|
||||||
def test_daily_temperatures_accepts_zero_lat_lon(self, mock_requests_get):
|
def test_daily_temperatures_accepts_zero_lat_lon(self, mock_requests_get):
|
||||||
@@ -106,3 +151,166 @@ class MCPAuthTests(APITestCase):
|
|||||||
unauthenticated_client = APIClient()
|
unauthenticated_client = APIClient()
|
||||||
response = unauthenticated_client.post("/api/mcp", {}, format="json")
|
response = unauthenticated_client.post("/api/mcp", {}, format="json")
|
||||||
self.assertIn(response.status_code, [401, 403])
|
self.assertIn(response.status_code, [401, 403])
|
||||||
|
|
||||||
|
|
||||||
|
class CollectionViewSetTests(APITestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.owner = User.objects.create_user(
|
||||||
|
username="collection-owner",
|
||||||
|
email="owner@example.com",
|
||||||
|
password="password123",
|
||||||
|
)
|
||||||
|
self.shared_user = User.objects.create_user(
|
||||||
|
username="collection-shared",
|
||||||
|
email="shared@example.com",
|
||||||
|
password="password123",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _create_test_image_file(self, name="test.png"):
|
||||||
|
# 1x1 PNG
|
||||||
|
png_bytes = base64.b64decode(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7Y9x8AAAAASUVORK5CYII="
|
||||||
|
)
|
||||||
|
return SimpleUploadedFile(name, png_bytes, content_type="image/png")
|
||||||
|
|
||||||
|
def _create_collection_with_non_location_images(self):
|
||||||
|
collection = Collection.objects.create(
|
||||||
|
user=self.owner,
|
||||||
|
name="Image fallback collection",
|
||||||
|
)
|
||||||
|
|
||||||
|
lodging = Lodging.objects.create(
|
||||||
|
user=self.owner,
|
||||||
|
collection=collection,
|
||||||
|
name="Fallback lodge",
|
||||||
|
)
|
||||||
|
transportation = Transportation.objects.create(
|
||||||
|
user=self.owner,
|
||||||
|
collection=collection,
|
||||||
|
type="car",
|
||||||
|
name="Fallback ride",
|
||||||
|
)
|
||||||
|
|
||||||
|
lodging_content_type = ContentType.objects.get_for_model(Lodging)
|
||||||
|
transportation_content_type = ContentType.objects.get_for_model(Transportation)
|
||||||
|
|
||||||
|
ContentImage.objects.create(
|
||||||
|
user=self.owner,
|
||||||
|
content_type=lodging_content_type,
|
||||||
|
object_id=lodging.id,
|
||||||
|
image=self._create_test_image_file("lodging.png"),
|
||||||
|
is_primary=True,
|
||||||
|
)
|
||||||
|
ContentImage.objects.create(
|
||||||
|
user=self.owner,
|
||||||
|
content_type=transportation_content_type,
|
||||||
|
object_id=transportation.id,
|
||||||
|
image=self._create_test_image_file("transport.png"),
|
||||||
|
is_primary=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return collection
|
||||||
|
|
||||||
|
def test_list_includes_lodging_transportation_images_when_no_location_images(self):
|
||||||
|
collection = self._create_collection_with_non_location_images()
|
||||||
|
|
||||||
|
self.client.force_authenticate(user=self.owner)
|
||||||
|
response = self.client.get("/api/collections/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertGreater(len(response.data.get("results", [])), 0)
|
||||||
|
|
||||||
|
collection_payload = next(
|
||||||
|
item
|
||||||
|
for item in response.data["results"]
|
||||||
|
if item["id"] == str(collection.id)
|
||||||
|
)
|
||||||
|
self.assertIn("location_images", collection_payload)
|
||||||
|
self.assertGreater(len(collection_payload["location_images"]), 0)
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
image.get("is_primary")
|
||||||
|
for image in collection_payload["location_images"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_shared_endpoint_includes_non_location_primary_images(self):
|
||||||
|
collection = self._create_collection_with_non_location_images()
|
||||||
|
collection.shared_with.add(self.shared_user)
|
||||||
|
|
||||||
|
self.client.force_authenticate(user=self.shared_user)
|
||||||
|
response = self.client.get("/api/collections/shared/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertGreater(len(response.data), 0)
|
||||||
|
|
||||||
|
collection_payload = next(
|
||||||
|
item for item in response.data if item["id"] == str(collection.id)
|
||||||
|
)
|
||||||
|
self.assertEqual(str(collection.id), collection_payload["id"])
|
||||||
|
self.assertIn("location_images", collection_payload)
|
||||||
|
self.assertGreater(len(collection_payload["location_images"]), 0)
|
||||||
|
first_image = collection_payload["location_images"][0]
|
||||||
|
self.assertSetEqual(
|
||||||
|
set(first_image.keys()),
|
||||||
|
{"id", "image", "is_primary", "user", "immich_id"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ExportCollectionsBackupCommandTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username="backup-user",
|
||||||
|
email="backup@example.com",
|
||||||
|
password="password123",
|
||||||
|
)
|
||||||
|
self.collaborator = User.objects.create_user(
|
||||||
|
username="collab-user",
|
||||||
|
email="collab@example.com",
|
||||||
|
password="password123",
|
||||||
|
)
|
||||||
|
self.collection = Collection.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
name="My Trip",
|
||||||
|
description="Backup test collection",
|
||||||
|
)
|
||||||
|
self.collection.shared_with.add(self.collaborator)
|
||||||
|
|
||||||
|
note = Note.objects.create(user=self.user, name="Test item")
|
||||||
|
note_content_type = ContentType.objects.get_for_model(Note)
|
||||||
|
CollectionItineraryItem.objects.create(
|
||||||
|
collection=self.collection,
|
||||||
|
content_type=note_content_type,
|
||||||
|
object_id=note.id,
|
||||||
|
date=timezone.now().date(),
|
||||||
|
is_global=False,
|
||||||
|
order=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_export_collections_backup_writes_expected_payload(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
output_file = Path(temp_dir) / "collections_snapshot.json"
|
||||||
|
|
||||||
|
call_command("export_collections_backup", output=str(output_file))
|
||||||
|
|
||||||
|
self.assertTrue(output_file.exists())
|
||||||
|
payload = json.loads(output_file.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
self.assertEqual(payload["backup_type"], "collections_snapshot")
|
||||||
|
self.assertIn("timestamp", payload)
|
||||||
|
self.assertEqual(payload["counts"]["collections"], 1)
|
||||||
|
self.assertEqual(payload["counts"]["collection_itinerary_items"], 1)
|
||||||
|
self.assertEqual(len(payload["collections"]), 1)
|
||||||
|
self.assertEqual(len(payload["collection_itinerary_items"]), 1)
|
||||||
|
self.assertEqual(
|
||||||
|
payload["collections"][0]["shared_with_ids"],
|
||||||
|
[self.collaborator.id],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_export_collections_backup_raises_for_missing_output_directory(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
missing_directory = Path(temp_dir) / "missing"
|
||||||
|
output_file = missing_directory / "collections_snapshot.json"
|
||||||
|
|
||||||
|
with self.assertRaises(CommandError):
|
||||||
|
call_command("export_collections_backup", output=str(output_file))
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -60,12 +60,6 @@ class WeatherViewSet(viewsets.ViewSet):
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if parsed_date > date_cls.today():
|
|
||||||
results.append(
|
|
||||||
{"date": date, "available": False, "temperature_c": None}
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
lat = float(latitude)
|
lat = float(latitude)
|
||||||
lon = float(longitude)
|
lon = float(longitude)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from django.db import IntegrityError
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
EncryptionConfigurationError,
|
EncryptionConfigurationError,
|
||||||
ImmichIntegration,
|
ImmichIntegration,
|
||||||
@@ -41,12 +43,28 @@ class UserAPIKeySerializer(serializers.ModelSerializer):
|
|||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
api_key = validated_data.pop("api_key")
|
api_key = validated_data.pop("api_key")
|
||||||
user = self.context["request"].user
|
user = self.context["request"].user
|
||||||
instance = UserAPIKey(user=user, **validated_data)
|
|
||||||
|
provider = validated_data.get("provider")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
instance, _ = UserAPIKey.objects.get_or_create(
|
||||||
|
user=user,
|
||||||
|
provider=provider,
|
||||||
|
defaults={"encrypted_api_key": ""},
|
||||||
|
)
|
||||||
instance.set_api_key(api_key)
|
instance.set_api_key(api_key)
|
||||||
except EncryptionConfigurationError as exc:
|
except EncryptionConfigurationError as exc:
|
||||||
raise serializers.ValidationError({"api_key": str(exc)}) from exc
|
raise serializers.ValidationError({"api_key": str(exc)}) from exc
|
||||||
instance.save()
|
except IntegrityError:
|
||||||
|
# Defensive retry: in highly concurrent requests a competing create can
|
||||||
|
# still race. Fall back to updating the existing row instead of 500.
|
||||||
|
instance = UserAPIKey.objects.get(user=user, provider=provider)
|
||||||
|
try:
|
||||||
|
instance.set_api_key(api_key)
|
||||||
|
except EncryptionConfigurationError as exc:
|
||||||
|
raise serializers.ValidationError({"api_key": str(exc)}) from exc
|
||||||
|
|
||||||
|
instance.save(update_fields=["encrypted_api_key", "updated_at"])
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
|
|||||||
@@ -51,3 +51,65 @@ class UserAPIKeyConfigurationTests(APITestCase):
|
|||||||
self.assertEqual(response.status_code, 400)
|
self.assertEqual(response.status_code, 400)
|
||||||
self.assertIn("not configured", response.json().get("error", "").lower())
|
self.assertIn("not configured", response.json().get("error", "").lower())
|
||||||
mock_requests_get.assert_not_called()
|
mock_requests_get.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class UserAPIKeyCreateBehaviorTests(APITestCase):
|
||||||
|
@override_settings(
|
||||||
|
FIELD_ENCRYPTION_KEY="YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE="
|
||||||
|
)
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username="api-key-create-user",
|
||||||
|
email="apikey-create@example.com",
|
||||||
|
password="password123",
|
||||||
|
)
|
||||||
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
FIELD_ENCRYPTION_KEY="YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE="
|
||||||
|
)
|
||||||
|
def test_duplicate_provider_post_updates_existing_key(self):
|
||||||
|
first_response = self.client.post(
|
||||||
|
"/api/integrations/api-keys/",
|
||||||
|
{"provider": "google_maps", "api_key": "first-secret"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(first_response.status_code, 201)
|
||||||
|
|
||||||
|
second_response = self.client.post(
|
||||||
|
"/api/integrations/api-keys/",
|
||||||
|
{"provider": "google_maps", "api_key": "second-secret"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(second_response.status_code, 201)
|
||||||
|
|
||||||
|
from integrations.models import UserAPIKey
|
||||||
|
|
||||||
|
records = UserAPIKey.objects.filter(user=self.user, provider="google_maps")
|
||||||
|
self.assertEqual(records.count(), 1)
|
||||||
|
self.assertEqual(records.first().get_api_key(), "second-secret")
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
FIELD_ENCRYPTION_KEY="YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE="
|
||||||
|
)
|
||||||
|
def test_provider_is_normalized_and_still_upserts(self):
|
||||||
|
self.client.post(
|
||||||
|
"/api/integrations/api-keys/",
|
||||||
|
{"provider": "Google_Maps", "api_key": "first-secret"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/integrations/api-keys/",
|
||||||
|
{"provider": " google_maps ", "api_key": "rotated-secret"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 201)
|
||||||
|
|
||||||
|
from integrations.models import UserAPIKey
|
||||||
|
|
||||||
|
records = UserAPIKey.objects.filter(user=self.user, provider="google_maps")
|
||||||
|
self.assertEqual(records.count(), 1)
|
||||||
|
self.assertEqual(records.first().get_api_key(), "rotated-secret")
|
||||||
|
|||||||
38
backend/server/main/tests.py
Normal file
38
backend/server/main/tests.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from rest_framework.authtoken.models import Token
|
||||||
|
from rest_framework.test import APIClient, APITestCase
|
||||||
|
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class MCPTokenEndpointTests(APITestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username="mcp-token-user",
|
||||||
|
email="mcp-token@example.com",
|
||||||
|
password="password123",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_requires_authentication(self):
|
||||||
|
unauthenticated_client = APIClient()
|
||||||
|
response = unauthenticated_client.get("/auth/mcp-token/")
|
||||||
|
self.assertIn(response.status_code, [401, 403])
|
||||||
|
|
||||||
|
def test_returns_token_for_authenticated_user(self):
|
||||||
|
self.client.force_authenticate(user=self.user)
|
||||||
|
response = self.client.get("/auth/mcp-token/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn("token", response.json())
|
||||||
|
self.assertTrue(Token.objects.filter(user=self.user).exists())
|
||||||
|
|
||||||
|
def test_reuses_existing_token(self):
|
||||||
|
existing_token = Token.objects.create(user=self.user)
|
||||||
|
|
||||||
|
self.client.force_authenticate(user=self.user)
|
||||||
|
response = self.client.get("/auth/mcp-token/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json().get("token"), existing_token.key)
|
||||||
|
self.assertEqual(Token.objects.filter(user=self.user).count(), 1)
|
||||||
@@ -10,7 +10,12 @@ from users.views import (
|
|||||||
EnabledSocialProvidersView,
|
EnabledSocialProvidersView,
|
||||||
DisablePasswordAuthenticationView,
|
DisablePasswordAuthenticationView,
|
||||||
)
|
)
|
||||||
from .views import get_csrf_token, get_public_url, serve_protected_media
|
from .views import (
|
||||||
|
get_csrf_token,
|
||||||
|
get_mcp_api_token,
|
||||||
|
get_public_url,
|
||||||
|
serve_protected_media,
|
||||||
|
)
|
||||||
from drf_yasg.views import get_schema_view
|
from drf_yasg.views import get_schema_view
|
||||||
from drf_yasg import openapi
|
from drf_yasg import openapi
|
||||||
from mcp_server.views import MCPServerStreamableHttpView
|
from mcp_server.views import MCPServerStreamableHttpView
|
||||||
@@ -48,6 +53,7 @@ urlpatterns = [
|
|||||||
),
|
),
|
||||||
name="mcp_server_streamable_http_endpoint",
|
name="mcp_server_streamable_http_endpoint",
|
||||||
),
|
),
|
||||||
|
path("auth/mcp-token/", get_mcp_api_token, name="get_mcp_api_token"),
|
||||||
path("auth/", include("allauth.headless.urls")),
|
path("auth/", include("allauth.headless.urls")),
|
||||||
# Serve protected media files
|
# Serve protected media files
|
||||||
re_path(
|
re_path(
|
||||||
|
|||||||
@@ -5,21 +5,42 @@ from django.conf import settings
|
|||||||
from django.http import HttpResponse, HttpResponseForbidden
|
from django.http import HttpResponse, HttpResponseForbidden
|
||||||
from django.views.static import serve
|
from django.views.static import serve
|
||||||
from adventures.utils.file_permissions import checkFilePermission
|
from adventures.utils.file_permissions import checkFilePermission
|
||||||
|
from rest_framework.authentication import SessionAuthentication, TokenAuthentication
|
||||||
|
from rest_framework.authtoken.models import Token
|
||||||
|
from rest_framework.decorators import (
|
||||||
|
api_view,
|
||||||
|
authentication_classes,
|
||||||
|
permission_classes,
|
||||||
|
)
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
|
||||||
def get_csrf_token(request):
|
def get_csrf_token(request):
|
||||||
csrf_token = get_token(request)
|
csrf_token = get_token(request)
|
||||||
return JsonResponse({'csrfToken': csrf_token})
|
return JsonResponse({"csrfToken": csrf_token})
|
||||||
|
|
||||||
|
|
||||||
def get_public_url(request):
|
def get_public_url(request):
|
||||||
return JsonResponse({'PUBLIC_URL': getenv('PUBLIC_URL')})
|
return JsonResponse({"PUBLIC_URL": getenv("PUBLIC_URL")})
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(["GET"])
|
||||||
|
@authentication_classes([SessionAuthentication, TokenAuthentication])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def get_mcp_api_token(request):
|
||||||
|
token, _ = Token.objects.get_or_create(user=request.user)
|
||||||
|
return Response({"token": token.key})
|
||||||
|
|
||||||
|
|
||||||
|
protected_paths = ["images/", "attachments/"]
|
||||||
|
|
||||||
protected_paths = ['images/', 'attachments/']
|
|
||||||
|
|
||||||
def serve_protected_media(request, path):
|
def serve_protected_media(request, path):
|
||||||
if any([path.startswith(protected_path) for protected_path in protected_paths]):
|
if any([path.startswith(protected_path) for protected_path in protected_paths]):
|
||||||
image_id = path.split('/')[1]
|
image_id = path.split("/")[1]
|
||||||
user = request.user
|
user = request.user
|
||||||
media_type = path.split('/')[0] + '/'
|
media_type = path.split("/")[0] + "/"
|
||||||
if checkFilePermission(image_id, user, media_type):
|
if checkFilePermission(image_id, user, media_type):
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
# In debug mode, serve the file directly
|
# In debug mode, serve the file directly
|
||||||
@@ -27,8 +48,8 @@ def serve_protected_media(request, path):
|
|||||||
else:
|
else:
|
||||||
# In production, use X-Accel-Redirect to serve the file using Nginx
|
# In production, use X-Accel-Redirect to serve the file using Nginx
|
||||||
response = HttpResponse()
|
response = HttpResponse()
|
||||||
response['Content-Type'] = ''
|
response["Content-Type"] = ""
|
||||||
response['X-Accel-Redirect'] = '/protectedMedia/' + path
|
response["X-Accel-Redirect"] = "/protectedMedia/" + path
|
||||||
return response
|
return response
|
||||||
else:
|
else:
|
||||||
return HttpResponseForbidden()
|
return HttpResponseForbidden()
|
||||||
@@ -37,6 +58,6 @@ def serve_protected_media(request, path):
|
|||||||
return serve(request, path, document_root=settings.MEDIA_ROOT)
|
return serve(request, path, document_root=settings.MEDIA_ROOT)
|
||||||
else:
|
else:
|
||||||
response = HttpResponse()
|
response = HttpResponse()
|
||||||
response['Content-Type'] = ''
|
response["Content-Type"] = ""
|
||||||
response['X-Accel-Redirect'] = '/protectedMedia/' + path
|
response["X-Accel-Redirect"] = "/protectedMedia/" + path
|
||||||
return response
|
return response
|
||||||
|
|||||||
@@ -255,6 +255,10 @@ export default defineConfig({
|
|||||||
text: "Guides",
|
text: "Guides",
|
||||||
collapsed: true,
|
collapsed: true,
|
||||||
items: [
|
items: [
|
||||||
|
{
|
||||||
|
text: "Travel Agent (MCP)",
|
||||||
|
link: "/docs/guides/travel_agent",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
text: "Admin Panel",
|
text: "Admin Panel",
|
||||||
link: "/docs/guides/admin_panel",
|
link: "/docs/guides/admin_panel",
|
||||||
|
|||||||
@@ -9,4 +9,15 @@ In addition to the primary configuration variables listed above, there are sever
|
|||||||
| `SOCIALACCOUNT_ALLOW_SIGNUP` | No | When set to `True`, signup will be allowed via social providers even if registration is disabled. | `False` | Backend |
|
| `SOCIALACCOUNT_ALLOW_SIGNUP` | No | When set to `True`, signup will be allowed via social providers even if registration is disabled. | `False` | Backend |
|
||||||
| `OSRM_BASE_URL` | No | Base URL of the OSRM routing server used for itinerary connector distance/travel-time metrics. The public OSRM demo server is used by default. Set this to point at your own OSRM instance (e.g. `http://osrm:5000`) for higher rate limits or offline use. When the OSRM server is unreachable, the backend automatically falls back to haversine-based approximations so the itinerary UI always shows metrics. | `https://router.project-osrm.org` | Backend |
|
| `OSRM_BASE_URL` | No | Base URL of the OSRM routing server used for itinerary connector distance/travel-time metrics. The public OSRM demo server is used by default. Set this to point at your own OSRM instance (e.g. `http://osrm:5000`) for higher rate limits or offline use. When the OSRM server is unreachable, the backend automatically falls back to haversine-based approximations so the itinerary UI always shows metrics. | `https://router.project-osrm.org` | Backend |
|
||||||
| `FIELD_ENCRYPTION_KEY` | No* | Fernet key used to encrypt user API keys at rest (integrations API key storage). Generate a 32-byte urlsafe base64 key (e.g. `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`). If missing/invalid, only API-key storage endpoints fail gracefully and the rest of the app remains available. | _(none)_ | Backend |
|
| `FIELD_ENCRYPTION_KEY` | No* | Fernet key used to encrypt user API keys at rest (integrations API key storage). Generate a 32-byte urlsafe base64 key (e.g. `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`). If missing/invalid, only API-key storage endpoints fail gracefully and the rest of the app remains available. | _(none)_ | Backend |
|
||||||
| `DJANGO_MCP_ENDPOINT` | No | HTTP path used for Django MCP server streamable endpoint. | `api/mcp` | Backend |
|
| `DJANGO_MCP_ENDPOINT` | No | HTTP path used for the Voyage Travel Agent MCP endpoint. Clients call this endpoint with `Authorization: Token <token>` using a DRF auth token for the target user account. | `api/mcp` | Backend |
|
||||||
|
|
||||||
|
## MCP endpoint authentication details
|
||||||
|
|
||||||
|
Voyage's MCP endpoint requires token authentication.
|
||||||
|
|
||||||
|
- Header format: `Authorization: Token <token>`
|
||||||
|
- Default endpoint path: `api/mcp`
|
||||||
|
- Override path with: `DJANGO_MCP_ENDPOINT`
|
||||||
|
- Token bootstrap endpoint for authenticated sessions: `GET /auth/mcp-token/`
|
||||||
|
|
||||||
|
For MCP usage patterns and tool-level examples, see the [Travel Agent (MCP) guide](../guides/travel_agent.md).
|
||||||
|
|||||||
@@ -1,6 +1,25 @@
|
|||||||
# Updating
|
# Updating
|
||||||
|
|
||||||
Updating Voyage when using docker can be quite easy. Run the following commands to pull the latest version and restart the containers. Make sure you backup your instance before updating just in case!
|
Updating Voyage when using docker can be quite easy. Run a collections backup before upgrading, then pull the latest version and restart the containers.
|
||||||
|
|
||||||
|
## Pre-upgrade backup (recommended)
|
||||||
|
|
||||||
|
Before running migrations or updating containers, export a collections snapshot:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec server python manage.py export_collections_backup
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also provide a custom output path:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec server python manage.py export_collections_backup --output /code/backups/collections_backup_pre_upgrade.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The backup file includes a timestamp, record counts, and snapshot data for:
|
||||||
|
|
||||||
|
- `Collection`
|
||||||
|
- `CollectionItineraryItem`
|
||||||
|
|
||||||
Note: Make sure you are in the same directory as your `docker-compose.yml` file.
|
Note: Make sure you are in the same directory as your `docker-compose.yml` file.
|
||||||
|
|
||||||
|
|||||||
155
documentation/docs/guides/travel_agent.md
Normal file
155
documentation/docs/guides/travel_agent.md
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
# Travel Agent (MCP)
|
||||||
|
|
||||||
|
Voyage includes a **Travel Agent** interface exposed through an **MCP-compatible HTTP endpoint**. This lets external MCP clients read and manage trip itineraries programmatically for authenticated users.
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
- Default path: `api/mcp`
|
||||||
|
- Configurable with: `DJANGO_MCP_ENDPOINT`
|
||||||
|
|
||||||
|
If you run Voyage at `https://voyage.example.com`, the default MCP URL is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://voyage.example.com/api/mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
MCP requests must include a DRF token in the `Authorization` header:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Authorization: Token <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
Use a token associated with the Voyage user account that should execute the MCP actions.
|
||||||
|
|
||||||
|
### Get a token from an authenticated session
|
||||||
|
|
||||||
|
Voyage exposes a token bootstrap endpoint for logged-in users:
|
||||||
|
|
||||||
|
- `GET /auth/mcp-token/`
|
||||||
|
|
||||||
|
Call it with your authenticated browser session (or any authenticated session cookie flow). It returns:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "token": "<token>" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Then use that token in all MCP requests with the same header format:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Authorization: Token <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Available MCP tools
|
||||||
|
|
||||||
|
The Voyage MCP server currently exposes these tools:
|
||||||
|
|
||||||
|
- `list_collections`
|
||||||
|
- `get_collection_details`
|
||||||
|
- `list_itinerary_items`
|
||||||
|
- `create_itinerary_item`
|
||||||
|
- `reorder_itinerary`
|
||||||
|
|
||||||
|
### Tool parameters
|
||||||
|
|
||||||
|
#### `list_collections`
|
||||||
|
|
||||||
|
- No parameters.
|
||||||
|
|
||||||
|
#### `get_collection_details`
|
||||||
|
|
||||||
|
- `collection_id` (required, string UUID): collection to inspect.
|
||||||
|
|
||||||
|
#### `list_itinerary_items`
|
||||||
|
|
||||||
|
- `collection_id` (optional, string UUID): if provided, limits results to one collection.
|
||||||
|
|
||||||
|
#### `create_itinerary_item`
|
||||||
|
|
||||||
|
Required:
|
||||||
|
|
||||||
|
- `collection_id` (string UUID)
|
||||||
|
- `content_type` (`location` \| `transportation` \| `note` \| `lodging` \| `visit` \| `checklist`)
|
||||||
|
- `object_id` (string UUID, id of the referenced content object)
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
|
||||||
|
- `date` (ISO date string, required when `is_global` is `false`)
|
||||||
|
- `is_global` (boolean, default `false`; when `true`, `date` must be omitted)
|
||||||
|
- `order` (integer; if omitted, Voyage appends to the end of the relevant scope)
|
||||||
|
|
||||||
|
#### `reorder_itinerary`
|
||||||
|
|
||||||
|
Required:
|
||||||
|
|
||||||
|
- `items` (list of item update objects)
|
||||||
|
|
||||||
|
Each entry in `items` should include:
|
||||||
|
|
||||||
|
- `id` (required, string UUID of `CollectionItineraryItem`)
|
||||||
|
- `date` (ISO date string for dated items)
|
||||||
|
- `order` (integer target order)
|
||||||
|
- `is_global` (optional boolean; include when moving between global and dated scopes)
|
||||||
|
|
||||||
|
## End-to-end example flow
|
||||||
|
|
||||||
|
This example shows a typical interaction from an MCP client.
|
||||||
|
|
||||||
|
1. **Connect** to the MCP endpoint using your Voyage server URL and token header.
|
||||||
|
2. Call **`list_collections`** to find the trip/collection you want to work with.
|
||||||
|
3. Call **`get_collection_details`** for the selected collection ID to inspect current trip context.
|
||||||
|
4. Call **`list_itinerary_items`** for a specific date or collection scope.
|
||||||
|
5. Call **`create_itinerary_item`** to add a new stop (for example, a location or note) to the itinerary.
|
||||||
|
6. Call **`reorder_itinerary`** to persist the final ordering after insertion.
|
||||||
|
|
||||||
|
### Example request headers (HTTP transport)
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/mcp HTTP/1.1
|
||||||
|
Host: voyage.example.com
|
||||||
|
Authorization: Token <token>
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example interaction sequence (conceptual)
|
||||||
|
|
||||||
|
```text
|
||||||
|
Client -> list_collections
|
||||||
|
Server -> [{"id": "6c5d9f61-2f09-4882-b277-8884b633d36b", "name": "Japan 2026"}, ...]
|
||||||
|
|
||||||
|
Client -> get_collection_details({"collection_id": "6c5d9f61-2f09-4882-b277-8884b633d36b"})
|
||||||
|
Server -> {...collection metadata...}
|
||||||
|
|
||||||
|
Client -> list_itinerary_items({"collection_id": "6c5d9f61-2f09-4882-b277-8884b633d36b"})
|
||||||
|
Server -> [...current ordered itinerary items...]
|
||||||
|
|
||||||
|
Client -> create_itinerary_item({
|
||||||
|
"collection_id": "6c5d9f61-2f09-4882-b277-8884b633d36b",
|
||||||
|
"content_type": "location",
|
||||||
|
"object_id": "fe7ee379-8a2b-456d-9c59-1eafcf83979b",
|
||||||
|
"date": "2026-06-12",
|
||||||
|
"order": 3
|
||||||
|
})
|
||||||
|
Server -> {"id": "5eb8c40c-7e36-4709-b4ec-7dc4cfa26ca5", ...}
|
||||||
|
|
||||||
|
Client -> reorder_itinerary({"items": [
|
||||||
|
{
|
||||||
|
"id": "5eb8c40c-7e36-4709-b4ec-7dc4cfa26ca5",
|
||||||
|
"date": "2026-06-12",
|
||||||
|
"order": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "a044f903-d788-4f67-bba7-3ee73da6d504",
|
||||||
|
"date": "2026-06-12",
|
||||||
|
"order": 1,
|
||||||
|
"is_global": false
|
||||||
|
}
|
||||||
|
]})
|
||||||
|
Server -> [...updated itinerary items...]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related docs
|
||||||
|
|
||||||
|
- [Advanced Configuration](../configuration/advanced_configuration.md)
|
||||||
|
- [How to use Voyage](../usage/usage.md)
|
||||||
@@ -23,6 +23,7 @@ Voyage is a full-fledged travel companion. With Voyage, you can log your adventu
|
|||||||
- Locations and itineraries can be shared via a public link or directly with other Voyage users.
|
- Locations and itineraries can be shared via a public link or directly with other Voyage users.
|
||||||
- Collaborators can view and edit shared itineraries (collections), making planning a breeze.
|
- Collaborators can view and edit shared itineraries (collections), making planning a breeze.
|
||||||
- **Customizable Themes** 🎨: Choose from 10 built-in themes including Light, Dark, Dim, Night, Forest, Aqua, Catppuccin Mocha, Aesthetic Light, Aesthetic Dark, and Northern Lights. Theme selection persists across sessions.
|
- **Customizable Themes** 🎨: Choose from 10 built-in themes including Light, Dark, Dim, Night, Forest, Aqua, Catppuccin Mocha, Aesthetic Light, Aesthetic Dark, and Northern Lights. Theme selection persists across sessions.
|
||||||
|
- **Travel Agent (MCP) access** 🤖: Voyage exposes an authenticated MCP endpoint so external clients can list collections, inspect itineraries, create itinerary items, and reorder trip timelines. See the [Travel Agent (MCP) guide](../guides/travel_agent.md).
|
||||||
|
|
||||||
## Why Voyage?
|
## Why Voyage?
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
Welcome to Voyage! This guide will help you get started with Voyage and provide you with an overview of the features available to you.
|
Welcome to Voyage! This guide will help you get started with Voyage and provide you with an overview of the features available to you.
|
||||||
|
|
||||||
|
Voyage also includes a Travel Agent MCP interface for authenticated programmatic trip access and itinerary actions. See the [Travel Agent (MCP) guide](../guides/travel_agent.md).
|
||||||
|
|
||||||
## Key Terms
|
## Key Terms
|
||||||
|
|
||||||
#### Locations
|
#### Locations
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import DotsHorizontal from '~icons/mdi/dots-horizontal';
|
import DotsHorizontal from '~icons/mdi/dots-horizontal';
|
||||||
import Calendar from '~icons/mdi/calendar';
|
import Calendar from '~icons/mdi/calendar';
|
||||||
|
import HelpCircle from '~icons/mdi/help-circle';
|
||||||
import AboutModal from './AboutModal.svelte';
|
import AboutModal from './AboutModal.svelte';
|
||||||
import AccountMultiple from '~icons/mdi/account-multiple';
|
import AccountMultiple from '~icons/mdi/account-multiple';
|
||||||
import MapMarker from '~icons/mdi/map-marker';
|
import MapMarker from '~icons/mdi/map-marker';
|
||||||
@@ -109,11 +110,24 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type NavigationItem = {
|
||||||
|
path: string;
|
||||||
|
icon: any;
|
||||||
|
label: string;
|
||||||
|
external?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
// Navigation items for better organization
|
// Navigation items for better organization
|
||||||
const navigationItems = [
|
const navigationItems: NavigationItem[] = [
|
||||||
{ path: '/locations', icon: MapMarker, label: 'locations.locations' },
|
{ path: '/locations', icon: MapMarker, label: 'locations.locations' },
|
||||||
{ path: '/collections', icon: FormatListBulletedSquare, label: 'navbar.collections' },
|
{ path: '/collections', icon: FormatListBulletedSquare, label: 'navbar.collections' },
|
||||||
{ path: '/invites', icon: AccountMultiple, label: 'invites.title' },
|
{ path: '/invites', icon: AccountMultiple, label: 'invites.title' },
|
||||||
|
{
|
||||||
|
path: 'https://voyage.app/docs/usage/usage.html',
|
||||||
|
icon: HelpCircle,
|
||||||
|
label: 'navbar.documentation',
|
||||||
|
external: true
|
||||||
|
},
|
||||||
{ path: '/worldtravel', icon: Earth, label: 'navbar.worldtravel' },
|
{ path: '/worldtravel', icon: Earth, label: 'navbar.worldtravel' },
|
||||||
{ path: '/map', icon: MapIcon, label: 'navbar.map' },
|
{ path: '/map', icon: MapIcon, label: 'navbar.map' },
|
||||||
{ path: '/calendar', icon: Calendar, label: 'navbar.calendar' },
|
{ path: '/calendar', icon: Calendar, label: 'navbar.calendar' },
|
||||||
@@ -149,8 +163,10 @@
|
|||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href={item.path}
|
href={item.path}
|
||||||
|
target={item.external ? '_blank' : undefined}
|
||||||
|
rel={item.external ? 'noopener noreferrer' : undefined}
|
||||||
class="btn btn-ghost justify-start gap-3 w-full text-left rounded-xl"
|
class="btn btn-ghost justify-start gap-3 w-full text-left rounded-xl"
|
||||||
class:btn-active={$page.url.pathname === item.path}
|
class:btn-active={!item.external && $page.url.pathname === item.path}
|
||||||
>
|
>
|
||||||
<svelte:component this={item.icon} class="w-5 h-5" />
|
<svelte:component this={item.icon} class="w-5 h-5" />
|
||||||
{$t(item.label)}
|
{$t(item.label)}
|
||||||
@@ -218,9 +234,11 @@
|
|||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href={item.path}
|
href={item.path}
|
||||||
|
target={item.external ? '_blank' : undefined}
|
||||||
|
rel={item.external ? 'noopener noreferrer' : undefined}
|
||||||
class="btn btn-ghost gap-2 rounded-xl transition-all duration-200 hover:bg-base-200"
|
class="btn btn-ghost gap-2 rounded-xl transition-all duration-200 hover:bg-base-200"
|
||||||
class:bg-primary-10={$page.url.pathname === item.path}
|
class:bg-primary-10={!item.external && $page.url.pathname === item.path}
|
||||||
class:text-primary={$page.url.pathname === item.path}
|
class:text-primary={!item.external && $page.url.pathname === item.path}
|
||||||
>
|
>
|
||||||
<svelte:component this={item.icon} class="w-4 h-4" />
|
<svelte:component this={item.icon} class="w-4 h-4" />
|
||||||
<span class="hidden xl:inline">{$t(item.label)}</span>
|
<span class="hidden xl:inline">{$t(item.label)}</span>
|
||||||
|
|||||||
@@ -1003,40 +1003,75 @@
|
|||||||
return `${rounded}°C`;
|
return `${rounded}°C`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function optimizeDayOrder(dayIndex: number) {
|
type HardAnchorTiming = {
|
||||||
if (!canModify || isSavingOrder) return;
|
primaryTimestamp: number;
|
||||||
|
secondaryTimestamp: number;
|
||||||
|
};
|
||||||
|
|
||||||
const day = days[dayIndex];
|
function parseAnchorDateTime(value: string | null | undefined): number | null {
|
||||||
if (!day) return;
|
if (!value) return null;
|
||||||
|
|
||||||
const sortableItems = day.items.filter((item) => {
|
const parsed = DateTime.fromISO(value);
|
||||||
if (item?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]) return false;
|
if (!parsed.isValid) return null;
|
||||||
return !!getCoordinatesFromItineraryItem(item);
|
|
||||||
});
|
|
||||||
|
|
||||||
const nonSortableItems = day.items.filter((item) => {
|
const millis = parsed.toMillis();
|
||||||
if (item?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]) return true;
|
return Number.isFinite(millis) ? millis : null;
|
||||||
return !getCoordinatesFromItineraryItem(item);
|
}
|
||||||
});
|
|
||||||
|
|
||||||
if (sortableItems.length < 2) {
|
function getHardAnchorTiming(item: ResolvedItineraryItem): HardAnchorTiming | null {
|
||||||
addToast('info', getI18nText('itinerary.optimize_not_enough_items', 'Not enough stops to optimize'));
|
const itemType = item.item?.type || '';
|
||||||
return;
|
|
||||||
|
if (itemType === 'transportation') {
|
||||||
|
const transportation = item.resolvedObject as Transportation | null;
|
||||||
|
const startTimestamp = parseAnchorDateTime(transportation?.date);
|
||||||
|
if (startTimestamp === null) return null;
|
||||||
|
|
||||||
|
const endTimestamp = parseAnchorDateTime(transportation?.end_date);
|
||||||
|
return {
|
||||||
|
primaryTimestamp: startTimestamp,
|
||||||
|
secondaryTimestamp: endTimestamp ?? startTimestamp
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const remaining = [...sortableItems];
|
if (itemType === 'lodging') {
|
||||||
|
const lodging = item.resolvedObject as Lodging | null;
|
||||||
|
const checkInTimestamp = parseAnchorDateTime(lodging?.check_in);
|
||||||
|
const checkOutTimestamp = parseAnchorDateTime(lodging?.check_out);
|
||||||
|
if (checkInTimestamp === null && checkOutTimestamp === null) return null;
|
||||||
|
|
||||||
|
const primaryTimestamp = checkInTimestamp ?? checkOutTimestamp;
|
||||||
|
if (primaryTimestamp === null) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
primaryTimestamp,
|
||||||
|
secondaryTimestamp: checkOutTimestamp ?? checkInTimestamp ?? primaryTimestamp
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optimizeNearestNeighborSegment(
|
||||||
|
items: ResolvedItineraryItem[]
|
||||||
|
): ResolvedItineraryItem[] {
|
||||||
|
if (items.length < 2) return [...items];
|
||||||
|
|
||||||
|
const remaining = [...items];
|
||||||
const sorted: ResolvedItineraryItem[] = [];
|
const sorted: ResolvedItineraryItem[] = [];
|
||||||
|
|
||||||
const firstItem = remaining.shift();
|
const firstItem = remaining.shift();
|
||||||
if (!firstItem) return;
|
if (!firstItem) return items;
|
||||||
sorted.push(firstItem);
|
sorted.push(firstItem);
|
||||||
|
|
||||||
while (remaining.length > 0) {
|
while (remaining.length > 0) {
|
||||||
const last = sorted[sorted.length - 1];
|
const last = sorted[sorted.length - 1];
|
||||||
const lastCoords = getCoordinatesFromItineraryItem(last);
|
const lastCoords = getCoordinatesFromItineraryItem(last);
|
||||||
if (!lastCoords) break;
|
if (!lastCoords) {
|
||||||
|
sorted.push(...remaining);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
let nearestIndex = 0;
|
let nearestIndex = -1;
|
||||||
let nearestDistance = Number.POSITIVE_INFINITY;
|
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
for (let index = 0; index < remaining.length; index += 1) {
|
for (let index = 0; index < remaining.length; index += 1) {
|
||||||
@@ -1052,10 +1087,111 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (nearestIndex < 0) {
|
||||||
|
sorted.push(...remaining);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
sorted.push(remaining.splice(nearestIndex, 1)[0]);
|
sorted.push(remaining.splice(nearestIndex, 1)[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
days[dayIndex].items = [...sorted, ...nonSortableItems];
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optimizeDayOrder(dayIndex: number) {
|
||||||
|
if (!canModify || isSavingOrder) return;
|
||||||
|
|
||||||
|
const day = days[dayIndex];
|
||||||
|
if (!day) return;
|
||||||
|
|
||||||
|
const nonShadowItems = day.items.filter((item) => !item?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]);
|
||||||
|
const shadowItems = day.items.filter((item) => item?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]);
|
||||||
|
|
||||||
|
const anchorEntries = nonShadowItems
|
||||||
|
.map((item, originalIndex) => {
|
||||||
|
const timing = getHardAnchorTiming(item);
|
||||||
|
if (!timing) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
item,
|
||||||
|
originalIndex,
|
||||||
|
...timing
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(
|
||||||
|
(entry): entry is {
|
||||||
|
item: ResolvedItineraryItem;
|
||||||
|
originalIndex: number;
|
||||||
|
primaryTimestamp: number;
|
||||||
|
secondaryTimestamp: number;
|
||||||
|
} => !!entry
|
||||||
|
);
|
||||||
|
|
||||||
|
const anchorIndexSet = new Set(anchorEntries.map((entry) => entry.originalIndex));
|
||||||
|
|
||||||
|
const movableCoordinateItems = nonShadowItems.filter((item, originalIndex) => {
|
||||||
|
if (anchorIndexSet.has(originalIndex)) return false;
|
||||||
|
return !!getCoordinatesFromItineraryItem(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (movableCoordinateItems.length < 2) {
|
||||||
|
addToast('info', getI18nText('itinerary.optimize_not_enough_items', 'Not enough stops to optimize'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const anchorsByPosition = [...anchorEntries].sort(
|
||||||
|
(a, b) => a.originalIndex - b.originalIndex
|
||||||
|
);
|
||||||
|
const chronologicalAnchors = [...anchorEntries]
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.primaryTimestamp !== b.primaryTimestamp) {
|
||||||
|
return a.primaryTimestamp - b.primaryTimestamp;
|
||||||
|
}
|
||||||
|
if (a.secondaryTimestamp !== b.secondaryTimestamp) {
|
||||||
|
return a.secondaryTimestamp - b.secondaryTimestamp;
|
||||||
|
}
|
||||||
|
return a.originalIndex - b.originalIndex;
|
||||||
|
})
|
||||||
|
.map((entry) => entry.item);
|
||||||
|
|
||||||
|
const movableSegments: ResolvedItineraryItem[][] = Array.from(
|
||||||
|
{ length: anchorsByPosition.length + 1 },
|
||||||
|
() => []
|
||||||
|
);
|
||||||
|
|
||||||
|
let activeSegmentIndex = 0;
|
||||||
|
let nextAnchorPositionIndex = 0;
|
||||||
|
|
||||||
|
nonShadowItems.forEach((item, originalIndex) => {
|
||||||
|
const nextAnchor = anchorsByPosition[nextAnchorPositionIndex];
|
||||||
|
if (nextAnchor && nextAnchor.originalIndex === originalIndex) {
|
||||||
|
nextAnchorPositionIndex += 1;
|
||||||
|
activeSegmentIndex += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anchorIndexSet.has(originalIndex)) return;
|
||||||
|
if (!getCoordinatesFromItineraryItem(item)) return;
|
||||||
|
|
||||||
|
movableSegments[activeSegmentIndex].push(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
const optimizedPath: ResolvedItineraryItem[] = [];
|
||||||
|
for (let segmentIndex = 0; segmentIndex < movableSegments.length; segmentIndex += 1) {
|
||||||
|
const optimizedSegment = optimizeNearestNeighborSegment(movableSegments[segmentIndex]);
|
||||||
|
optimizedPath.push(...optimizedSegment);
|
||||||
|
|
||||||
|
if (segmentIndex < chronologicalAnchors.length) {
|
||||||
|
optimizedPath.push(chronologicalAnchors[segmentIndex]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nonCoordinateItems = nonShadowItems.filter((item, originalIndex) => {
|
||||||
|
if (anchorIndexSet.has(originalIndex)) return false;
|
||||||
|
return !getCoordinatesFromItineraryItem(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
days[dayIndex].items = [...optimizedPath, ...nonCoordinateItems, ...shadowItems];
|
||||||
days = [...days];
|
days = [...days];
|
||||||
|
|
||||||
isSavingOrder = true;
|
isSavingOrder = true;
|
||||||
|
|||||||
@@ -734,7 +734,27 @@
|
|||||||
"activities": "Aktivitäten",
|
"activities": "Aktivitäten",
|
||||||
"trails": "Wanderwege",
|
"trails": "Wanderwege",
|
||||||
"use_imperial": "Verwenden Sie imperiale Einheiten",
|
"use_imperial": "Verwenden Sie imperiale Einheiten",
|
||||||
"use_imperial_desc": "Verwenden Sie imperiale Einheiten (Füße, Zoll, Pfund) anstelle von metrischen Einheiten"
|
"use_imperial_desc": "Verwenden Sie imperiale Einheiten (Füße, Zoll, Pfund) anstelle von metrischen Einheiten",
|
||||||
|
"ai_api_keys": "KI-API-Schlüssel",
|
||||||
|
"ai_api_keys_desc": "Verwalten Sie nur schreibbare API-Schlüssel für Reiseagenten-Empfehlungen.",
|
||||||
|
"travel_agent_help_title": "So verwenden Sie den Reiseagenten",
|
||||||
|
"travel_agent_help_body": "Öffnen Sie eine Sammlung und wechseln Sie zu Empfehlungen, um mit dem Reiseagenten nach Vorschlägen zu suchen.",
|
||||||
|
"travel_agent_help_open_collections": "Sammlungen öffnen",
|
||||||
|
"travel_agent_help_setup_guide": "Einrichtungsanleitung für Reiseagenten",
|
||||||
|
"saved_api_keys": "Gespeicherte API-Schlüssel",
|
||||||
|
"no_api_keys_saved": "Noch keine API-Schlüssel gespeichert.",
|
||||||
|
"add_api_key": "API-Schlüssel hinzufügen",
|
||||||
|
"provider": "Anbieter",
|
||||||
|
"api_key_value": "API-Schlüssel",
|
||||||
|
"api_key_value_placeholder": "Geben Sie Ihren API-Schlüssel ein",
|
||||||
|
"api_key_write_only_hint": "Aus Sicherheitsgründen ist Ihr Klartextschlüssel nur zum Schreiben verfügbar und wird nach dem Speichern nicht mehr angezeigt.",
|
||||||
|
"save_api_key": "API-Schlüssel speichern",
|
||||||
|
"api_keys_saved": "API-Schlüssel gespeichert.",
|
||||||
|
"api_keys_deleted": "API-Schlüssel gelöscht.",
|
||||||
|
"api_keys_generic_error": "API-Schlüssel können derzeit nicht aktualisiert werden.",
|
||||||
|
"api_keys_value_required": "Bitte geben Sie einen API-Schlüssel ein.",
|
||||||
|
"api_keys_config_unavailable": "API-Schlüsselspeicher ist nicht verfügbar",
|
||||||
|
"api_keys_config_guidance": "Bitten Sie Ihren Serveradministrator, FIELD_ENCRYPTION_KEY zu konfigurieren, und versuchen Sie es erneut."
|
||||||
},
|
},
|
||||||
"checklist": {
|
"checklist": {
|
||||||
"checklist_delete_error": "Fehler beim Löschen der Checkliste",
|
"checklist_delete_error": "Fehler beim Löschen der Checkliste",
|
||||||
|
|||||||
@@ -735,7 +735,27 @@
|
|||||||
"use_imperial": "Use Imperial Units",
|
"use_imperial": "Use Imperial Units",
|
||||||
"use_imperial_desc": "Use imperial units (feet, inches, pounds) instead of metric units",
|
"use_imperial_desc": "Use imperial units (feet, inches, pounds) instead of metric units",
|
||||||
"trails": "Trails",
|
"trails": "Trails",
|
||||||
"activities": "Activities"
|
"activities": "Activities",
|
||||||
|
"ai_api_keys": "AI API Keys",
|
||||||
|
"ai_api_keys_desc": "Manage write-only API keys for travel-agent recommendations.",
|
||||||
|
"saved_api_keys": "Saved API Keys",
|
||||||
|
"no_api_keys_saved": "No API keys saved yet.",
|
||||||
|
"add_api_key": "Add API Key",
|
||||||
|
"provider": "Provider",
|
||||||
|
"api_key_value": "API Key",
|
||||||
|
"api_key_value_placeholder": "Enter your API key",
|
||||||
|
"api_key_write_only_hint": "For security, your plaintext key is write-only and is never shown after saving.",
|
||||||
|
"save_api_key": "Save API Key",
|
||||||
|
"api_keys_saved": "API key saved.",
|
||||||
|
"api_keys_deleted": "API key deleted.",
|
||||||
|
"api_keys_generic_error": "Unable to update API keys right now.",
|
||||||
|
"api_keys_value_required": "Please enter an API key.",
|
||||||
|
"api_keys_config_unavailable": "API key storage is unavailable",
|
||||||
|
"api_keys_config_guidance": "Ask your server administrator to configure FIELD_ENCRYPTION_KEY and try again.",
|
||||||
|
"travel_agent_help_title": "How to use the travel agent",
|
||||||
|
"travel_agent_help_body": "Open a collection and switch to Recommendations to interact with the travel agent for place suggestions.",
|
||||||
|
"travel_agent_help_open_collections": "Open Collections",
|
||||||
|
"travel_agent_help_setup_guide": "Travel agent setup guide"
|
||||||
},
|
},
|
||||||
"collection": {
|
"collection": {
|
||||||
"collection_created": "Collection created successfully!",
|
"collection_created": "Collection created successfully!",
|
||||||
|
|||||||
@@ -734,7 +734,10 @@
|
|||||||
"use_imperial": "İngiliz Ölçü Birimlerini Kullan",
|
"use_imperial": "İngiliz Ölçü Birimlerini Kullan",
|
||||||
"use_imperial_desc": "Metrik birimler yerine İngiliz birimlerini (fit, inç, pound) kullanın",
|
"use_imperial_desc": "Metrik birimler yerine İngiliz birimlerini (fit, inç, pound) kullanın",
|
||||||
"trails": "Patikalar",
|
"trails": "Patikalar",
|
||||||
"activities": "Aktiviteler"
|
"activities": "Aktiviteler",
|
||||||
|
"ai_api_keys": "Yapay zeka API anahtarları",
|
||||||
|
"saved_api_keys": "Kaydedilen API anahtarları",
|
||||||
|
"add_api_key": "API anahtarı ekle"
|
||||||
},
|
},
|
||||||
"collection": {
|
"collection": {
|
||||||
"collection_created": "Koleksiyon başarıyla oluşturuldu!",
|
"collection_created": "Koleksiyon başarıyla oluşturuldu!",
|
||||||
|
|||||||
@@ -51,9 +51,7 @@
|
|||||||
|
|
||||||
if (browser) {
|
if (browser) {
|
||||||
init({
|
init({
|
||||||
fallbackLocale: locales.includes(navigator.language.split('-')[0])
|
fallbackLocale: 'en',
|
||||||
? navigator.language.split('-')[0]
|
|
||||||
: 'en',
|
|
||||||
initialLocale: data.locale
|
initialLocale: data.locale
|
||||||
});
|
});
|
||||||
// get the locale cookie if it exists and set it as the initial locale if it exists
|
// get the locale cookie if it exists and set it as the initial locale if it exists
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ type MFAAuthenticatorResponse = {
|
|||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type UserAPIKey = {
|
||||||
|
id: string;
|
||||||
|
provider: string;
|
||||||
|
masked_api_key: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
export const load: PageServerLoad = async (event) => {
|
export const load: PageServerLoad = async (event) => {
|
||||||
if (!event.locals.user) {
|
if (!event.locals.user) {
|
||||||
return redirect(302, '/');
|
return redirect(302, '/');
|
||||||
@@ -85,6 +93,21 @@ export const load: PageServerLoad = async (event) => {
|
|||||||
let wandererEnabled = integrations.wanderer.exists as boolean;
|
let wandererEnabled = integrations.wanderer.exists as boolean;
|
||||||
let wandererExpired = integrations.wanderer.expired as boolean;
|
let wandererExpired = integrations.wanderer.expired as boolean;
|
||||||
|
|
||||||
|
let apiKeys: UserAPIKey[] = [];
|
||||||
|
let apiKeysConfigError: string | null = null;
|
||||||
|
let apiKeysFetch = await fetch(`${endpoint}/api/integrations/api-keys/`, {
|
||||||
|
headers: {
|
||||||
|
Cookie: `sessionid=${sessionId}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (apiKeysFetch.ok) {
|
||||||
|
apiKeys = (await apiKeysFetch.json()) as UserAPIKey[];
|
||||||
|
} else if (apiKeysFetch.status === 503) {
|
||||||
|
const errorBody = (await apiKeysFetch.json()) as { detail?: string };
|
||||||
|
apiKeysConfigError = errorBody.detail ?? 'API key storage is currently unavailable.';
|
||||||
|
}
|
||||||
|
|
||||||
let publicUrlFetch = await fetch(`${endpoint}/public-url/`);
|
let publicUrlFetch = await fetch(`${endpoint}/public-url/`);
|
||||||
let publicUrl = '';
|
let publicUrl = '';
|
||||||
if (!publicUrlFetch.ok) {
|
if (!publicUrlFetch.ok) {
|
||||||
@@ -101,10 +124,13 @@ export const load: PageServerLoad = async (event) => {
|
|||||||
authenticators,
|
authenticators,
|
||||||
immichIntegration,
|
immichIntegration,
|
||||||
publicUrl,
|
publicUrl,
|
||||||
|
mcpTokenHeaderFormat: 'Authorization: Token <token>',
|
||||||
socialProviders,
|
socialProviders,
|
||||||
googleMapsEnabled,
|
googleMapsEnabled,
|
||||||
stravaGlobalEnabled,
|
stravaGlobalEnabled,
|
||||||
stravaUserEnabled,
|
stravaUserEnabled,
|
||||||
|
apiKeys,
|
||||||
|
apiKeysConfigError,
|
||||||
wandererEnabled,
|
wandererEnabled,
|
||||||
wandererExpired
|
wandererExpired
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@
|
|||||||
import WandererLogoSrc from '$lib/assets/wanderer.svg';
|
import WandererLogoSrc from '$lib/assets/wanderer.svg';
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
console.log(data);
|
|
||||||
let user: User;
|
let user: User;
|
||||||
let emails: typeof data.props.emails;
|
let emails: typeof data.props.emails;
|
||||||
if (data.user) {
|
if (data.user) {
|
||||||
@@ -37,6 +36,21 @@
|
|||||||
let stravaUserEnabled = data.props.stravaUserEnabled;
|
let stravaUserEnabled = data.props.stravaUserEnabled;
|
||||||
let wandererEnabled = data.props.wandererEnabled;
|
let wandererEnabled = data.props.wandererEnabled;
|
||||||
let wandererExpired = data.props.wandererExpired;
|
let wandererExpired = data.props.wandererExpired;
|
||||||
|
type UserAPIKey = {
|
||||||
|
id: string;
|
||||||
|
provider: string;
|
||||||
|
masked_api_key: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
let userApiKeys: UserAPIKey[] = data.props.apiKeys ?? [];
|
||||||
|
let apiKeysConfigError: string | null = data.props.apiKeysConfigError ?? null;
|
||||||
|
let newApiKeyProvider = 'google_maps';
|
||||||
|
let newApiKeyValue = '';
|
||||||
|
let isSavingApiKey = false;
|
||||||
|
let deletingApiKeyId: string | null = null;
|
||||||
|
let mcpToken: string | null = null;
|
||||||
|
let isLoadingMcpToken = false;
|
||||||
let activeSection: string = 'profile';
|
let activeSection: string = 'profile';
|
||||||
|
|
||||||
// typed alias for social providers to satisfy TypeScript
|
// typed alias for social providers to satisfy TypeScript
|
||||||
@@ -82,6 +96,7 @@
|
|||||||
{ id: 'security', icon: '🔒', label: () => $t('settings.security') },
|
{ id: 'security', icon: '🔒', label: () => $t('settings.security') },
|
||||||
{ id: 'emails', icon: '📧', label: () => $t('settings.emails') },
|
{ id: 'emails', icon: '📧', label: () => $t('settings.emails') },
|
||||||
{ id: 'integrations', icon: '🔗', label: () => $t('settings.integrations') },
|
{ id: 'integrations', icon: '🔗', label: () => $t('settings.integrations') },
|
||||||
|
{ id: 'ai_api_keys', icon: '🤖', label: () => $t('settings.ai_api_keys') },
|
||||||
{ id: 'import_export', icon: '📦', label: () => $t('settings.backup_restore') },
|
{ id: 'import_export', icon: '📦', label: () => $t('settings.backup_restore') },
|
||||||
{ id: 'admin', icon: '⚙️', label: () => $t('settings.admin') },
|
{ id: 'admin', icon: '⚙️', label: () => $t('settings.admin') },
|
||||||
{ id: 'advanced', icon: '🛠️', label: () => $t('settings.advanced') }
|
{ id: 'advanced', icon: '🛠️', label: () => $t('settings.advanced') }
|
||||||
@@ -401,6 +416,162 @@
|
|||||||
newWandererIntegration.password = '';
|
newWandererIntegration.password = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getApiKeysErrorMessage(errorBody: any): string {
|
||||||
|
if (errorBody?.detail) {
|
||||||
|
return errorBody.detail;
|
||||||
|
}
|
||||||
|
if (errorBody?.api_key?.[0]) {
|
||||||
|
return errorBody.api_key[0];
|
||||||
|
}
|
||||||
|
if (errorBody?.provider?.[0]) {
|
||||||
|
return errorBody.provider[0];
|
||||||
|
}
|
||||||
|
return $t('settings.api_keys_generic_error');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addUserApiKey(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!newApiKeyValue.trim()) {
|
||||||
|
addToast('error', $t('settings.api_keys_value_required'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSavingApiKey = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/integrations/api-keys/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
provider: newApiKeyProvider,
|
||||||
|
api_key: newApiKeyValue
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let payload: any = null;
|
||||||
|
try {
|
||||||
|
payload = await res.json();
|
||||||
|
} catch {
|
||||||
|
payload = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.ok && payload) {
|
||||||
|
const existingIndex = userApiKeys.findIndex((key) => key.provider === payload.provider);
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
const updated = [...userApiKeys];
|
||||||
|
updated[existingIndex] = payload;
|
||||||
|
userApiKeys = updated.sort((a, b) => a.provider.localeCompare(b.provider));
|
||||||
|
} else {
|
||||||
|
userApiKeys = [...userApiKeys, payload].sort((a, b) => a.provider.localeCompare(b.provider));
|
||||||
|
}
|
||||||
|
newApiKeyValue = '';
|
||||||
|
apiKeysConfigError = null;
|
||||||
|
addToast('success', $t('settings.api_keys_saved'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 503) {
|
||||||
|
apiKeysConfigError = getApiKeysErrorMessage(payload);
|
||||||
|
addToast('error', $t('settings.api_keys_config_unavailable'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addToast('error', getApiKeysErrorMessage(payload));
|
||||||
|
} catch {
|
||||||
|
addToast('error', $t('settings.api_keys_generic_error'));
|
||||||
|
} finally {
|
||||||
|
isSavingApiKey = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUserApiKey(apiKey: UserAPIKey) {
|
||||||
|
deletingApiKeyId = apiKey.id;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/integrations/api-keys/${apiKey.id}/`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok || res.status === 204) {
|
||||||
|
userApiKeys = userApiKeys.filter((key) => key.id !== apiKey.id);
|
||||||
|
addToast('success', $t('settings.api_keys_deleted'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: any = null;
|
||||||
|
try {
|
||||||
|
payload = await res.json();
|
||||||
|
} catch {
|
||||||
|
payload = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 503) {
|
||||||
|
apiKeysConfigError = getApiKeysErrorMessage(payload);
|
||||||
|
addToast('error', $t('settings.api_keys_config_unavailable'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addToast('error', getApiKeysErrorMessage(payload));
|
||||||
|
} catch {
|
||||||
|
addToast('error', $t('settings.api_keys_generic_error'));
|
||||||
|
} finally {
|
||||||
|
deletingApiKeyId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMaskedMcpToken(token: string): string {
|
||||||
|
if (token.length <= 8) {
|
||||||
|
return '••••••••';
|
||||||
|
}
|
||||||
|
return `${token.slice(0, 4)}••••••••${token.slice(-4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchOrCreateMcpToken() {
|
||||||
|
isLoadingMcpToken = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/auth/mcp-token/', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
addToast('error', $t('settings.generic_error'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await res.json()) as { token?: string };
|
||||||
|
if (!payload.token) {
|
||||||
|
addToast('error', $t('settings.generic_error'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mcpToken = payload.token;
|
||||||
|
addToast('success', 'MCP token ready.');
|
||||||
|
} catch {
|
||||||
|
addToast('error', $t('settings.generic_error'));
|
||||||
|
} finally {
|
||||||
|
isLoadingMcpToken = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyMcpAuthHeader() {
|
||||||
|
if (!mcpToken) {
|
||||||
|
addToast('error', 'Generate token first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeader = `Authorization: Token ${mcpToken}`;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(authHeader);
|
||||||
|
addToast('success', $t('adventures.copied_to_clipboard'));
|
||||||
|
} catch {
|
||||||
|
addToast('error', $t('adventures.copy_failed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if isMFAModalOpen}
|
{#if isMFAModalOpen}
|
||||||
@@ -1292,6 +1463,189 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- AI API Keys Section -->
|
||||||
|
{#if activeSection === 'ai_api_keys'}
|
||||||
|
<div class="bg-base-100 rounded-2xl shadow-xl p-8">
|
||||||
|
<div class="flex items-center gap-4 mb-6">
|
||||||
|
<div class="p-3 bg-primary/10 rounded-xl">
|
||||||
|
<span class="text-2xl">🤖</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold">{$t('settings.ai_api_keys')}</h2>
|
||||||
|
<p class="text-base-content/70">
|
||||||
|
{$t('settings.ai_api_keys_desc')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if apiKeysConfigError}
|
||||||
|
<div class="alert alert-warning mb-6">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="stroke-current shrink-0 h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold">{$t('settings.api_keys_config_unavailable')}</p>
|
||||||
|
<p class="text-sm">{apiKeysConfigError}</p>
|
||||||
|
<p class="text-sm mt-1">{$t('settings.api_keys_config_guidance')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="alert alert-info mb-6">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="stroke-current shrink-0 h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold">{$t('settings.travel_agent_help_title')}</p>
|
||||||
|
<p class="text-sm">{$t('settings.travel_agent_help_body')}</p>
|
||||||
|
<p class="text-sm mt-1 flex flex-wrap gap-3">
|
||||||
|
<a class="link link-primary" href="/collections"
|
||||||
|
>{$t('settings.travel_agent_help_open_collections')}</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="link link-primary"
|
||||||
|
href="https://voyage.app/docs/usage/usage.html"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>{$t('settings.travel_agent_help_setup_guide')}</a
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6 bg-base-200 rounded-xl mb-6">
|
||||||
|
<h3 class="text-lg font-semibold mb-2">MCP Access Token</h3>
|
||||||
|
<p class="text-sm text-base-content/70 mb-4">
|
||||||
|
Create or fetch your personal token for MCP clients. The same token is reused if one
|
||||||
|
already exists.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-3 mb-4">
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
on:click={fetchOrCreateMcpToken}
|
||||||
|
disabled={isLoadingMcpToken}
|
||||||
|
>
|
||||||
|
{#if isLoadingMcpToken}
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
{/if}
|
||||||
|
{mcpToken ? 'Refresh token' : 'Get MCP token'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-outline"
|
||||||
|
on:click={copyMcpAuthHeader}
|
||||||
|
disabled={!mcpToken}
|
||||||
|
>
|
||||||
|
{$t('settings.copy')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="text-xs uppercase tracking-wide text-base-content/60">Token</div>
|
||||||
|
<div class="font-mono text-sm p-3 rounded-lg bg-base-100 border border-base-300">
|
||||||
|
{mcpToken ? getMaskedMcpToken(mcpToken) : 'Not generated yet'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 p-4 bg-base-100 rounded-lg border border-base-300">
|
||||||
|
<div class="text-sm font-medium mb-1">Use this exact auth header format</div>
|
||||||
|
<div class="font-mono text-sm">{data.props.mcpTokenHeaderFormat}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6 bg-base-200 rounded-xl mb-6">
|
||||||
|
<h3 class="text-lg font-semibold mb-4">{$t('settings.saved_api_keys')}</h3>
|
||||||
|
{#if userApiKeys.length === 0}
|
||||||
|
<p class="text-base-content/70">{$t('settings.no_api_keys_saved')}</p>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each userApiKeys as apiKey}
|
||||||
|
<div class="flex items-center justify-between gap-4 p-4 bg-base-100 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">{apiKey.provider}</div>
|
||||||
|
<div class="text-sm text-base-content/70 font-mono">
|
||||||
|
{apiKey.masked_api_key}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-error"
|
||||||
|
on:click={() => deleteUserApiKey(apiKey)}
|
||||||
|
disabled={deletingApiKeyId === apiKey.id}
|
||||||
|
>
|
||||||
|
{#if deletingApiKeyId === apiKey.id}
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
{/if}
|
||||||
|
{$t('adventures.remove')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6 bg-base-200 rounded-xl">
|
||||||
|
<h3 class="text-lg font-semibold mb-4">{$t('settings.add_api_key')}</h3>
|
||||||
|
<form class="space-y-4" on:submit={addUserApiKey}>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="api-key-provider">
|
||||||
|
<span class="label-text font-medium">{$t('settings.provider')}</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="api-key-provider"
|
||||||
|
class="select select-bordered select-primary w-full"
|
||||||
|
bind:value={newApiKeyProvider}
|
||||||
|
>
|
||||||
|
<option value="google_maps">Google Maps</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="api-key-value">
|
||||||
|
<span class="label-text font-medium">{$t('settings.api_key_value')}</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="api-key-value"
|
||||||
|
type="password"
|
||||||
|
class="input input-bordered input-primary focus:input-primary"
|
||||||
|
bind:value={newApiKeyValue}
|
||||||
|
placeholder={$t('settings.api_key_value_placeholder')}
|
||||||
|
required
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<p class="text-sm text-base-content/70 mt-1">
|
||||||
|
{$t('settings.api_key_write_only_hint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" type="submit" disabled={isSavingApiKey}>
|
||||||
|
{#if isSavingApiKey}
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
{/if}
|
||||||
|
{$t('settings.save_api_key')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- import export -->
|
<!-- import export -->
|
||||||
{#if activeSection === 'import_export'}
|
{#if activeSection === 'import_export'}
|
||||||
<div class="bg-base-100 rounded-2xl shadow-xl p-8">
|
<div class="bg-base-100 rounded-2xl shadow-xl p-8">
|
||||||
|
|||||||
Reference in New Issue
Block a user