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

@@ -0,0 +1,160 @@
from django.contrib.contenttypes.models import ContentType
from django.db.models import Max, Q
from rest_framework.exceptions import ValidationError
from adventures.models import (
Checklist,
Collection,
CollectionItineraryDay,
CollectionItineraryItem,
Lodging,
Location,
Note,
Transportation,
Visit,
)
from adventures.permissions import IsOwnerOrSharedWithFullAccess
from adventures.serializers import (
CollectionItineraryDaySerializer,
CollectionItineraryItemSerializer,
CollectionSerializer,
UltraSlimCollectionSerializer,
)
from adventures.utils.itinerary import reorder_itinerary_items
from mcp_server import MCPToolset
from mcp_server.djangomcp import global_mcp_server
class VoyageTripTools(MCPToolset):
mcp_server = global_mcp_server
def _assert_authenticated(self):
if (
not getattr(self.request, "user", None)
or not self.request.user.is_authenticated
):
raise ValidationError("Authentication required")
def _accessible_collections_queryset(self):
self._assert_authenticated()
user = self.request.user
return Collection.objects.filter(Q(user=user) | Q(shared_with=user)).distinct()
def list_collections(self):
"""List collections visible to authenticated user."""
queryset = self._accessible_collections_queryset().order_by("-updated_at")
return UltraSlimCollectionSerializer(
queryset,
many=True,
context={"request": self.request},
).data
def get_collection_details(self, collection_id: str):
"""Get collection details, itinerary items, and itinerary-day metadata."""
try:
collection = self._accessible_collections_queryset().get(id=collection_id)
except Collection.DoesNotExist as exc:
raise ValidationError("Collection not found or not accessible") from exc
data = CollectionSerializer(collection, context={"request": self.request}).data
itinerary_items = CollectionItineraryItem.objects.filter(collection=collection)
itinerary_days = CollectionItineraryDay.objects.filter(collection=collection)
data["itinerary"] = CollectionItineraryItemSerializer(
itinerary_items, many=True
).data
data["itinerary_days"] = CollectionItineraryDaySerializer(
itinerary_days, many=True
).data
return data
def list_itinerary_items(self, collection_id: str | None = None):
"""List itinerary items; optionally limit by collection_id."""
self._assert_authenticated()
queryset = CollectionItineraryItem.objects.filter(
Q(collection__user=self.request.user)
| Q(collection__shared_with=self.request.user)
).distinct()
if collection_id:
queryset = queryset.filter(collection_id=collection_id)
queryset = queryset.order_by("date", "order")
return CollectionItineraryItemSerializer(queryset, many=True).data
def create_itinerary_item(
self,
collection_id: str,
content_type: str,
object_id: str,
date: str | None = None,
is_global: bool = False,
order: int | None = None,
):
"""Create a new itinerary item."""
try:
collection = self._accessible_collections_queryset().get(id=collection_id)
except Collection.DoesNotExist as exc:
raise ValidationError("Collection not found or not accessible") from exc
content_map = {
"location": Location,
"transportation": Transportation,
"note": Note,
"lodging": Lodging,
"visit": Visit,
"checklist": Checklist,
}
model_class = content_map.get((content_type or "").lower())
if not model_class:
raise ValidationError("Invalid content_type")
try:
content_object = model_class.objects.get(id=object_id)
except model_class.DoesNotExist as exc:
raise ValidationError("Referenced object not found") from exc
permission_checker = IsOwnerOrSharedWithFullAccess()
if not permission_checker.has_object_permission(
self.request, None, content_object
):
raise ValidationError(
"User does not have permission to access this content"
)
if is_global and date:
raise ValidationError("Global itinerary items must not include a date")
if (not is_global) and not date:
raise ValidationError("Dated itinerary items must include a date")
if order is None:
if is_global:
existing_max = (
CollectionItineraryItem.objects.filter(
collection=collection, is_global=True
)
.aggregate(max_order=Max("order"))
.get("max_order")
)
else:
existing_max = (
CollectionItineraryItem.objects.filter(
collection=collection, date=date, is_global=False
)
.aggregate(max_order=Max("order"))
.get("max_order")
)
order = 0 if existing_max is None else int(existing_max) + 1
itinerary_item = CollectionItineraryItem.objects.create(
collection=collection,
content_type=ContentType.objects.get_for_model(model_class),
object_id=object_id,
date=date,
is_global=is_global,
order=order,
)
return CollectionItineraryItemSerializer(itinerary_item).data
def reorder_itinerary(self, items: list[dict]):
"""Bulk reorder itinerary items."""
self._assert_authenticated()
updated_items = reorder_itinerary_items(self.request.user, items or [])
return CollectionItineraryItemSerializer(updated_items, many=True).data

View File

