lib.itmens/users/models/user.py

452 lines
15 KiB
Python
Raw Normal View History

import re
from datetime import timedelta
from functools import cached_property
2023-09-16 11:09:57 -04:00
from typing import TYPE_CHECKING, ClassVar
2024-02-20 20:20:43 -05:00
import httpx
2024-04-03 23:10:21 -04:00
from django.conf import settings
2023-08-14 08:15:55 -04:00
from django.contrib.auth.models import AbstractUser, BaseUserManager
2023-08-11 01:43:19 -04:00
from django.contrib.auth.validators import UnicodeUsernameValidator
2023-07-04 17:21:17 -04:00
from django.core.exceptions import ValidationError
2024-02-20 20:20:43 -05:00
from django.core.files.base import ContentFile
2024-07-01 17:29:38 -04:00
from django.db import models, transaction
2024-07-03 00:07:07 -04:00
from django.db.models.functions import Lower
from django.urls import reverse
from django.utils import timezone, translation
from django.utils.deconstruct import deconstructible
2021-12-24 11:56:04 -08:00
from django.utils.translation import gettext_lazy as _
from loguru import logger
2024-07-03 00:07:07 -04:00
from mastodon.models import (
BlueskyAccount,
EmailAccount,
MastodonAccount,
SocialAccount,
ThreadsAccount,
)
2023-07-20 21:59:49 -04:00
from takahe.utils import Takahe
if TYPE_CHECKING:
2023-07-20 21:59:49 -04:00
from .apidentity import APIdentity
from .preference import Preference
2023-07-04 17:21:17 -04:00
2023-07-12 01:11:15 -04:00
_RESERVED_USERNAMES = [
2023-07-04 17:21:17 -04:00
"connect",
"__",
"admin",
2024-04-12 20:42:36 -04:00
"administrator",
2024-04-19 20:24:34 -04:00
"service",
"support",
2024-04-12 20:42:36 -04:00
"system",
"user",
"users",
2023-07-04 17:21:17 -04:00
"api",
2024-04-19 20:24:34 -04:00
"bot",
2023-07-04 17:21:17 -04:00
"me",
]
2020-05-01 22:46:15 +08:00
@deconstructible
2023-08-11 01:43:19 -04:00
class UsernameValidator(UnicodeUsernameValidator):
2023-07-04 17:21:17 -04:00
regex = r"^[a-zA-Z0-9_]{2,30}$"
message = _(
"Enter a valid username. This value may contain only unaccented lowercase a-z and uppercase A-Z letters, numbers, and _ characters."
)
flags = re.ASCII
2023-07-04 17:21:17 -04:00
def __call__(self, value):
2023-07-12 01:11:15 -04:00
if value and value.lower() in _RESERVED_USERNAMES:
2023-07-04 17:21:17 -04:00
raise ValidationError(self.message, code=self.code)
return super().__call__(value)
2023-08-14 08:15:55 -04:00
class UserManager(BaseUserManager):
def create_user(self, username, email, password=None):
2024-07-03 00:07:07 -04:00
from mastodon.models import Email
2023-08-14 08:15:55 -04:00
Takahe.get_domain() # ensure configuration is complete
2024-07-03 00:07:07 -04:00
user = User.register(username=username)
e = Email.new_account(email)
if not e:
raise ValueError("Invalid Email")
e.user = user
e.save()
2023-08-14 08:15:55 -04:00
return user
def create_superuser(self, username, email, password=None):
2024-07-03 00:07:07 -04:00
from mastodon.models import Email
2023-08-14 08:15:55 -04:00
from takahe.models import User as TakaheUser
Takahe.get_domain() # ensure configuration is complete
2024-07-03 00:07:07 -04:00
user = User.register(username=username, is_superuser=True)
e = Email.new_account(email)
if not e:
raise ValueError("Invalid Email")
e.user = user
e.save()
2023-08-14 08:15:55 -04:00
tu = TakaheUser.objects.get(pk=user.pk, email="@" + username)
tu.admin = True
tu.save()
return user
2020-05-01 22:46:15 +08:00
class User(AbstractUser):
2023-07-20 21:59:49 -04:00
identity: "APIdentity"
2023-07-12 01:11:15 -04:00
preference: "Preference"
2024-07-01 17:29:38 -04:00
social_accounts: "models.QuerySet[SocialAccount]"
objects: ClassVar[UserManager] = UserManager()
username_validator = UsernameValidator()
username = models.CharField(
_("username"),
2023-07-02 16:56:09 -04:00
max_length=100,
unique=True,
null=True, # allow null for newly registered users who has not set a user name
help_text=_("Required. 50 characters or fewer. Letters, digits and _ only."),
validators=[username_validator],
error_messages={
"unique": _("A user with that username already exists."),
},
)
2024-07-01 17:29:38 -04:00
language = models.CharField(
_("language"),
max_length=10,
choices=settings.LANGUAGES,
null=False,
default="en",
)
# remove the following
2023-07-07 02:02:48 -04:00
email = models.EmailField(
_("email address"),
unique=True,
default=None,
null=True,
)
2023-07-04 17:21:17 -04:00
pending_email = models.EmailField(
_("email address pending verification"), default=None, null=True
)
2023-07-07 02:02:48 -04:00
local_following = models.ManyToManyField(
through="Follow",
to="self",
through_fields=("owner", "target"),
symmetrical=False,
related_name="local_followers",
)
2023-07-07 16:54:15 -04:00
local_blocking = models.ManyToManyField(
through="Block",
to="self",
through_fields=("owner", "target"),
symmetrical=False,
related_name="local_blocked_by",
)
local_muting = models.ManyToManyField(
through="Mute",
to="self",
through_fields=("owner", "target"),
symmetrical=False,
related_name="+",
)
2022-05-30 17:54:35 -04:00
following = models.JSONField(default=list)
2023-07-07 16:54:15 -04:00
muting = models.JSONField(default=list)
# rejecting = local/external blocking + local/external blocked_by + domain_blocking + domain_blocked_by
rejecting = models.JSONField(default=list)
mastodon_id = models.CharField(max_length=100, default=None, null=True)
mastodon_username = models.CharField(max_length=100, default=None, null=True)
mastodon_site = models.CharField(max_length=100, default=None, null=True)
mastodon_token = models.CharField(max_length=2048, default="")
mastodon_refresh_token = models.CharField(max_length=2048, default="")
mastodon_locked = models.BooleanField(default=False)
mastodon_followers = models.JSONField(default=list)
mastodon_following = models.JSONField(default=list)
mastodon_mutes = models.JSONField(default=list)
mastodon_blocks = models.JSONField(default=list)
mastodon_domain_blocks = models.JSONField(default=list)
mastodon_account = models.JSONField(default=dict)
mastodon_last_refresh = models.DateTimeField(default=timezone.now)
2023-11-11 00:53:03 -05:00
mastodon_last_reachable = models.DateTimeField(default=timezone.now)
2022-05-13 00:11:59 -04:00
# store the latest read announcement id,
2020-12-09 13:47:00 +01:00
# every time user read the announcement update this field
read_announcement_index = models.PositiveIntegerField(default=0)
2020-10-22 21:45:05 +02:00
class Meta:
constraints = [
models.UniqueConstraint(
Lower("username"),
name="unique_username",
),
]
2024-07-03 00:07:07 -04:00
indexes = [models.Index("is_active", name="index_user_is_active")]
2020-05-01 22:46:15 +08:00
2024-07-01 17:29:38 -04:00
@cached_property
def mastodon(self) -> "MastodonAccount | None":
return MastodonAccount.objects.filter(user=self).first()
2024-07-03 00:07:07 -04:00
@cached_property
def threads(self) -> "ThreadsAccount | None":
return ThreadsAccount.objects.filter(user=self).first()
@cached_property
def bluesky(self) -> "BlueskyAccount | None":
return BlueskyAccount.objects.filter(user=self).first()
2024-07-01 17:29:38 -04:00
@cached_property
def email_account(self) -> "EmailAccount | None":
return EmailAccount.objects.filter(user=self).first()
2023-07-07 16:54:15 -04:00
@cached_property
def mastodon_acct(self):
2024-07-01 17:29:38 -04:00
return self.mastodon.handle if self.mastodon else ""
2020-10-30 13:18:31 +01:00
2024-07-01 17:29:38 -04:00
@cached_property
2023-07-07 02:02:48 -04:00
def locked(self):
2024-07-01 17:29:38 -04:00
return self.identity.locked
2023-07-07 02:02:48 -04:00
2022-05-30 19:43:29 -04:00
@property
def display_name(self):
2024-07-01 17:29:38 -04:00
return self.identity.display_name
2022-05-30 19:43:29 -04:00
@property
def avatar(self):
2023-08-24 05:48:14 +00:00
return (
self.identity.avatar if self.identity else settings.SITE_INFO["user_icon"]
)
2022-11-08 02:32:35 +00:00
@property
def url(self):
2024-07-01 17:29:38 -04:00
return reverse("journal:user_profile", args=[self.username])
2022-11-08 02:32:35 +00:00
2023-12-23 00:57:00 -05:00
@property
def absolute_url(self):
return settings.SITE_INFO["site_url"] + self.url
def __str__(self):
2024-07-01 17:29:38 -04:00
return f'USER:{self.pk}:{self.username or "<missing>"}:{self.mastodon or self.email_account or ""}'
2023-09-03 20:11:46 +00:00
@property
def registration_complete(self):
return self.username is not None
@property
def last_usage(self):
2024-01-14 22:16:53 -05:00
from journal.models import ShelfMember
2024-01-14 22:16:53 -05:00
p = (
ShelfMember.objects.filter(owner=self.identity)
.order_by("-edited_time")
.first()
)
return p.edited_time if p else None
def clear(self):
2024-07-03 00:07:07 -04:00
if not self.is_active:
return
2024-07-01 17:29:38 -04:00
with transaction.atomic():
2024-07-03 00:07:07 -04:00
accts = [str(a) for a in self.social_accounts.all()]
self.first_name = (";").join(accts)
self.last_name = self.username
2024-07-01 17:29:38 -04:00
self.is_active = False
# self.username = "~removed~" + str(self.pk)
# to get ready for federation, username has to be reserved
self.save()
self.identity.deleted = timezone.now()
self.identity.save()
2024-07-03 00:07:07 -04:00
self.social_accounts.all().delete()
def sync_relationship(self):
2024-07-01 17:29:38 -04:00
def get_identity_ids(accts: list):
return set(
MastodonAccount.objects.filter(handle__in=accts).values_list(
"user__identity", flat=True
)
)
def get_identity_ids_in_domains(domains: list):
return set(
MastodonAccount.objects.filter(domain__in=domains).values_list(
"user__identity", flat=True
)
)
me = self.identity.pk
if not self.mastodon:
return
for target_identity in get_identity_ids(self.mastodon.following):
if not Takahe.get_is_following(me, target_identity):
Takahe.follow(me, target_identity, True)
for target_identity in get_identity_ids(self.mastodon.blocks):
if not Takahe.get_is_blocking(me, target_identity):
Takahe.block(me, target_identity)
for target_identity in get_identity_ids_in_domains(self.mastodon.domain_blocks):
if not Takahe.get_is_blocking(me, target_identity):
Takahe.block(me, target_identity)
for target_identity in get_identity_ids(self.mastodon.mutes):
if not Takahe.get_is_muting(me, target_identity):
Takahe.mute(me, target_identity)
def sync_identity(self):
2024-07-03 00:07:07 -04:00
"""sync display name, bio, and avatar from available sources"""
identity = self.identity.takahe_identity
if identity.deleted:
logger.error(f"Identity {identity} is deleted, skip sync")
return
2024-07-01 17:29:38 -04:00
mastodon = self.mastodon
2024-07-03 00:07:07 -04:00
threads = self.threads
2024-07-05 10:53:43 -04:00
bluesky = self.bluesky
2024-07-03 00:07:07 -04:00
changed = False
name = (
(mastodon.display_name if mastodon else "")
or (threads.username if threads else "")
2024-07-05 10:53:43 -04:00
or (bluesky.display_name if bluesky else "")
2024-07-03 00:07:07 -04:00
or identity.name
or identity.username
)
if identity.name != name:
identity.name = name
changed = True
summary = (
(mastodon.note if mastodon else "")
or (threads.threads_biography if threads else "")
2024-07-05 10:53:43 -04:00
or (bluesky.description if bluesky else "")
2024-07-03 00:07:07 -04:00
or identity.summary
)
if identity.summary != summary:
identity.summary = summary
changed = True
identity.manually_approves_followers = (
mastodon.locked if mastodon else identity.manually_approves_followers
)
# it's tedious to update avatar repeatedly, so only sync it once
if not identity.icon:
url = None
if mastodon and mastodon.avatar:
url = mastodon.avatar
elif threads and threads.threads_profile_picture_url:
url = threads.threads_profile_picture_url
2024-07-05 10:53:43 -04:00
elif bluesky and bluesky.avatar:
url = bluesky.avatar
2024-07-03 00:07:07 -04:00
if url:
2024-02-20 20:20:43 -05:00
try:
2024-07-03 00:07:07 -04:00
r = httpx.get(url)
2024-02-20 20:20:43 -05:00
f = ContentFile(r.content, name=identity.icon_uri.split("/")[-1])
identity.icon.save(f.name, f, save=False)
2024-07-03 00:07:07 -04:00
changed = True
2024-02-20 20:20:43 -05:00
except Exception as e:
2024-05-25 23:38:11 -04:00
logger.error(
f"fetch icon failed: {identity} {identity.icon_uri}",
extra={"exception": e},
)
2024-07-03 00:07:07 -04:00
if changed:
identity.save()
Takahe.update_state(identity, "outdated")
2023-07-07 16:54:15 -04:00
2024-07-01 17:29:38 -04:00
def refresh_mastodon_data(self, skip_detail=False, sleep_hours=0):
2023-11-11 00:53:03 -05:00
"""Try refresh account data from mastodon server, return True if refreshed successfully"""
2024-07-01 17:29:38 -04:00
mastodon = self.mastodon
if not mastodon:
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}")
2024-07-01 17:29:38 -04:00
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
2024-07-01 17:29:38 -04:00
self.save(update_fields=["is_active"])
return False
if not mastodon.refresh():
2024-02-10 23:39:49 -05:00
return False
if skip_detail:
return True
if not self.preference.mastodon_skip_userinfo:
self.sync_identity()
if not self.preference.mastodon_skip_relationship:
2024-07-01 17:29:38 -04:00
mastodon.refresh_graph()
2024-02-10 23:39:49 -05:00
self.sync_relationship()
return True
2024-04-17 00:00:40 -04:00
@cached_property
2023-04-20 13:36:12 -04:00
def unread_announcements(self):
2024-04-17 00:00:40 -04:00
from takahe.utils import Takahe
return Takahe.get_announcements_for_user(self)
2023-04-20 13:36:12 -04:00
2023-07-20 21:59:49 -04:00
@property
def activity_manager(self):
if not self.identity:
raise ValueError("User has no identity")
return self.identity.activity_manager
@property
def shelf_manager(self):
if not self.identity:
raise ValueError("User has no identity")
return self.identity.shelf_manager
@property
def tag_manager(self):
if not self.identity:
raise ValueError("User has no identity")
return self.identity.tag_manager
2022-05-13 00:11:59 -04:00
@classmethod
2024-07-01 17:29:38 -04:00
def register(cls, **param) -> "User":
2023-07-20 21:59:49 -04:00
from .preference import Preference
2024-07-01 17:29:38 -04:00
account = param.pop("account", None)
with transaction.atomic():
new_user = cls(**param)
if not new_user.username:
raise ValueError("username is not set")
if "language" not in param:
new_user.language = translation.get_language()
2024-07-03 00:07:07 -04:00
new_user.set_unusable_password()
2024-07-01 17:29:38 -04:00
new_user.save()
Preference.objects.create(user=new_user)
if account:
account.user = new_user
account.save()
Takahe.init_identity_for_local_user(new_user)
new_user.identity.shelf_manager
return new_user
2024-07-03 00:07:07 -04:00
def reconnect_account(self, account: SocialAccount):
with transaction.atomic():
SocialAccount.objects.filter(user=self, type=account.type).delete()
account.user = self
account.save()
2023-08-11 01:43:19 -04:00
2023-07-20 21:59:49 -04:00
# TODO the following models should be deprecated soon
2023-08-11 01:43:19 -04:00
2021-02-17 15:08:16 +01:00
2023-07-07 02:02:48 -04:00
class Follow(models.Model):
owner = models.ForeignKey(User, on_delete=models.CASCADE, related_name="+")
target = models.ForeignKey(User, on_delete=models.CASCADE, related_name="+")
created_time = models.DateTimeField(auto_now_add=True)
edited_time = models.DateTimeField(auto_now=True)
2023-07-07 16:54:15 -04:00
class Block(models.Model):
owner = models.ForeignKey(User, on_delete=models.CASCADE, related_name="+")
target = models.ForeignKey(User, on_delete=models.CASCADE, related_name="+")
created_time = models.DateTimeField(auto_now_add=True)
edited_time = models.DateTimeField(auto_now=True)
class Mute(models.Model):
owner = models.ForeignKey(User, on_delete=models.CASCADE, related_name="+")
target = models.ForeignKey(User, on_delete=models.CASCADE, related_name="+")
created_time = models.DateTimeField(auto_now_add=True)
edited_time = models.DateTimeField(auto_now=True)