refactor accounts sync

This commit is contained in:
Your Name 2024-07-05 18:15:10 -04:00 committed by Henri Dickson
parent 4cc0fdd0ac
commit e29b921d34
14 changed files with 131 additions and 137 deletions

View file

@ -560,7 +560,7 @@ CORS_ALLOW_METHODS = (
# "PUT", # "PUT",
) )
DEACTIVATE_AFTER_UNREACHABLE_DAYS = 120 DEACTIVATE_AFTER_UNREACHABLE_DAYS = 365
DEFAULT_RELAY_SERVER = "https://relay.neodb.net/inbox" DEFAULT_RELAY_SERVER = "https://relay.neodb.net/inbox"

View file

View file

@ -70,9 +70,9 @@ class Bluesky:
account.session_string = session_string account.session_string = session_string
account.base_url = base_url account.base_url = base_url
if account.pk: if account.pk:
account.refresh(save=True, did_refresh=False) account.refresh(save=True, did_check=False)
else: else:
account.refresh(save=False, did_refresh=False) account.refresh(save=False, did_check=False)
return account return account
@ -108,40 +108,53 @@ class BlueskyAccount(SocialAccount):
def url(self): def url(self):
return f"https://{self.handle}" return f"https://{self.handle}"
def refresh(self, save=True, did_refresh=True): def check_alive(self, save=True):
if did_refresh: did = self.uid
did = self.uid did_r = DidResolver()
did_r = DidResolver() handle_r = HandleResolver(timeout=5)
handle_r = HandleResolver(timeout=5) did_doc = did_r.resolve(did)
did_doc = did_r.resolve(did) if not did_doc:
if not did_doc: logger.warning(f"ATProto refresh failed: did {did} -> <missing doc>")
logger.warning(f"ATProto refresh failed: did {did} -> <missing doc>") return False
return False resolved_handle = did_doc.get_handle()
resolved_handle = did_doc.get_handle() if not resolved_handle:
if not resolved_handle: logger.warning(f"ATProto refresh failed: did {did} -> <missing handle>")
logger.warning(f"ATProto refresh failed: did {did} -> <missing handle>") return False
return False resolved_did = handle_r.resolve(resolved_handle)
resolved_did = handle_r.resolve(resolved_handle) resolved_pds = did_doc.get_pds_endpoint()
resolved_pds = did_doc.get_pds_endpoint() if did != resolved_did:
if did != resolved_did: logger.warning(
logger.warning( f"ATProto refresh failed: did {did} -> handle {resolved_handle} -> did {resolved_did}"
f"ATProto refresh failed: did {did} -> handle {resolved_handle} -> did {resolved_did}" )
) return False
return False if resolved_handle != self.handle:
if resolved_handle != self.handle: logger.debug(
logger.debug( f"ATProto refresh: handle changed for did {did}: handle {self.handle} -> {resolved_handle}"
f"ATProto refresh: handle changed for did {did}: handle {self.handle} -> {resolved_handle}" )
) self.handle = resolved_handle
self.handle = resolved_handle if resolved_pds != self.base_url:
if resolved_pds != self.base_url: logger.debug(
logger.debug( f"ATProto refresh: pds changed for did {did}: handle {self.base_url} -> {resolved_pds}"
f"ATProto refresh: pds changed for did {did}: handle {self.base_url} -> {resolved_pds}" )
) self.base_url = resolved_pds
self.base_url = resolved_pds self.last_reachable = timezone.now()
if save:
self.save(
update_fields=[
"access_data",
"handle",
"last_reachable",
]
)
return True
def refresh(self, save=True, did_check=True):
if did_check:
self.check_alive(save=save)
profile = self._client.me profile = self._client.me
if not profile: if not profile:
logger.warning("Bluesky: client not logged in.") # this should not happen logger.warning("Bluesky: client not logged in.") # this should not happen
return None return False
if self.handle != profile.handle: if self.handle != profile.handle:
logger.warning( logger.warning(
"ATProto refresh: handle mismatch {self.handle} from did doc -> {profile.handle} from PDS" "ATProto refresh: handle mismatch {self.handle} from did doc -> {profile.handle} from PDS"
@ -150,17 +163,14 @@ class BlueskyAccount(SocialAccount):
k: v for k, v in profile.__dict__.items() if isinstance(v, (int, str)) k: v for k, v in profile.__dict__.items() if isinstance(v, (int, str))
} }
self.last_refresh = timezone.now() self.last_refresh = timezone.now()
self.last_reachable = self.last_refresh
if save: if save:
self.save( self.save(
update_fields=[ update_fields=[
"access_data",
"account_data", "account_data",
"handle",
"last_refresh",
"last_reachable", "last_reachable",
] ]
) )
return True
def post( def post(
self, self,

View file

@ -1,7 +1,10 @@
from datetime import timedelta
from django.db import models from django.db import models
from django.db.models.functions import Lower from django.db.models.functions import Lower
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from loguru import logger
from typedmodels.models import TypedModel from typedmodels.models import TypedModel
from catalog.common import jsondata from catalog.common import jsondata
@ -66,15 +69,12 @@ class SocialAccount(TypedModel):
] ]
def __str__(self) -> str: def __str__(self) -> str:
return f"({self.pk}){self.platform}#{self.handle}:{self.uid}@{self.domain}" return f"({self.pk}){self.platform}@{self.handle}"
@property @property
def platform(self) -> Platform: def platform(self) -> Platform:
return Platform(self.type.replace("mastodon.", "", 1).replace("account", "", 1)) return Platform(self.type.replace("mastodon.", "", 1).replace("account", "", 1))
def sync_later(self):
pass
def to_dict(self): def to_dict(self):
# skip cached_property, datetime and other non-serializable fields # skip cached_property, datetime and other non-serializable fields
d = { d = {
@ -101,5 +101,26 @@ class SocialAccount(TypedModel):
def check_alive(self) -> bool: def check_alive(self) -> bool:
return False return False
def sync(self) -> bool: def refresh(self) -> bool:
return False return False
def refresh_graph(self, save=True) -> bool:
return False
def sync(self, skip_graph=False, sleep_hours=0) -> bool:
if self.last_refresh and self.last_refresh > timezone.now() - timedelta(
hours=sleep_hours
):
logger.debug(f"{self} skip refreshing as it's done recently")
return False
if not self.check_alive():
dt = timezone.now() - self.last_reachable
logger.warning(f"{self} unreachable for {dt.days} days")
return False
if not self.refresh():
logger.warning(f"{self} refresh failed")
return False
if not skip_graph:
self.refresh_graph()
logger.debug(f"{self} refreshed")
return True

View file

@ -15,7 +15,8 @@ _code_ttl = 60 * 15
class EmailAccount(SocialAccount): class EmailAccount(SocialAccount):
pass def sync(self, skip_graph=False, sleep_hours=0) -> bool:
return True
class Email: class Email:

View file

@ -576,6 +576,8 @@ class Mastodon:
existing_account.account_data = mastodon_account.account_data existing_account.account_data = mastodon_account.account_data
existing_account.save(update_fields=["access_data", "account_data"]) existing_account.save(update_fields=["access_data", "account_data"])
return existing_account return existing_account
# for fresh account, ping them for convenience
Takahe.fetch_remote_identity(mastodon_account.handle)
return mastodon_account return mastodon_account
@ -774,6 +776,7 @@ class MastodonAccount(SocialAccount):
"domain_blocks", "domain_blocks",
] ]
) )
return True
def boost(self, post_url: str): def boost(self, post_url: str):
boost_toot(self._api_domain, self.access_token, post_url) boost_toot(self._api_domain, self.access_token, post_url)
@ -830,9 +833,5 @@ class MastodonAccount(SocialAccount):
raise PermissionDenied() raise PermissionDenied()
raise RequestAborted() raise RequestAborted()
def sync_later(self):
Takahe.fetch_remote_identity(self.handle)
# TODO
def get_reauthorize_url(self): def get_reauthorize_url(self):
return reverse("mastodon:login") + "?domain=" + self.domain return reverse("mastodon:login") + "?domain=" + self.domain