@@ -1,3 +1,108 @@
from django.test import TestCase
from datetime import timedelta
from unittest.mock import Mock, patch
# Create your tests here.
from django.contrib.auth import get_user_model
from django.core.cache import cache
from django.utils import timezone
from rest_framework.test import APIClient, APITestCase
User = get_user_model()
class WeatherEndpointTests(APITestCase):
def setUp(self):
self.user = User.objects.create_user(
username="weather-user",
email="weather@example.com",
password="password123",
)
self.client.force_authenticate(user=self.user)
cache.clear()
def test_daily_temperatures_rejects_too_many_days(self):
payload = {
"days": [
{"date": "2026-01-01", "latitude": 10, "longitude": 10}
for _ in range(61)
]
}
response = self.client.post(
"/api/weather/daily-temperatures/", payload, format="json"
)
self.assertEqual(response.status_code, 400)
self.assertIn("maximum", response.json().get("error", "").lower())
@patch("adventures.views.weather_view.requests.get")
def test_daily_temperatures_future_date_returns_unavailable_without_external_call(
self, mock_requests_get
):
future_date = (timezone.now().date() + timedelta(days=10)).isoformat()
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, "available": False, "temperature_c": None},
)
mock_requests_get.assert_not_called()
@patch("adventures.views.weather_view.requests.get")
def test_daily_temperatures_accepts_zero_lat_lon(self, mock_requests_get):
today = timezone.now().date().isoformat()
mocked_response = Mock()
mocked_response.raise_for_status.return_value = None
mocked_response.json.return_value = {
"daily": {
"temperature_2m_max": [20.0],
"temperature_2m_min": [10.0],
}
}
mock_requests_get.return_value = mocked_response
response = self.client.post(
"/api/weather/daily-temperatures/",
{"days": [{"date": today, "latitude": 0, "longitude": 0}]},
format="json",
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["results"][0]["date"], today)
self.assertTrue(response.json()["results"][0]["available"])
self.assertEqual(response.json()["results"][0]["temperature_c"], 15.0)
class RecommendationPhotoProxyValidationTests(APITestCase):
def setUp(self):
self.user = User.objects.create_user(
username="reco-user",
email="reco@example.com",
password="password123",
)
self.client.force_authenticate(user=self.user)
def test_google_photo_rejects_invalid_photo_name(self):
response = self.client.get(
"/api/recommendations/google-photo/?photo_name=invalid-photo-name"
)
self.assertEqual(response.status_code, 400)
def test_google_photo_rejects_trailing_newline_photo_name(self):
response = self.client.get(
"/api/recommendations/google-photo/?photo_name=places/abc/photos/def%0A"
)
self.assertEqual(response.status_code, 400)
class MCPAuthTests(APITestCase):
def test_mcp_unauthenticated_access_is_rejected(self):
unauthenticated_client = APIClient()
response = unauthenticated_client.post("/api/mcp", {}, format="json")
self.assertIn(response.status_code, [401, 403])

View File

@@ -3,31 +3,36 @@ from rest_framework.routers import DefaultRouter
from adventures.views import *
router = DefaultRouter()
router.register(r'locations', LocationViewSet, basename='locations')
router.register(r'collections', CollectionViewSet, basename='collections')
router.register(r'stats', StatsViewSet, basename='stats')
router.register(r'generate', GenerateDescription, basename='generate')
router.register(r'tags', ActivityTypesView, basename='tags')
router.register(r'transportations', TransportationViewSet, basename='transportations')
router.register(r'notes', NoteViewSet, basename='notes')
router.register(r'checklists', ChecklistViewSet, basename='checklists')
router.register(r'images', ContentImageViewSet, basename='images')
router.register(r'reverse-geocode', ReverseGeocodeViewSet, basename='reverse-geocode')
router.register(r'categories', CategoryViewSet, basename='categories')
router.register(r'ics-calendar', IcsCalendarGeneratorViewSet, basename='ics-calendar')
router.register(r'search', GlobalSearchView, basename='search')
router.register(r'attachments', AttachmentViewSet, basename='attachments')
router.register(r'lodging', LodgingViewSet, basename='lodging')
router.register(r'recommendations', RecommendationsViewSet, basename='recommendations'),
router.register(r'backup', BackupViewSet, basename='backup')
router.register(r'trails', TrailViewSet, basename='trails')
router.register(r'activities', ActivityViewSet, basename='activities')
router.register(r'visits', VisitViewSet, basename='visits')
router.register(r'itineraries', ItineraryViewSet, basename='itineraries')
router.register(r'itinerary-days', ItineraryDayViewSet, basename='itinerary-days')
router.register(r'route-metrics', RouteMetricsViewSet, basename='route-metrics')
router.register(r"locations", LocationViewSet, basename="locations")
router.register(r"collections", CollectionViewSet, basename="collections")
router.register(r"stats", StatsViewSet, basename="stats")
router.register(r"generate", GenerateDescription, basename="generate")
router.register(r"tags", ActivityTypesView, basename="tags")
router.register(r"transportations", TransportationViewSet, basename="transportations")
router.register(r"notes", NoteViewSet, basename="notes")
router.register(r"checklists", ChecklistViewSet, basename="checklists")
router.register(r"images", ContentImageViewSet, basename="images")
router.register(r"reverse-geocode", ReverseGeocodeViewSet, basename="reverse-geocode")
router.register(r"categories", CategoryViewSet, basename="categories")
router.register(r"ics-calendar", IcsCalendarGeneratorViewSet, basename="ics-calendar")
router.register(r"search", GlobalSearchView, basename="search")
router.register(r"attachments", AttachmentViewSet, basename="attachments")
router.register(r"lodging", LodgingViewSet, basename="lodging")
(
router.register(
r"recommendations", RecommendationsViewSet, basename="recommendations"
),
)
router.register(r"backup", BackupViewSet, basename="backup")
router.register(r"trails", TrailViewSet, basename="trails")
router.register(r"activities", ActivityViewSet, basename="activities")
router.register(r"visits", VisitViewSet, basename="visits")
router.register(r"itineraries", ItineraryViewSet, basename="itineraries")
router.register(r"itinerary-days", ItineraryDayViewSet, basename="itinerary-days")
router.register(r"route-metrics", RouteMetricsViewSet, basename="route-metrics")
router.register(r"weather", WeatherViewSet, basename="weather")
urlpatterns = [
# Include the router under the 'api/' prefix
path('', include(router.urls)),
path("", include(router.urls)),
]

