mirror of
https://git.linux-kernel.at/oliver/ivatar.git
synced 2025-11-15 04:28:03 +00:00
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:
@@ -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"
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
"""
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user