Fix Bluesky integration caching and API session management

- Fix stale cache issue: assignment pages now show updated data immediately
- Implement persistent session management to reduce createSession API calls
- Add robust error handling for cache operations when Memcached unavailable
- Eliminate code duplication in get_profile method with _make_profile_request
- Add Bluesky credentials configuration to config_local.py.example

Resolves caching problems and API rate limiting issues in development and production.
This commit is contained in:
Oliver Falk
2025-10-16 11:37:47 +02:00
parent 844aca54a0
commit 8b04c170ec
4 changed files with 140 additions and 13 deletions

View File

@@ -44,3 +44,7 @@ import os
# Example: Override logs directory for production # Example: Override logs directory for production
# LOGS_DIR = "/var/log/ivatar" # LOGS_DIR = "/var/log/ivatar"
# Bluesky integration credentials
# BLUESKY_IDENTIFIER = "your-bluesky-handle"
# BLUESKY_APP_PASSWORD = "your-app-password"

View File

@@ -398,9 +398,28 @@ class ConfirmedEmail(BaseAccountModel):
) )
cache_key = f"views.decorators.cache.cache_page.{quote(str(cache_url))}" cache_key = f"views.decorators.cache.cache_page.{quote(str(cache_url))}"
if cache.has_key(cache_key): try:
cache.delete(cache_key) if cache.has_key(cache_key):
logger.debug("Successfully cleaned up cached page: %s" % cache_key) cache.delete(cache_key)
logger.debug("Successfully cleaned up cached page: %s" % cache_key)
except Exception as exc:
logger.warning(
"Failed to clean up cached page %s: %s" % (cache_key, exc)
)
# Invalidate Bluesky avatar URL cache if bluesky_handle changed
if hasattr(self, "bluesky_handle") and self.bluesky_handle:
try:
cache.delete(self.bluesky_handle)
logger.debug(
"Successfully cleaned up Bluesky avatar cache for handle: %s"
% self.bluesky_handle
)
except Exception as exc:
logger.warning(
"Failed to clean up Bluesky avatar cache for handle %s: %s"
% (self.bluesky_handle, exc)
)
return super().save(force_insert, force_update, using, update_fields) return super().save(force_insert, force_update, using, update_fields)
@@ -570,6 +589,36 @@ class ConfirmedOpenId(BaseAccountModel):
openid_variations(lowercase_url)[3].encode("utf-8") openid_variations(lowercase_url)[3].encode("utf-8")
).hexdigest() ).hexdigest()
# Invalidate page caches and Bluesky avatar cache
if self.pk:
# Invalidate assign_photo_openid page cache
cache_url = reverse_lazy(
"assign_photo_openid", kwargs={"openid_id": int(self.pk)}
)
cache_key = f"views.decorators.cache.cache_page.{quote(str(cache_url))}"
try:
if cache.has_key(cache_key):
cache.delete(cache_key)
logger.debug("Successfully cleaned up cached page: %s" % cache_key)
except Exception as exc:
logger.warning(
"Failed to clean up cached page %s: %s" % (cache_key, exc)
)
# Invalidate Bluesky avatar URL cache if bluesky_handle exists
if hasattr(self, "bluesky_handle") and self.bluesky_handle:
try:
cache.delete(self.bluesky_handle)
logger.debug(
"Successfully cleaned up Bluesky avatar cache for handle: %s"
% self.bluesky_handle
)
except Exception as exc:
logger.warning(
"Failed to clean up Bluesky avatar cache for handle %s: %s"
% (self.bluesky_handle, exc)
)
return super().save(force_insert, force_update, using, update_fields) return super().save(force_insert, force_update, using, update_fields)
def __str__(self): def __str__(self):

View File

@@ -23,7 +23,7 @@ django.setup()
# pylint: disable=wrong-import-position # pylint: disable=wrong-import-position
from ivatar import settings from ivatar import settings
from ivatar.ivataraccount.models import ConfirmedOpenId, ConfirmedEmail from ivatar.ivataraccount.models import ConfirmedOpenId, ConfirmedEmail
from ivatar.utils import random_string from ivatar.utils import random_string, Bluesky
from libravatar import libravatar_url from libravatar import libravatar_url
@@ -63,6 +63,16 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
) )
settings.EMAIL_BACKEND = "django.core.mail.backends.dummy.EmailBackend" settings.EMAIL_BACKEND = "django.core.mail.backends.dummy.EmailBackend"
# Clear any existing Bluesky session to ensure clean test state
Bluesky.clear_shared_session()
def tearDown(self):
"""
Clean up after tests
"""
# Clear Bluesky session to avoid affecting other tests
Bluesky.clear_shared_session()
def create_confirmed_openid(self): def create_confirmed_openid(self):
""" """
Create a confirmed openid Create a confirmed openid