View File

@@ -20,3 +20,4 @@ from .activity_view import *
from .visit_view import *
from .itinerary_view import *
from .route_metrics_view import *
from .weather_view import *

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,154 @@
import hashlib
import logging
from datetime import date as date_cls
import requests
from django.core.cache import cache
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
logger = logging.getLogger(__name__)
class WeatherViewSet(viewsets.ViewSet):
permission_classes = [IsAuthenticated]
OPEN_METEO_ARCHIVE_URL = "https://archive-api.open-meteo.com/v1/archive"
OPEN_METEO_FORECAST_URL = "https://api.open-meteo.com/v1/forecast"
CACHE_TIMEOUT_SECONDS = 60 * 60 * 6
MAX_DAYS_PER_REQUEST = 60
@action(detail=False, methods=["post"], url_path="daily-temperatures")
def daily_temperatures(self, request):
days = request.data.get("days", [])
if not isinstance(days, list):
return Response(
{"error": "'days' must be a list"}, status=status.HTTP_400_BAD_REQUEST
)
if len(days) > self.MAX_DAYS_PER_REQUEST:
return Response(
{
"error": f"A maximum of {self.MAX_DAYS_PER_REQUEST} days is allowed per request"
},
status=status.HTTP_400_BAD_REQUEST,
)
results = []
for entry in days:
if not isinstance(entry, dict):
results.append(
{"date": None, "available": False, "temperature_c": None}
)
continue
date = entry.get("date")
latitude = entry.get("latitude")
longitude = entry.get("longitude")
if not date or latitude is None or longitude is None:
results.append(
{"date": date, "available": False, "temperature_c": None}
)
continue
parsed_date = self._parse_date(date)
if parsed_date is None:
results.append(
{"date": date, "available": False, "temperature_c": None}
)
continue
if parsed_date > date_cls.today():
results.append(
{"date": date, "available": False, "temperature_c": None}
)
continue
try:
lat = float(latitude)
lon = float(longitude)
except (TypeError, ValueError):
results.append(
{"date": date, "available": False, "temperature_c": None}
)
continue
cache_key = self._cache_key(date, lat, lon)
cached = cache.get(cache_key)
if cached is not None:
results.append(cached)
continue
payload = self._fetch_daily_temperature(date, lat, lon)
cache.set(cache_key, payload, timeout=self.CACHE_TIMEOUT_SECONDS)
results.append(payload)
return Response({"results": results}, status=status.HTTP_200_OK)
def _fetch_daily_temperature(self, date: str, latitude: float, longitude: float):
base_payload = {
"date": date,
"available": False,
"temperature_c": None,
}
for url in (self.OPEN_METEO_ARCHIVE_URL, self.OPEN_METEO_FORECAST_URL):
try:
response = requests.get(
url,
params={
"latitude": latitude,
"longitude": longitude,
"start_date": date,
"end_date": date,
"daily": "temperature_2m_max,temperature_2m_min",
"timezone": "UTC",
},
timeout=8,
)
response.raise_for_status()
data = response.json()
except requests.RequestException:
continue
except ValueError:
continue
daily = data.get("daily") or {}
max_values = daily.get("temperature_2m_max") or []
min_values = daily.get("temperature_2m_min") or []
if not max_values or not min_values:
continue
try:
avg = (float(max_values[0]) + float(min_values[0])) / 2
except (TypeError, ValueError, IndexError):
continue
return {
"date": date,
"available": True,
"temperature_c": round(avg, 1),
}
logger.info(
"No weather data available for date=%s lat=%s lon=%s",
date,
latitude,
longitude,
)
return base_payload
def _cache_key(self, date: str, latitude: float, longitude: float) -> str:
rounded_lat = round(latitude, 3)
rounded_lon = round(longitude, 3)
raw = f"{date}:{rounded_lat}:{rounded_lon}"
digest = hashlib.sha256(raw.encode()).hexdigest()
return f"weather_daily:{digest}"
def _parse_date(self, value: str):
try:
return date_cls.fromisoformat(value)
except ValueError:
return None

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

