1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-08-05 13:15:18 +02:00

feat: add Wanderer integration with authentication and management features

This commit is contained in:
Sean Morley 2025-08-04 13:34:59 -04:00
parent 2c23b6ca57
commit 9fd06911b2
14 changed files with 462 additions and 26 deletions

View file

@ -0,0 +1,17 @@
# Generated by Django 5.2.2 on 2025-08-04 16:40
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('adventures', '0058_alter_collectioninvite_options_activity'),
]
operations = [
migrations.AlterModelOptions(
name='activity',
options={'verbose_name': 'Activity', 'verbose_name_plural': 'Activities'},
),
]

View file

@ -1,10 +1,11 @@
from django.contrib import admin from django.contrib import admin
from allauth.account.decorators import secure_admin_login from allauth.account.decorators import secure_admin_login
from .models import ImmichIntegration, StravaToken from .models import ImmichIntegration, StravaToken, WandererIntegration
admin.autodiscover() admin.autodiscover()
admin.site.login = secure_admin_login(admin.site.login) admin.site.login = secure_admin_login(admin.site.login)
admin.site.register(ImmichIntegration) admin.site.register(ImmichIntegration)
admin.site.register(StravaToken) admin.site.register(StravaToken)
admin.site.register(WandererIntegration)

View file