View file

@ -12,6 +12,7 @@ from django.utils import timezone
from loguru import logger from loguru import logger
from catalog.common import jsondata from catalog.common import jsondata
from takahe.utils import Takahe
from .common import SocialAccount from .common import SocialAccount
@ -183,6 +184,8 @@ class Threads:
account.domain = Threads.DOMAIN account.domain = Threads.DOMAIN
account.token_expires_at = expires_at account.token_expires_at = expires_at
account.refresh(save=False) account.refresh(save=False)
# for fresh account, ping them for convenience
Takahe.fetch_remote_identity(account.handle + "@" + Threads.DOMAIN)
return account return account
@ -237,7 +240,7 @@ class ThreadsAccount(SocialAccount):
if self.handle != data["username"]: if self.handle != data["username"]:
if self.handle: if self.handle:
logger.info(f'{self} handle changed to {data["username"]}') logger.info(f'{self} handle changed to {data["username"]}')
self.handle = data["username"] self.handle = str(data["username"])
self.account_data = data self.account_data = data
self.last_refresh = timezone.now() self.last_refresh = timezone.now()
if save: if save:

View file

@ -8,6 +8,7 @@ from django.utils.translation import gettext as _
from common.views import render_error from common.views import render_error
from mastodon.models.common import SocialAccount from mastodon.models.common import SocialAccount
from users.models import User
from users.views.account import auth_login, logout_takahe from users.views.account import auth_login, logout_takahe
@ -24,12 +25,12 @@ def process_verified_account(request: HttpRequest, account: SocialAccount):
def login_existing_user(request: HttpRequest, account: SocialAccount): def login_existing_user(request: HttpRequest, account: SocialAccount):
user = authenticate(request, social_account=account) user: User | None = authenticate(request, social_account=account) # type:ignore
if not user: if not user:
return render_error(request, _("Authentication failed"), _("Invalid user.")) return render_error(request, _("Authentication failed"), _("Invalid user."))
existing_user = account.user existing_user = account.user
auth_login(request, existing_user) auth_login(request, existing_user)
account.sync_later() user.sync_accounts_later()
if not existing_user.username or not existing_user.identity: if not existing_user.username or not existing_user.identity:
# this should not happen # this should not happen
response = redirect(reverse("users:register")) response = redirect(reverse("users:register"))
@ -50,7 +51,7 @@ def register_new_user(request: HttpRequest, account: SocialAccount):
def reconnect_account(request, account: SocialAccount): def reconnect_account(request, account: SocialAccount):
if account.user == request.user: if account.user == request.user:
account.sync_later() account.user.sync_accounts_later()
messages.add_message( messages.add_message(
request, request,
messages.INFO, messages.INFO,
@ -73,7 +74,7 @@ def reconnect_account(request, account: SocialAccount):
del request.session["new_user"] del request.session["new_user"]
return render(request, "users/welcome.html") return render(request, "users/welcome.html")
else: else:
account.sync_later() request.user.sync_accounts_later()
messages.add_message( messages.add_message(
request, request,
messages.INFO, messages.INFO,

View file

@ -15,12 +15,12 @@ class MastodonUserSync(BaseJob):
interval = timedelta(hours=interval_hours) interval = timedelta(hours=interval_hours)
def run(self): def run(self):
logger.info("Mastodon User Sync start.") inactive_threshold = timezone.now() - timedelta(days=30)
inactive_threshold = timezone.now() - timedelta(days=90) batches = (24 + self.interval_hours - 1) // self.interval_hours
batch = (24 + self.interval_hours - 1) // self.interval_hours if batches < 1:
if batch < 1: batches = 1
batch = 1 batch = timezone.now().hour // self.interval_hours
m = timezone.now().hour // self.interval_hours logger.info(f"User accounts sync job starts batch {batch+1} of {batches}")
qs = ( qs = (
User.objects.exclude( User.objects.exclude(
preference__mastodon_skip_userinfo=True, preference__mastodon_skip_userinfo=True,
@ -30,15 +30,15 @@ class MastodonUserSync(BaseJob):
username__isnull=False, username__isnull=False,
is_active=True, is_active=True,
) )
.annotate(idmod=F("id") % batch) .annotate(idmod=F("id") % batches)
.filter(idmod=m) .filter(idmod=batch)
) )
for user in qs.iterator(): for user in qs.iterator():
skip_detail = False skip_graph = False
if not user.last_login or user.last_login < inactive_threshold: if not user.last_login or user.last_login < inactive_threshold:
last_usage = user.last_usage last_usage = user.last_usage
if not last_usage or last_usage < inactive_threshold: if not last_usage or last_usage < inactive_threshold:
logger.info(f"Skip {user} detail because of inactivity.") skip_graph = True
skip_detail = True logger.debug(f"User accounts sync for {user}, skip_graph:{skip_graph}")
user.refresh_mastodon_data(skip_detail, self.interval_hours) user.sync_accounts(skip_graph, self.interval_hours)
logger.info("Mastodon User Sync finished.") logger.info("User accounts sync job finished.")

View file

@ -1,24 +1,6 @@
import hashlib
import re
from functools import cached_property
from django.conf import settings
from django.contrib.auth.models import AbstractUser
from django.core import validators
from django.core.exceptions import ValidationError
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
from django.db import models from django.db import models
from django.db.models import F, Q, Value
from django.db.models.functions import Concat, Lower
from django.templatetags.static import static
from django.urls import reverse
from django.utils import timezone
from django.utils.deconstruct import deconstructible
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from loguru import logger
from mastodon.api import *
from takahe.utils import Takahe
from .user import User from .user import User

View file

@ -3,6 +3,7 @@ from datetime import timedelta
from functools import cached_property from functools import cached_property
from typing import TYPE_CHECKING, ClassVar from typing import TYPE_CHECKING, ClassVar
import django_rq
import httpx import httpx
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import AbstractUser, BaseUserManager from django.contrib.auth.models import AbstractUser, BaseUserManager
@ -221,7 +222,7 @@ class User(AbstractUser):
return settings.SITE_INFO["site_url"] + self.url return settings.SITE_INFO["site_url"] + self.url
def __str__(self): def __str__(self):
return f'USER:{self.pk}:{self.username or "<missing>"}:{self.mastodon or self.email_account or ""}' return f'{self.pk}:{self.username or "<missing>"}'
@property @property
def registration_complete(self): def registration_complete(self):
@ -331,49 +332,44 @@ class User(AbstractUser):
if url: if url:
try: try:
r = httpx.get(url) r = httpx.get(url)
f = ContentFile(r.content, name=identity.icon_uri.split("/")[-1])
identity.icon.save(f.name, f, save=False)
changed = True
except Exception as e: except Exception as e:
logger.error( logger.error(
f"fetch icon failed: {identity} {identity.icon_uri}", f"fetch icon failed: {identity} {url}",
extra={"exception": e}, extra={"exception": e},
) )
r = None
if r:
name = str(self.pk) + "-" + url.split("/")[-1].split("?")[0][-100:]
f = ContentFile(r.content, name=name)
identity.icon.save(name, f, save=False)
changed = True
if changed: if changed:
identity.save() identity.save()
Takahe.update_state(identity, "outdated") Takahe.update_state(identity, "outdated")
def refresh_mastodon_data(self, skip_detail=False, sleep_hours=0): def sync_accounts(self, skip_graph=False, sleep_hours=0):
"""Try refresh account data from mastodon server, return True if refreshed successfully""" """Try refresh account data from 3p server"""
mastodon = self.mastodon for account in self.social_accounts.all():
if not mastodon: account.sync(skip_graph=skip_graph, sleep_hours=sleep_hours)
return False
if mastodon.last_refresh and mastodon.last_refresh > timezone.now() - timedelta(
hours=sleep_hours
):
logger.debug(f"Skip refreshing Mastodon data for {self}")
return
logger.debug(f"Refreshing Mastodon data for {self}")
if not mastodon.check_alive():
if (
timezone.now() - self.mastodon_last_reachable
> timedelta(days=settings.DEACTIVATE_AFTER_UNREACHABLE_DAYS)
and not self.email
):
logger.warning(f"Deactivate {self} bc unable to reach for too long")
self.is_active = False
self.save(update_fields=["is_active"])
return False
if not mastodon.refresh():
return False
if skip_detail:
return True
if not self.preference.mastodon_skip_userinfo: if not self.preference.mastodon_skip_userinfo:
self.sync_identity() self.sync_identity()
if skip_graph:
return
if not self.preference.mastodon_skip_relationship: if not self.preference.mastodon_skip_relationship:
mastodon.refresh_graph()
self.sync_relationship() self.sync_relationship()
return True return
@staticmethod
def sync_accounts_task(user_id):
user = User.objects.get(pk=user_id)
logger.info(f"{user} accounts sync start")
if user.sync_accounts():
logger.info(f"{user} accounts sync done")
else:
logger.warning(f"{user} accounts sync failed")
def sync_accounts_later(self):
django_rq.get_queue("mastodon").enqueue(User.sync_accounts_task, self.pk)
@cached_property @cached_property
def unread_announcements(self): def unread_announcements(self):

View file

@ -1,14 +0,0 @@
from loguru import logger
from .models import User
def refresh_mastodon_data_task(user_id):
user = User.objects.get(pk=user_id)
if not user.mastodon:
logger.info(f"{user} mastodon data refresh skipped")
return
if user.refresh_mastodon_data():
logger.info(f"{user} mastodon data refreshed")
else:
logger.warning(f"{user} mastodon data refresh failed")

View file

@ -13,7 +13,6 @@ from common.utils import (
HTTPResponseHXRedirect, HTTPResponseHXRedirect,
target_identity_required, target_identity_required,
) )
from mastodon.api import *
from takahe.utils import Takahe from takahe.utils import Takahe
from ..models import APIdentity from ..models import APIdentity

View file

@ -19,10 +19,8 @@ from journal.importers.goodreads import GoodreadsImporter
from journal.importers.letterboxd import LetterboxdImporter from journal.importers.letterboxd import LetterboxdImporter
from journal.importers.opml import OPMLImporter from journal.importers.opml import OPMLImporter
from journal.models import ShelfType, reset_journal_visibility_for_user from journal.models import ShelfType, reset_journal_visibility_for_user
from mastodon.api import *
from social.models import reset_social_visibility_for_user from social.models import reset_social_visibility_for_user
from ..tasks import *
from .account import * from .account import *
@ -146,10 +144,8 @@ def export_marks(request):
@login_required @login_required
def sync_mastodon(request): def sync_mastodon(request):
if request.method == "POST" and request.user.mastodon: if request.method == "POST":
django_rq.get_queue("mastodon").enqueue( request.user.sync_accounts_later()
refresh_mastodon_data_task, request.user.pk
)
messages.add_message(request, messages.INFO, _("Sync in progress.")) messages.add_message(request, messages.INFO, _("Sync in progress."))
return redirect(reverse("users:info")) return redirect(reverse("users:info"))