View File

@@ -28,88 +28,88 @@ BASE_DIR = os.path.dirname(os.path.dirname(__file__))
# Core Security & Debug
# ---------------------------------------------------------------------------
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = getenv('SECRET_KEY')
SECRET_KEY = getenv("SECRET_KEY")
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = getenv('DEBUG', 'true').lower() == 'true'
DEBUG = getenv("DEBUG", "true").lower() == "true"
# ALLOWED_HOSTS = [
# 'localhost',
# '127.0.0.1',
# 'server'
# ]
ALLOWED_HOSTS = ['*'] # In production, restrict to known hosts.
ALLOWED_HOSTS = ["*"] # In production, restrict to known hosts.
# ---------------------------------------------------------------------------
# Installed Apps
# ---------------------------------------------------------------------------
INSTALLED_APPS = (
"allauth_ui",
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.sites',
'rest_framework',
'rest_framework.authtoken',
'allauth',
'allauth.account',
'allauth.mfa',
'allauth.headless',
'allauth.socialaccount',
'allauth.socialaccount.providers.github',
'allauth.socialaccount.providers.openid_connect',
'invitations',
'drf_yasg',
'djmoney',
'corsheaders',
'adventures',
'worldtravel',
'users',
'integrations',
'django.contrib.gis',
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.sites",
"rest_framework",
"rest_framework.authtoken",
"allauth",
"allauth.account",
"allauth.mfa",
"allauth.headless",
"allauth.socialaccount",
"allauth.socialaccount.providers.github",
"allauth.socialaccount.providers.openid_connect",
"invitations",
"drf_yasg",
"djmoney",
"corsheaders",
"adventures",
"worldtravel",
"users",
"integrations",
"mcp_server",
"django.contrib.gis",
# 'achievements', # Not done yet, will be added later in a future update
'widget_tweaks',
'slippers',
"widget_tweaks",
"slippers",
)
# ---------------------------------------------------------------------------
# Middleware
# ---------------------------------------------------------------------------
MIDDLEWARE = (
'whitenoise.middleware.WhiteNoiseMiddleware',
'adventures.middleware.XSessionTokenMiddleware',
'adventures.middleware.DisableCSRFForSessionTokenMiddleware',
'adventures.middleware.DisableCSRFForMobileLoginSignup',
'corsheaders.middleware.CorsMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'adventures.middleware.OverrideHostMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'allauth.account.middleware.AccountMiddleware',
"whitenoise.middleware.WhiteNoiseMiddleware",
"adventures.middleware.XSessionTokenMiddleware",
"adventures.middleware.DisableCSRFForSessionTokenMiddleware",
"adventures.middleware.DisableCSRFForMobileLoginSignup",
"corsheaders.middleware.CorsMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"adventures.middleware.OverrideHostMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"allauth.account.middleware.AccountMiddleware",
)
# ---------------------------------------------------------------------------
# Caching
# ---------------------------------------------------------------------------
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.memcached.PyMemcacheCache',
'LOCATION': '127.0.0.1:11211',
'TIMEOUT': 60 * 60 * 24, # Optional: 1 day cache
"default": {
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
"LOCATION": "127.0.0.1:11211",
"TIMEOUT": 60 * 60 * 24, # Optional: 1 day cache
}
}
# For backwards compatibility for Django 1.8
MIDDLEWARE_CLASSES = MIDDLEWARE
ROOT_URLCONF = 'main.urls'
ROOT_URLCONF = "main.urls"
# WSGI_APPLICATION = 'demo.wsgi.application'
@@ -118,6 +118,7 @@ ROOT_URLCONF = 'main.urls'
# ---------------------------------------------------------------------------
# Using legacy PG environment variables for compatibility with existing setups
def env(*keys, default=None):
"""Return the first non-empty environment variable from a list of keys."""
for key in keys:
@@ -126,16 +127,17 @@ def env(*keys, default=None):
return value
return default
DATABASES = {
'default': {
'ENGINE': 'django.contrib.gis.db.backends.postgis',
'NAME': env('PGDATABASE', 'POSTGRES_DB'),
'USER': env('PGUSER', 'POSTGRES_USER'),
'PASSWORD': env('PGPASSWORD', 'POSTGRES_PASSWORD'),
'HOST': env('PGHOST', default='localhost'),
'PORT': int(env('PGPORT', default='5432')),
'OPTIONS': {
'sslmode': 'prefer', # Prefer SSL, but allow non-SSL connections
"default": {
"ENGINE": "django.contrib.gis.db.backends.postgis",
"NAME": env("PGDATABASE", "POSTGRES_DB"),
"USER": env("PGUSER", "POSTGRES_USER"),
"PASSWORD": env("PGPASSWORD", "POSTGRES_PASSWORD"),
"HOST": env("PGHOST", default="localhost"),
"PORT": int(env("PGPORT", default="5432")),
"OPTIONS": {
"sslmode": "prefer", # Prefer SSL, but allow non-SSL connections
},
}
}
@@ -146,8 +148,8 @@ DATABASES = {
# ---------------------------------------------------------------------------
# Internationalization
# ---------------------------------------------------------------------------
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_L10N = True
USE_TZ = True
@@ -156,20 +158,20 @@ USE_TZ = True
# Frontend URL & Cookies
# ---------------------------------------------------------------------------
# Derive frontend URL from environment and configure cookie behavior.
unParsedFrontenedUrl = getenv('FRONTEND_URL', 'http://localhost:3000')
FRONTEND_URL = unParsedFrontenedUrl.translate(str.maketrans('', '', '\'"'))
unParsedFrontenedUrl = getenv("FRONTEND_URL", "http://localhost:3000")
FRONTEND_URL = unParsedFrontenedUrl.translate(str.maketrans("", "", "'\""))
SESSION_COOKIE_SAMESITE = 'Lax'
SESSION_COOKIE_NAME = 'sessionid'
SESSION_COOKIE_SAMESITE = "Lax"
SESSION_COOKIE_NAME = "sessionid"
# Secure cookies if frontend is served over HTTPS
SESSION_COOKIE_SECURE = FRONTEND_URL.startswith('https')
CSRF_COOKIE_SECURE = FRONTEND_URL.startswith('https')
SESSION_COOKIE_SECURE = FRONTEND_URL.startswith("https")
CSRF_COOKIE_SECURE = FRONTEND_URL.startswith("https")
# Dynamically determine cookie domain to support subdomains while avoiding IPs
hostname = urlparse(FRONTEND_URL).hostname
is_ip_address = hostname.replace('.', '').isdigit()
is_single_label = '.' not in hostname # single-label hostnames (e.g., "localhost")
is_ip_address = hostname.replace(".", "").isdigit()
is_single_label = "." not in hostname # single-label hostnames (e.g., "localhost")
if is_ip_address or is_single_label:
SESSION_COOKIE_DOMAIN = None
@@ -181,15 +183,15 @@ else:
# ---------------------------------------------------------------------------
# Static & Media Files
# ---------------------------------------------------------------------------
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
BASE_DIR = Path(__file__).resolve().parent.parent
STATIC_ROOT = BASE_DIR / "staticfiles"
STATIC_URL = '/static/'
STATIC_URL = "/static/"
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media' # Must match NGINX root for media serving
STATICFILES_DIRS = [BASE_DIR / 'static']
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media" # Must match NGINX root for media serving
STATICFILES_DIRS = [BASE_DIR / "static"]
STORAGES = {
"staticfiles": {
@@ -197,7 +199,7 @@ STORAGES = {
},
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
}
},
}
SILENCED_SYSTEM_CHECKS = ["slippers.E001"]
@@ -207,15 +209,17 @@ SILENCED_SYSTEM_CHECKS = ["slippers.E001"]
# ---------------------------------------------------------------------------
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [os.path.join(BASE_DIR, 'templates'), ],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [
os.path.join(BASE_DIR, "templates"),
],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
@@ -226,24 +230,29 @@ ALLAUTH_UI_THEME = "dim"
# ---------------------------------------------------------------------------
# Authentication & Accounts
# ---------------------------------------------------------------------------
DISABLE_REGISTRATION = getenv('DISABLE_REGISTRATION', 'false').lower() == 'true'
DISABLE_REGISTRATION_MESSAGE = getenv('DISABLE_REGISTRATION_MESSAGE', 'Registration is disabled. Please contact the administrator if you need an account.')
DISABLE_REGISTRATION = getenv("DISABLE_REGISTRATION", "false").lower() == "true"
DISABLE_REGISTRATION_MESSAGE = getenv(
"DISABLE_REGISTRATION_MESSAGE",
"Registration is disabled. Please contact the administrator if you need an account.",
)
SOCIALACCOUNT_ALLOW_SIGNUP = getenv('SOCIALACCOUNT_ALLOW_SIGNUP', 'false').lower() == 'true'
SOCIALACCOUNT_ALLOW_SIGNUP = (
getenv("SOCIALACCOUNT_ALLOW_SIGNUP", "false").lower() == "true"
)
AUTH_USER_MODEL = 'users.CustomUser'
ACCOUNT_ADAPTER = 'users.adapters.CustomAccountAdapter'
AUTH_USER_MODEL = "users.CustomUser"
ACCOUNT_ADAPTER = "users.adapters.CustomAccountAdapter"
INVITATIONS_ADAPTER = ACCOUNT_ADAPTER
INVITATIONS_ACCEPT_INVITE_AFTER_SIGNUP = True
INVITATIONS_EMAIL_SUBJECT_PREFIX = 'Voyage: '
SOCIALACCOUNT_ADAPTER = 'users.adapters.CustomSocialAccountAdapter'
ACCOUNT_SIGNUP_FORM_CLASS = 'users.form_overrides.CustomSignupForm'
INVITATIONS_EMAIL_SUBJECT_PREFIX = "Voyage: "
SOCIALACCOUNT_ADAPTER = "users.adapters.CustomSocialAccountAdapter"
ACCOUNT_SIGNUP_FORM_CLASS = "users.form_overrides.CustomSignupForm"
SESSION_SAVE_EVERY_REQUEST = True
LOGIN_REDIRECT_URL = FRONTEND_URL # Redirect to frontend after login
SOCIALACCOUNT_LOGIN_ON_GET = True
INVITATIONS_INVITE_FORM = 'users.form_overrides.UseAdminInviteForm'
INVITATIONS_INVITE_FORM = "users.form_overrides.UseAdminInviteForm"
INVITATIONS_SIGNUP_REDIRECT_URL = f"{FRONTEND_URL}/signup"
HEADLESS_FRONTEND_URLS = {
@@ -256,34 +265,38 @@ HEADLESS_FRONTEND_URLS = {
}
AUTHENTICATION_BACKENDS = [
'users.backends.NoPasswordAuthBackend',
"users.backends.NoPasswordAuthBackend",
# 'allauth.account.auth_backends.AuthenticationBackend',
# 'django.contrib.auth.backends.ModelBackend',
]
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
SITE_ID = 1
ACCOUNT_EMAIL_REQUIRED = True
ACCOUNT_UNIQUE_EMAIL = True
ACCOUNT_EMAIL_VERIFICATION = getenv('ACCOUNT_EMAIL_VERIFICATION', 'none') # 'none', 'optional', 'mandatory'
ACCOUNT_EMAIL_VERIFICATION = getenv(
"ACCOUNT_EMAIL_VERIFICATION", "none"
) # 'none', 'optional', 'mandatory'
SOCIALACCOUNT_EMAIL_AUTHENTICATION = True
SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = True # Auto-link by email
SOCIALACCOUNT_AUTO_SIGNUP = True # Allow auto-signup post adapter checks
FORCE_SOCIALACCOUNT_LOGIN = getenv('FORCE_SOCIALACCOUNT_LOGIN', 'false').lower() == 'true' # When true, only social login is allowed (no password login) and the login page will show only social providers or redirect directly to the first provider if only one is configured.
FORCE_SOCIALACCOUNT_LOGIN = (
getenv("FORCE_SOCIALACCOUNT_LOGIN", "false").lower() == "true"
) # When true, only social login is allowed (no password login) and the login page will show only social providers or redirect directly to the first provider if only one is configured.
if getenv('EMAIL_BACKEND', 'console') == 'console':
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
if getenv("EMAIL_BACKEND", "console") == "console":
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
else:
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST = getenv('EMAIL_HOST')
EMAIL_USE_TLS = getenv('EMAIL_USE_TLS', 'true').lower() == 'true'
EMAIL_PORT = getenv('EMAIL_PORT', 587)
EMAIL_USE_SSL = getenv('EMAIL_USE_SSL', 'false').lower() == 'true'
EMAIL_HOST_USER = getenv('EMAIL_HOST_USER')
EMAIL_HOST_PASSWORD = getenv('EMAIL_HOST_PASSWORD')
DEFAULT_FROM_EMAIL = getenv('DEFAULT_FROM_EMAIL')
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = getenv("EMAIL_HOST")
EMAIL_USE_TLS = getenv("EMAIL_USE_TLS", "true").lower() == "true"
EMAIL_PORT = getenv("EMAIL_PORT", 587)
EMAIL_USE_SSL = getenv("EMAIL_USE_SSL", "false").lower() == "true"
EMAIL_HOST_USER = getenv("EMAIL_HOST_USER")
EMAIL_HOST_PASSWORD = getenv("EMAIL_HOST_PASSWORD")
DEFAULT_FROM_EMAIL = getenv("DEFAULT_FROM_EMAIL")
# EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
# EMAIL_HOST = 'smtp.resend.com'
@@ -299,63 +312,71 @@ else:
# Django REST Framework
# ---------------------------------------------------------------------------
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework.authentication.SessionAuthentication',
"DEFAULT_AUTHENTICATION_CLASSES": (
"rest_framework.authentication.SessionAuthentication",
),
'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema',
'DEFAULT_THROTTLE_CLASSES': [
'rest_framework.throttling.UserRateThrottle',
"DEFAULT_SCHEMA_CLASS": "rest_framework.schemas.coreapi.AutoSchema",
"DEFAULT_THROTTLE_CLASSES": [
"rest_framework.throttling.UserRateThrottle",
],
'DEFAULT_THROTTLE_RATES': {
'user': '1000/day',
'image_proxy': '60/minute',
"DEFAULT_THROTTLE_RATES": {
"user": "1000/day",
"image_proxy": "60/minute",
},
}
if DEBUG:
REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'] = (
'rest_framework.renderers.JSONRenderer',
'rest_framework.renderers.BrowsableAPIRenderer',
REST_FRAMEWORK["DEFAULT_RENDERER_CLASSES"] = (
"rest_framework.renderers.JSONRenderer",
"rest_framework.renderers.BrowsableAPIRenderer",
)
else:
REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'] = (
'rest_framework.renderers.JSONRenderer',
REST_FRAMEWORK["DEFAULT_RENDERER_CLASSES"] = (
"rest_framework.renderers.JSONRenderer",
)
# ---------------------------------------------------------------------------
# CORS & CSRF
# ---------------------------------------------------------------------------
CORS_ALLOWED_ORIGINS = [origin.strip() for origin in getenv('CSRF_TRUSTED_ORIGINS', 'http://localhost').split(',') if origin.strip()]
CSRF_TRUSTED_ORIGINS = [origin.strip() for origin in getenv('CSRF_TRUSTED_ORIGINS', 'http://localhost').split(',') if origin.strip()]
CORS_ALLOWED_ORIGINS = [
origin.strip()
for origin in getenv("CSRF_TRUSTED_ORIGINS", "http://localhost").split(",")
if origin.strip()
]
CSRF_TRUSTED_ORIGINS = [
origin.strip()
for origin in getenv("CSRF_TRUSTED_ORIGINS", "http://localhost").split(",")
if origin.strip()
]
CORS_ALLOW_CREDENTIALS = True
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'handlers': {
'console': {
'class': 'logging.StreamHandler',
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {
"class": "logging.StreamHandler",
},
'file': {
'class': 'logging.FileHandler',
'filename': 'scheduler.log',
"file": {
"class": "logging.FileHandler",
"filename": "scheduler.log",
},
},
'root': {
'handlers': ['console', 'file'],
'level': 'INFO',
"root": {
"handlers": ["console", "file"],
"level": "INFO",
},
'loggers': {
'django': {
'handlers': ['console', 'file'],
'level': 'INFO',
'propagate': False,
"loggers": {
"django": {
"handlers": ["console", "file"],
"level": "INFO",
"propagate": False,
},
},
}
@@ -363,18 +384,28 @@ LOGGING = {
# ---------------------------------------------------------------------------
# Public URLs & Third-Party Integrations
# ---------------------------------------------------------------------------
PUBLIC_URL = getenv('PUBLIC_URL', 'http://localhost:8000')
PUBLIC_URL = getenv("PUBLIC_URL", "http://localhost:8000")
# VOYAGE_CDN_URL = getenv('VOYAGE_CDN_URL', 'https://cdn.voyage.app')
# Major release version of Voyage, not including the patch version date.
VOYAGE_RELEASE_VERSION = 'v0.12.0'
VOYAGE_RELEASE_VERSION = "v0.12.0"
# https://github.com/dr5hn/countries-states-cities-database/tags
COUNTRY_REGION_JSON_VERSION = 'v3.0'
COUNTRY_REGION_JSON_VERSION = "v3.0"
# External service keys (do not hardcode secrets)
GOOGLE_MAPS_API_KEY = getenv('GOOGLE_MAPS_API_KEY', '')
STRAVA_CLIENT_ID = getenv('STRAVA_CLIENT_ID', '')
STRAVA_CLIENT_SECRET = getenv('STRAVA_CLIENT_SECRET', '')
OSRM_BASE_URL = getenv('OSRM_BASE_URL', 'https://router.project-osrm.org')
GOOGLE_MAPS_API_KEY = getenv("GOOGLE_MAPS_API_KEY", "")
STRAVA_CLIENT_ID = getenv("STRAVA_CLIENT_ID", "")
STRAVA_CLIENT_SECRET = getenv("STRAVA_CLIENT_SECRET", "")
OSRM_BASE_URL = getenv("OSRM_BASE_URL", "https://router.project-osrm.org")
FIELD_ENCRYPTION_KEY = getenv("FIELD_ENCRYPTION_KEY", "")
DJANGO_MCP_ENDPOINT = getenv("DJANGO_MCP_ENDPOINT", "api/mcp")
DJANGO_MCP_AUTHENTICATION_CLASSES = [
"rest_framework.authentication.TokenAuthentication",
]
DJANGO_MCP_ENDPOINT_PERMISSION_CLASSES = [
"rest_framework.permissions.IsAuthenticated",
]

View File

@@ -1,52 +1,100 @@
from django.urls import include, re_path, path
from django.contrib import admin
from django.views.generic import RedirectView, TemplateView
from users.views import IsRegistrationDisabled, PublicUserListView, PublicUserDetailView, UserMetadataView, UpdateUserMetadataView, EnabledSocialProvidersView, DisablePasswordAuthenticationView
from users.views import (
IsRegistrationDisabled,
PublicUserListView,
PublicUserDetailView,
UserMetadataView,
UpdateUserMetadataView,
EnabledSocialProvidersView,
DisablePasswordAuthenticationView,
)
from .views import get_csrf_token, get_public_url, serve_protected_media
from drf_yasg.views import get_schema_view
from drf_yasg import openapi
from mcp_server.views import MCPServerStreamableHttpView
from django.conf import settings
from django.utils.module_loading import import_string
schema_view = get_schema_view(
openapi.Info(
title='API Docs',
default_version='v1',
title="API Docs",
default_version="v1",
)
)
urlpatterns = [
path('api/', include('adventures.urls')),
path('api/', include('worldtravel.urls')),
path("api/", include("adventures.urls")),
path("api/", include("worldtravel.urls")),
path(
getattr(settings, "DJANGO_MCP_ENDPOINT", "api/mcp"),
MCPServerStreamableHttpView.as_view(
permission_classes=[
import_string(cls)
for cls in getattr(
settings,
"DJANGO_MCP_ENDPOINT_PERMISSION_CLASSES",
["rest_framework.permissions.IsAuthenticated"],
)
],
authentication_classes=[
import_string(cls)
for cls in getattr(
settings,
"DJANGO_MCP_AUTHENTICATION_CLASSES",
["rest_framework.authentication.TokenAuthentication"],
)
],
),
name="mcp_server_streamable_http_endpoint",
),
path("auth/", include("allauth.headless.urls")),
# Serve protected media files
re_path(r'^media/(?P<path>.*)$', serve_protected_media, name='serve-protected-media'),
path('auth/is-registration-disabled/', IsRegistrationDisabled.as_view(), name='is_registration_disabled'),
path('auth/users/', PublicUserListView.as_view(), name='public-user-list'),
path('auth/user/<str:username>/', PublicUserDetailView.as_view(), name='public-user-detail'),
path('auth/update-user/', UpdateUserMetadataView.as_view(), name='update-user-metadata'),
path('auth/user-metadata/', UserMetadataView.as_view(), name='user-metadata'),
path('auth/social-providers/', EnabledSocialProvidersView.as_view(), name='enabled-social-providers'),
path('auth/disable-password/', DisablePasswordAuthenticationView.as_view(), name='disable-password-authentication'),
path('csrf/', get_csrf_token, name='get_csrf_token'),
path('public-url/', get_public_url, name='get_public_url'),
path("invitations/", include('invitations.urls', namespace='invitations')),
path('', TemplateView.as_view(template_name='home.html')),
re_path(r'^admin/', admin.site.urls),
re_path(r'^accounts/profile/$', RedirectView.as_view(url='/',
permanent=True), name='profile-redirect'),
re_path(r'^docs/$', schema_view.with_ui('swagger',
cache_timeout=0), name='api_docs'),
re_path(
r"^media/(?P<path>.*)$", serve_protected_media, name="serve-protected-media"
),
path(
"auth/is-registration-disabled/",
IsRegistrationDisabled.as_view(),
name="is_registration_disabled",
),
path("auth/users/", PublicUserListView.as_view(), name="public-user-list"),
path(
"auth/user/<str:username>/",
PublicUserDetailView.as_view(),
name="public-user-detail",
),
path(
"auth/update-user/",
UpdateUserMetadataView.as_view(),
name="update-user-metadata",
),
path("auth/user-metadata/", UserMetadataView.as_view(), name="user-metadata"),
path(
"auth/social-providers/",
EnabledSocialProvidersView.as_view(),
name="enabled-social-providers",
),
path(
"auth/disable-password/",
DisablePasswordAuthenticationView.as_view(),
name="disable-password-authentication",
),
path("csrf/", get_csrf_token, name="get_csrf_token"),
path("public-url/", get_public_url, name="get_public_url"),
path("invitations/", include("invitations.urls", namespace="invitations")),
path("", TemplateView.as_view(template_name="home.html")),
re_path(r"^admin/", admin.site.urls),
re_path(
r"^accounts/profile/$",
RedirectView.as_view(url="/", permanent=True),
name="profile-redirect",
),
re_path(
r"^docs/$", schema_view.with_ui("swagger", cache_timeout=0), name="api_docs"
),
# path('auth/account-confirm-email/', VerifyEmailView.as_view(), name='account_email_verification_sent'),
path("accounts/", include("allauth.urls")),
path("api/integrations/", include("integrations.urls")),
# Include the API endpoints:
]
# Include the API endpoints:
]

View File

@@ -31,3 +31,5 @@ gpxpy==1.6.2
pymemcache==4.0.0
legacy-cgi==2.6.4
requests>=2.32.5
cryptography>=46.0.5
django-mcp-server>=0.5.7