[tasks] Clean up todoist scrobbling
All checks were successful
build & deploy / test (push) Successful in 1m47s
build & deploy / deploy (push) Successful in 21s

This commit is contained in:
2026-03-31 18:28:49 -04:00
parent 01aa0cba76
commit 2dbd752609
4 changed files with 147 additions and 96 deletions

View File

@ -1,8 +1,7 @@
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy
from django.http.response import HttpResponseBadRequest from django.http.response import HttpResponseBadRequest
from django.urls import reverse_lazy
from django.views.generic import FormView from django.views.generic import FormView
from profiles.forms import UserProfileForm from profiles.forms import UserProfileForm
@ -27,4 +26,5 @@ class ProfileFormView(LoginRequiredMixin, FormView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context["user_id"] = self.request.user.id context["user_id"] = self.request.user.id
context["profile"] = self.request.user.profile
return context return context

View File

@ -1,6 +1,6 @@
from django.urls import path from django.urls import path
from scrobbles import views from scrobbles import views
from tasks.webhooks import EmacsWebhookView, todoist_webhook from tasks.webhooks import EmacsWebhookView, TodoistWebhookView
app_name = "scrobbles" app_name = "scrobbles"
@ -82,7 +82,7 @@ urlpatterns = [
views.MopidyWebhookView.as_view(), views.MopidyWebhookView.as_view(),
name="mopidy-webhook", name="mopidy-webhook",
), ),
path("webhook/todoist/", todoist_webhook, name="todoist-webhook"), path("webhook/todoist/", TodoistWebhookView.as_view(), name="todoist-webhook"),
path("webhook/emacs/", EmacsWebhookView.as_view(), name="emacs_webhook"), path("webhook/emacs/", EmacsWebhookView.as_view(), name="emacs_webhook"),
path("export/", views.export, name="export"), path("export/", views.export, name="export"),
path( path(

View File

@ -1,11 +1,12 @@
import json import json
import logging import logging
from django.views.decorators.csrf import csrf_exempt from django.utils import timezone
from profiles.models import UserProfile from profiles.models import UserProfile
from rest_framework.decorators import permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework import status from rest_framework import status
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed
from rest_framework.permissions import BasePermission
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from scrobbles.scrobblers import ( from scrobbles.scrobblers import (
@ -18,11 +19,53 @@ from scrobbles.scrobblers import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@csrf_exempt class TodoistTokenAuthentication(BaseAuthentication):
@permission_classes([IsAuthenticated]) keyword = "Bearer"
def todoist_webhook(request):
def authenticate(self, request):
auth_header = request.META.get("HTTP_AUTHORIZATION", "")
if not auth_header:
return None
parts = auth_header.split()
if len(parts) != 2 or parts[0] != self.keyword:
return None
token = parts[1]
profile = UserProfile.objects.filter(todoist_auth_key=token).first()
if not profile:
raise AuthenticationFailed("Invalid token")
if not profile.user:
raise AuthenticationFailed("No user associated with token")
return (profile.user, profile)
class IsAuthenticatedForTodoist(BasePermission):
def has_permission(self, request):
return (
hasattr(request, "user") and request.user and request.user.is_authenticated
)
class TodoistWebhookView(APIView):
authentication_classes = [TodoistTokenAuthentication]
permission_classes = [IsAuthenticatedForTodoist]
@property
def logger(self):
return logger
def post(self, request):
user_profile = request.auth
post_data = request.data post_data = request.data
logger.info( self.logger.info(
"[todoist_webhook] called", "[todoist_webhook] called",
extra={"post_data": post_data}, extra={"post_data": post_data},
) )
@ -31,12 +74,11 @@ def todoist_webhook(request):
todoist_type, todoist_event = post_data.get("event_name").split(":") todoist_type, todoist_event = post_data.get("event_name").split(":")
event_data = post_data.get("event_data", {}) event_data = post_data.get("event_data", {})
is_item_type = todoist_type == "item" is_item_type = todoist_type == "item"
is_note_type = todoist_tyllll = "note" is_note_type = todoist_type == "note"
new_labels = event_data.get("labels", []) new_labels = event_data.get("labels", [])
old_labels = ( old_labels = (
post_data.get("event_data_extra", {}).get("old_item", {}).get("labels", []) post_data.get("event_data_extra", {}).get("old_item", {}).get("labels", [])
) )
# TODO Don't hard code status strings in here
is_updated = todoist_event in ["updated"] is_updated = todoist_event in ["updated"]
is_added = todoist_event in ["added"] is_added = todoist_event in ["added"]
@ -65,11 +107,13 @@ def todoist_webhook(request):
"updated_at": task_data.get("updated_at"), "updated_at": task_data.get("updated_at"),
"details": task_data.get("description"), "details": task_data.get("description"),
"notes": event_data.get("content"), "notes": event_data.get("content"),
"is_deleted": (True if event_data.get("is_deleted") == "true" else False), "is_deleted": (
True if event_data.get("is_deleted") == "true" else False
),
} }
if (is_added and not todoist_note) or (is_updated and not todoist_task): if (is_added and not todoist_note) or (is_updated and not todoist_task):
logger.info( self.logger.info(
"[todoist_webhook] ignoring wrong todoist type, event or labels", "[todoist_webhook] ignoring wrong todoist type, event or labels",
extra={ extra={
"todoist_type": todoist_type, "todoist_type": todoist_type,
@ -82,10 +126,6 @@ def todoist_webhook(request):
) )
return Response({}, status=status.HTTP_304_NOT_MODIFIED) return Response({}, status=status.HTTP_304_NOT_MODIFIED)
user_profile = UserProfile.objects.filter(
todoist_user_id=post_data.get("user_id", None)
).first()
scrobble = None scrobble = None
if todoist_task: if todoist_task:
scrobble = todoist_scrobble_task( scrobble = todoist_scrobble_task(
@ -99,7 +139,7 @@ def todoist_webhook(request):
scrobble = todoist_scrobble_update_task(todoist_note, user_profile.user_id) scrobble = todoist_scrobble_update_task(todoist_note, user_profile.user_id)
if not scrobble: if not scrobble:
logger.info( self.logger.info(
"[todoist_webhook] finished with no note or task found", "[todoist_webhook] finished with no note or task found",
extra={"scrobble_id": None}, extra={"scrobble_id": None},
) )
@ -108,7 +148,7 @@ def todoist_webhook(request):
status=status.HTTP_304_NOT_MODIFIED, status=status.HTTP_304_NOT_MODIFIED,
) )
logger.info( self.logger.info(
"[todoist_webhook] finished", "[todoist_webhook] finished",
extra={"scrobble_id": scrobble.id}, extra={"scrobble_id": scrobble.id},
) )

View File

@ -138,4 +138,15 @@
{% endfor %} {% endfor %}
<input type="submit" value="Save"> <input type="submit" value="Save">
</form> </form>
<div class="widget-links" style="margin-top: 16px;">
<strong>Todoist Integration:</strong>
{% if profile.todoist_auth_key %}
<p>Connected to Todoist</p>
<a href="{% url 'tasks:task_todoist_auth' %}" class="btn btn-sm btn-secondary">Reconnect to Todoist</a>
{% else %}
<p>Not connected</p>
<a href="{% url 'tasks:task_todoist_auth' %}" class="btn btn-sm btn-primary">Connect to Todoist</a>
{% endif %}
</div>
{% endblock %} {% endblock %}