@ -0,0 +1,32 @@
# Generated by Django 5.2.2 on 2025-08-04 16:40
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('integrations', '0003_stravatoken'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='WandererIntegration',
fields=[
('server_url', models.CharField(max_length=255)),
('username', models.CharField(max_length=255)),
('token', models.CharField(blank=True, max_length=255, null=True)),
('token_expiry', models.DateTimeField(blank=True, null=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='wanderer_integrations', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Wanderer Integration',
'verbose_name_plural': 'Wanderer Integrations',
},
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2.2 on 2025-08-04 16:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('integrations', '0004_wandererintegration'),
]
operations = [
migrations.AlterField(
model_name='wandererintegration',
name='token',
field=models.CharField(blank=True, max_length=1000, null=True),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2.2 on 2025-08-04 17:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('integrations', '0005_alter_wandererintegration_token'),
]
operations = [
migrations.AlterField(
model_name='wandererintegration',
name='token',
field=models.CharField(blank=True, null=True),
),
]

View file

@ -24,3 +24,19 @@ class StravaToken(models.Model):
athlete_id = models.BigIntegerField(null=True, blank=True) athlete_id = models.BigIntegerField(null=True, blank=True)
scope = models.CharField(max_length=255, null=True, blank=True) scope = models.CharField(max_length=255, null=True, blank=True)
updated_at = models.DateTimeField(auto_now=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')
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)
def __str__(self):
return self.user.username + ' - ' + self.server_url
class Meta:
verbose_name = "Wanderer Integration"
verbose_name_plural = "Wanderer Integrations"

View file

@ -1,7 +1,7 @@
from integrations.views import * from integrations.views import *
from django.urls import path, include from django.urls import path, include
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from integrations.views import IntegrationView, StravaIntegrationView from integrations.views import IntegrationView, StravaIntegrationView, WandererIntegrationViewSet
# Create the router and register the ViewSet # Create the router and register the ViewSet
router = DefaultRouter() router = DefaultRouter()
@ -9,6 +9,7 @@ router.register(r'immich', ImmichIntegrationView, basename='immich')
router.register(r'', IntegrationView, basename='integrations') router.register(r'', IntegrationView, basename='integrations')
router.register(r'immich', ImmichIntegrationViewSet, basename='immich_viewset') router.register(r'immich', ImmichIntegrationViewSet, basename='immich_viewset')
router.register(r'strava', StravaIntegrationView, basename='strava') router.register(r'strava', StravaIntegrationView, basename='strava')
router.register(r'wanderer', WandererIntegrationViewSet, basename='wanderer')
# Include the router URLs # Include the router URLs
urlpatterns = [ urlpatterns = [

View file

@ -1,3 +1,4 @@
from .immich_view import ImmichIntegrationView, ImmichIntegrationViewSet from .immich_view import ImmichIntegrationView, ImmichIntegrationViewSet
from .integration_view import IntegrationView from .integration_view import IntegrationView
from .strava_view import StravaIntegrationView from .strava_view import StravaIntegrationView
from .wanderer_view import WandererIntegrationViewSet

View file

@ -2,7 +2,7 @@ import os
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import viewsets, status from rest_framework import viewsets, status
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from integrations.models import ImmichIntegration, StravaToken from integrations.models import ImmichIntegration, StravaToken, WandererIntegration
from django.conf import settings from django.conf import settings
@ -16,6 +16,7 @@ class IntegrationView(viewsets.ViewSet):
google_map_integration = settings.GOOGLE_MAPS_API_KEY != '' google_map_integration = settings.GOOGLE_MAPS_API_KEY != ''
strava_integration_global = settings.STRAVA_CLIENT_ID != '' and settings.STRAVA_CLIENT_SECRET != '' strava_integration_global = settings.STRAVA_CLIENT_ID != '' and settings.STRAVA_CLIENT_SECRET != ''
strava_integration_user = StravaToken.objects.filter(user=request.user).exists() strava_integration_user = StravaToken.objects.filter(user=request.user).exists()
wanderer_integration = WandererIntegration.objects.filter(user=request.user).exists()
return Response( return Response(
{ {
@ -24,7 +25,8 @@ class IntegrationView(viewsets.ViewSet):
'strava': { 'strava': {
'global': strava_integration_global, 'global': strava_integration_global,
'user': strava_integration_user 'user': strava_integration_user
} },
'wanderer': wanderer_integration
}, },
status=status.HTTP_200_OK status=status.HTTP_200_OK
) )

View file

@ -0,0 +1,141 @@
# views.py
import requests
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.exceptions import ValidationError, NotFound
from integrations.models import WandererIntegration
from integrations.wanderer_services import get_valid_session, login_to_wanderer, IntegrationError
from django.utils import timezone
class WandererIntegrationViewSet(viewsets.ViewSet):
permission_classes = [IsAuthenticated]
def _get_obj(self):
try:
return WandererIntegration.objects.filter(user=self.request.user).first()
except WandererIntegration.DoesNotExist:
raise NotFound("Wanderer integration not found.")
# def list(self, request):
# try:
# inst = self._get_obj()
# except NotFound:
# return Response([], status=status.HTTP_200_OK)
# return Response({
# "id": inst.id,
# "server_url": inst.server_url,
# "username": inst.username,
# "is_connected": bool(inst.token and inst.token_expiry and inst.token_expiry > timezone.now()),
# "token_expiry": inst.token_expiry,
# })
def create(self, request):
if WandererIntegration.objects.filter(user=request.user).exists():
raise ValidationError("Wanderer integration already exists. Use UPDATE instead.")
server_url = request.data.get("server_url")
username = request.data.get("username")
password = request.data.get("password")
if not server_url or not username or not password:
raise ValidationError(
"Must provide server_url, username + password in request data."
)
inst = WandererIntegration(
user=request.user,
server_url=server_url.rstrip("/"),
username=username,
)
try:
token, expiry = login_to_wanderer(inst, password)
except IntegrationError as e:
raise ValidationError({"error": str(e)})
inst.token = token
inst.token_expiry = expiry
inst.save()
return Response(
{"message": "Wanderer integration created and authenticated successfully."},
status=status.HTTP_201_CREATED,
)
def update(self, request, pk=None):
inst = self._get_obj()
changed = False
for field in ("server_url", "username"):
if field in request.data and getattr(inst, field) != request.data[field]:
setattr(inst, field, request.data[field].rstrip("/") if field=="server_url" else request.data[field])
changed = True
password = request.data.get("password")
if not changed and not password:
return Response(
{"detail": "Nothing updated: send at least one of server_url, username, or password."},
status=status.HTTP_400_BAD_REQUEST,
)
# If password provided: re-auth / token renewal
if password:
try:
token, expiry = login_to_wanderer(inst, password)
except IntegrationError as e:
raise ValidationError({"error": str(e)})
inst.token = token
inst.token_expiry = expiry
inst.save()
return Response({"message": "Integration updated successfully."})
@action(detail=False, methods=["post"])
def disable(self, request):
inst = self._get_obj()
inst.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=False, methods=["post"])
def refresh(self, request):
inst = self._get_obj()
password = request.data.get("password")
try:
session = get_valid_session(inst, password_for_reauth=password)
except IntegrationError as e:
raise ValidationError({"detail": str(e)})
return Response({
"token": inst.token,
"token_expiry": inst.token_expiry,
"is_connected": True,
})
@action(detail=False, methods=["get"], url_path='list')
def list_trails(self, request):
inst = self._get_obj()
# Check if we need to prompt for password
password = request.query_params.get("password") # Allow password via query param if needed
try:
session = get_valid_session(inst, password_for_reauth=password)
except IntegrationError as e:
# If session expired and no password provided, give a helpful error
if "password is required" in str(e).lower():
raise ValidationError({
"detail": str(e),
"requires_password": True
})
raise ValidationError({"detail": str(e)})
url = f"{inst.server_url.rstrip('/')}/api/v1/list"
try:
response = session.get(url, timeout=10)
response.raise_for_status()
except requests.RequestException as exc:
raise ValidationError({"detail": f"Error fetching trails: {exc}"})
return Response(response.json())

View file

@ -0,0 +1,111 @@
# wanderer_services.py
import requests
from datetime import datetime
from datetime import timezone as dt_timezone
from django.utils import timezone as django_timezone
import logging
from .models import WandererIntegration
logger = logging.getLogger(__name__)
class IntegrationError(Exception):
pass
# Use both possible cookie names
COOKIE_NAMES = ("pb_auth", "pb-auth")
LOGIN_PATH = "/api/v1/auth/login"
def login_to_wanderer(integration: WandererIntegration, password: str):
"""
Authenticate with Wanderer and return the auth cookie and expiry.
"""
url = integration.server_url.rstrip("/") + LOGIN_PATH
try:
resp = requests.post(url, json={
"username": integration.username,
"password": password
}, timeout=10)
resp.raise_for_status()
except requests.RequestException as exc:
logger.error("Error connecting to Wanderer login: %s", exc)
raise IntegrationError("Could not connect to Wanderer server.")
# Log only summary (not full token body)
logger.debug("Wanderer login status: %s, headers: %s", resp.status_code, resp.headers.get("Set-Cookie"))
# Extract auth cookie and expiry
token = None
expiry = None
for cookie in resp.cookies:
if cookie.name in COOKIE_NAMES:
token = cookie.value
if cookie.expires:
expiry = datetime.fromtimestamp(cookie.expires, tz=dt_timezone.utc)
else:
# If no expiry set, assume 24 hours from now
expiry = django_timezone.now() + django_timezone.timedelta(hours=24)
break
if not token:
logger.error("Wanderer login succeeded but no auth cookie in response.")
raise IntegrationError("Authentication succeeded, but token cookie not found.")
logger.info(f"Successfully authenticated with Wanderer. Token expires: {expiry}")
return token, expiry
def get_valid_session(integration: WandererIntegration, password_for_reauth: str = None):
"""
Get a requests session with valid authentication.
Will reuse existing token if valid, or re-authenticate if needed.
"""
now = django_timezone.now()
session = requests.Session()
# Check if we have a valid token
if integration.token and integration.token_expiry and integration.token_expiry > now:
logger.debug("Using existing valid token")
session.cookies.set(COOKIE_NAMES[0], integration.token)
return session
# Token expired or missing - need to re-authenticate
if password_for_reauth is None:
raise IntegrationError("Session expired; password is required to reconnect.")
logger.info("Token expired, re-authenticating with Wanderer")
token, expiry = login_to_wanderer(integration, password_for_reauth)
# Update the integration with new token
integration.token = token
integration.token_expiry = expiry
integration.save(update_fields=["token", "token_expiry"])
# Set the cookie in the session
session.cookies.set(COOKIE_NAMES[0], token)
return session
def make_wanderer_request(integration: WandererIntegration, endpoint: str, method: str = "GET", password_for_reauth: str = None, **kwargs):
"""
Helper function to make authenticated requests to Wanderer API.
Args:
integration: WandererIntegration instance
endpoint: API endpoint (e.g., '/api/v1/list')
method: HTTP method (GET, POST, etc.)
password_for_reauth: Password to use if re-authentication is needed
**kwargs: Additional arguments to pass to requests method
Returns:
requests.Response object
"""
session = get_valid_session(integration, password_for_reauth)
url = f"{integration.server_url.rstrip('/')}{endpoint}"
try:
response = getattr(session, method.lower())(url, timeout=10, **kwargs)
response.raise_for_status()
return response
except requests.RequestException as exc:
logger.error(f"Error making {method} request to {url}: {exc}")
raise IntegrationError(f"Error communicating with Wanderer: {exc}")

View file

@ -799,5 +799,10 @@
"authorization_error": "Error redirecting to strava authorization URL", "authorization_error": "Error redirecting to strava authorization URL",
"disconnected": "Successfully disconnected from Strava", "disconnected": "Successfully disconnected from Strava",
"disconnect_error": "Error disconnecting from Strava" "disconnect_error": "Error disconnecting from Strava"
},
"wanderer": {
"wanderer_integration_desc": "Connect to Wanderer to easily import and view your trails in locations",
"connection_error": "Error connecting to Wanderer",
"connected": "Successfully connected to Wanderer"
} }
} }

