feat: ship MVP itinerary optimization, weather, AI key prefs, and MCP tools
This commit is contained in:
160
backend/server/adventures/mcp.py
Normal file
160
backend/server/adventures/mcp.py
Normal 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
|
||||
@@ -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])
|
||||
|
||||
@@ -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)),
|
||||
]
|
||||
|
||||
@@ -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
154
backend/server/adventures/views/weather_view.py
Normal file
154
backend/server/adventures/views/weather_view.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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")},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
33
backend/server/integrations/views/user_api_key_view.py
Normal file
33
backend/server/integrations/views/user_api_key_view.py
Normal 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")
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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:
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user