View File

@@ -36,13 +36,15 @@ def urlopen(url, timeout=URL_TIMEOUT):
class Bluesky: class Bluesky:
""" """
Handle Bluesky client access Handle Bluesky client access with persistent session management
""" """
identifier = "" identifier = ""
app_password = "" app_password = ""
service = "https://bsky.social" service = "https://bsky.social"
session = None session = None
_shared_session = None # Class-level shared session
_session_expires_at = None # Track session expiration
def __init__( def __init__(
self, self,
@@ -54,10 +56,29 @@ class Bluesky:
self.app_password = app_password self.app_password = app_password
self.service = service self.service = service
def _is_session_valid(self) -> bool:
"""
Check if the current session is still valid
"""
if not self._shared_session or not self._session_expires_at:
return False
import time
# Add 5 minute buffer before actual expiration
return time.time() < (self._session_expires_at - 300)
def login(self): def login(self):
""" """
Login to Bluesky Login to Bluesky with session persistence
""" """
# Use shared session if available and valid
if self._is_session_valid():
self.session = self._shared_session
logger.debug("Reusing existing Bluesky session")
return
logger.debug("Creating new Bluesky session")
auth_response = requests.post( auth_response = requests.post(
f"{self.service}/xrpc/com.atproto.server.createSession", f"{self.service}/xrpc/com.atproto.server.createSession",
json={"identifier": self.identifier, "password": self.app_password}, json={"identifier": self.identifier, "password": self.app_password},
@@ -65,6 +86,29 @@ class Bluesky:
auth_response.raise_for_status() auth_response.raise_for_status()
self.session = auth_response.json() self.session = auth_response.json()
# Store session data for reuse
self._shared_session = self.session
import time
# Sessions typically expire in 24 hours, but we'll refresh every 12 hours
self._session_expires_at = time.time() + (12 * 60 * 60)
logger.debug(
"Created new Bluesky session, expires at: %s",
time.strftime(
"%Y-%m-%d %H:%M:%S", time.localtime(self._session_expires_at)
),
)
@classmethod
def clear_shared_session(cls):
"""
Clear the shared session (useful for testing)
"""
cls._shared_session = None
cls._session_expires_at = None
logger.debug("Cleared shared Bluesky session")
def normalize_handle(self, handle: str) -> str: def normalize_handle(self, handle: str) -> str:
""" """
Return the normalized handle for given handle Return the normalized handle for given handle
@@ -79,11 +123,10 @@ class Bluesky:
handle = handle[:-1] handle = handle[:-1]
return handle return handle
def get_profile(self, handle: str) -> str: def _make_profile_request(self, handle: str):
if not self.session: """
self.login() Make a profile request to Bluesky API with automatic retry on session expiration
profile_response = None """
try: try:
profile_response = requests.get( profile_response = requests.get(
f"{self.service}/xrpc/app.bsky.actor.getProfile", f"{self.service}/xrpc/app.bsky.actor.getProfile",
@@ -91,11 +134,32 @@ class Bluesky:
params={"actor": handle}, params={"actor": handle},
) )
profile_response.raise_for_status() profile_response.raise_for_status()
return profile_response.json()
except requests.exceptions.HTTPError as exc:
if exc.response.status_code == 401:
# Session expired, try to login again
logger.warning("Bluesky session expired, re-authenticating")
self.clear_shared_session()
self.login()
# Retry the request
profile_response = requests.get(
f"{self.service}/xrpc/app.bsky.actor.getProfile",
headers={"Authorization": f'Bearer {self.session["accessJwt"]}'},
params={"actor": handle},
)
profile_response.raise_for_status()
return profile_response.json()
else:
logger.warning(f"Bluesky profile fetch failed with HTTP error: {exc}")
return None
except Exception as exc: except Exception as exc:
logger.warning(f"Bluesky profile fetch failed with HTTP error: {exc}") logger.warning(f"Bluesky profile fetch failed with error: {exc}")
return None return None
return profile_response.json() def get_profile(self, handle: str) -> str:
if not self.session or not self._is_session_valid():
self.login()
return self._make_profile_request(handle)
def get_avatar(self, handle: str): def get_avatar(self, handle: str):
""" """