View file

@ -82,6 +82,7 @@ export const load: PageServerLoad = async (event) => {
let googleMapsEnabled = integrations.google_maps as boolean; let googleMapsEnabled = integrations.google_maps as boolean;
let stravaGlobalEnabled = integrations.strava.global as boolean; let stravaGlobalEnabled = integrations.strava.global as boolean;
let stravaUserEnabled = integrations.strava.user as boolean; let stravaUserEnabled = integrations.strava.user as boolean;
let wandererEnabled = integrations.wanderer as boolean;
let publicUrlFetch = await fetch(`${endpoint}/public-url/`); let publicUrlFetch = await fetch(`${endpoint}/public-url/`);
let publicUrl = ''; let publicUrl = '';
@ -102,7 +103,8 @@ export const load: PageServerLoad = async (event) => {
socialProviders, socialProviders,
googleMapsEnabled, googleMapsEnabled,
stravaGlobalEnabled, stravaGlobalEnabled,
stravaUserEnabled stravaUserEnabled,
wandererEnabled
} }
}; };
}; };

View file

@ -28,6 +28,7 @@
let googleMapsEnabled = data.props.googleMapsEnabled; let googleMapsEnabled = data.props.googleMapsEnabled;
let stravaGlobalEnabled = data.props.stravaGlobalEnabled; let stravaGlobalEnabled = data.props.stravaGlobalEnabled;
let stravaUserEnabled = data.props.stravaUserEnabled; let stravaUserEnabled = data.props.stravaUserEnabled;
let wandererEnabled = data.props.wandererEnabled;
let activeSection: string = 'profile'; let activeSection: string = 'profile';
// Initialize activeSection from URL on mount // Initialize activeSection from URL on mount
@ -55,6 +56,12 @@
copy_locally: true copy_locally: true
}; };
let newWandererIntegration = {
server_url: '',
username: '',
password: ''
};
let isMFAModalOpen: boolean = false; let isMFAModalOpen: boolean = false;
const sections = [ const sections = [
@ -321,6 +328,39 @@
addToast('error', $t('strava.disconnect_error')); addToast('error', $t('strava.disconnect_error'));
} }
} }
async function wandererDisconnect() {
const res = await fetch('/api/integrations/wanderer/disable/', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (res.ok) {
addToast('success', $t('wanderer.disconnected'));
wandererEnabled = false;
} else {
addToast('error', $t('wanderer.disconnect_error'));
}
}
async function wandererConnect() {
const res = await fetch('/api/integrations/wanderer/', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(newWandererIntegration)
});
if (res.ok) {
addToast('success', $t('wanderer.connected'));
wandererEnabled = true;
newWandererIntegration = { server_url: '', username: '', password: '' };
} else {
const data = await res.json();
addToast('error', $t('wanderer.connection_error'));
}
}
</script> </script>
{#if isMFAModalOpen} {#if isMFAModalOpen}
@ -1054,10 +1094,10 @@
<div> <div>
<h3 class="text-xl font-bold">Wanderer</h3> <h3 class="text-xl font-bold">Wanderer</h3>
<p class="text-sm text-base-content/70"> <p class="text-sm text-base-content/70">
{$t('strava.strava_integration_desc')} {$t('wanderer.wanderer_integration_desc')}
</p> </p>
</div> </div>
{#if stravaGlobalEnabled && stravaUserEnabled} {#if wandererEnabled}
<div class="badge badge-success ml-auto">{$t('settings.connected')}</div> <div class="badge badge-success ml-auto">{$t('settings.connected')}</div>
{:else} {:else}
<div class="badge badge-error ml-auto">{$t('settings.disconnected')}</div> <div class="badge badge-error ml-auto">{$t('settings.disconnected')}</div>
@ -1065,25 +1105,56 @@
</div> </div>
<!-- Content based on integration status --> <!-- Content based on integration status -->
{#if !stravaGlobalEnabled} {#if !wandererEnabled}
<!-- Strava not enabled globally --> <!-- login form with server url username and password -->
<div class="text-center"> <div class="space-y-4">
<p class="text-base-content/70 mb-4"> <div class="form-control">
{$t('strava.not_enabled') || <!-- svelte-ignore a11y-label-has-associated-control -->
'Strava integration is not enabled on this instance.'} <label class="label">
</p> <span class="label-text font-medium">Server URL</span>
</div> </label>
{:else if !stravaUserEnabled && stravaGlobalEnabled} <input
<!-- Globally enabled but user not connected --> type="url"
<div class="text-center"> class="input input-bordered input-primary focus:input-primary"
<button class="btn btn-primary" on:click={stravaAuthorizeRedirect}> placeholder="https://wanderer.example.com"
🔗 {$t('strava.connect_account')} bind:value={newWandererIntegration.server_url}
/>
</div>
<div class="form-control">
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="label">
<span class="label-text font-medium">Username</span>
</label>
<input
type="text"
class="input input-bordered input-primary focus:input-primary"
placeholder="Enter your username"
bind:value={newWandererIntegration.username}
/>
</div>
<div class="form-control">
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="label">
<span class="label-text font-medium">Password</span>
</label>
<input
type="password"
class="input input-bordered input-primary focus:input-primary"
placeholder="Enter your password"
bind:value={newWandererIntegration.password}
/>
</div>
<button class="btn btn-primary w-full" on:click={wandererConnect}>
🔗 Connect to Wanderer
</button> </button>
</div> </div>
{:else if stravaGlobalEnabled && stravaUserEnabled} {:else}
<!-- User connected - show management options --> <!-- User connected - show management options -->
<div class="text-center"> <div class="text-center">
<button class="btn btn-error" on:click={stravaDisconnect}> <button class="btn btn-error" on:click={wandererDisconnect}>
{$t('strava.disconnect')} {$t('strava.disconnect')}
</button> </button>
</div> </div>
@ -1097,7 +1168,7 @@
📖 {$t('immich.need_help')} 📖 {$t('immich.need_help')}
<a <a
class="link link-primary" class="link link-primary"
href="https://adventurelog.app/docs/configuration/strava_integration.html" href="https://adventurelog.app/docs/configuration/wanderer_integration.html"
target="_blank">{$t('navbar.documentation')}</a target="_blank">{$t('navbar.documentation')}</a
> >
</p> </p>