""" database schema for user data """ from urllib.parse import urlparse import re from django.apps import apps from django.contrib.auth.models import AbstractUser, Group from django.contrib.postgres.fields import CICharField from django.core.validators import MinValueValidator from django.dispatch import receiver from django.db import models, transaction from django.utils import timezone from model_utils import FieldTracker import pytz from bookwyrm import activitypub from bookwyrm.models.shelf import Shelf from bookwyrm.models.status import Status, Review from bookwyrm.preview_images import generate_user_preview_image_task from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES, USE_HTTPS, LANGUAGES from bookwyrm.utils import regex from .activitypub_mixin import OrderedCollectionPageMixin from .base_model import BookWyrmModel, DeactivationReason, new_access_code from .actor import ActorModel from . import fields def site_link(): """helper for generating links to the site""" protocol = "https" if USE_HTTPS else "http" return f"{protocol}://{DOMAIN}" class User(OrderedCollectionPageMixin, AbstractUser, ActorModel): """a user who wants to read books""" username = fields.UsernameField() email = models.EmailField(unique=True, null=True) bookwyrm_user = fields.BooleanField(default=True) localname = CICharField( max_length=255, null=True, unique=True, validators=[fields.validate_localname], ) shared_inbox = fields.RemoteIdField( activitypub_field="sharedInbox", activitypub_wrapper="endpoints", deduplication_field=False, null=True, ) # name is your display name, which you can change at will name = fields.CharField(max_length=100, null=True, blank=True) avatar = fields.ImageField( upload_to="avatars/", blank=True, null=True, activitypub_field="icon", alt_field="alt_text", ) preview_image = models.ImageField( upload_to="previews/avatars/", blank=True, null=True ) followers_url = fields.CharField(max_length=255, activitypub_field="followers") followers = models.ManyToManyField( "self", symmetrical=False, through="UserFollows", through_fields=("user_object", "user_subject"), related_name="following", ) follow_requests = models.ManyToManyField( "self", symmetrical=False, through="UserFollowRequest", through_fields=("user_subject", "user_object"), related_name="follower_requests", ) blocks = models.ManyToManyField( "self", symmetrical=False, through="UserBlocks", through_fields=("user_subject", "user_object"), related_name="blocked_by", ) saved_lists = models.ManyToManyField( "List", symmetrical=False, related_name="saved_lists", blank=True ) favorites = models.ManyToManyField( "Status", symmetrical=False, through="Favorite", through_fields=("user", "status"), related_name="favorite_statuses", ) remote_id = fields.RemoteIdField(null=True, unique=True, activitypub_field="id") created_date = models.DateTimeField(auto_now_add=True) updated_date = models.DateTimeField(auto_now=True) last_active_date = models.DateTimeField(default=timezone.now) # options to turn features on and off show_goal = models.BooleanField(default=True) show_suggested_users = models.BooleanField(default=True) preferred_timezone = models.CharField( choices=[(str(tz), str(tz)) for tz in pytz.all_timezones], default=str(pytz.utc), max_length=255, ) preferred_language = models.CharField( choices=LANGUAGES, null=True, blank=True, max_length=255, ) deactivation_reason = models.CharField( max_length=255, choices=DeactivationReason, null=True, blank=True ) deactivation_date = models.DateTimeField(null=True, blank=True) confirmation_code = models.CharField(max_length=32, default=new_access_code) name_field = "username" field_tracker = FieldTracker(fields=["name", "avatar"]) @property def confirmation_link(self): """helper for generating confirmation links""" link = site_link() return f"{link}/confirm-email/{self.confirmation_code}" @property def following_link(self): """just how to find out the following info""" return f"{self.remote_id}/following" @property def alt_text(self): """alt text with username""" # pylint: disable=consider-using-f-string return "avatar for {:s}".format(self.localname or self.username) @property def display_name(self): """show the cleanest version of the user's name possible""" if self.name and self.name != "": return self.name return self.localname or self.username @property def deleted(self): """for consistent naming""" return not self.is_active @property def unread_notification_count(self): """count of notifications, for the templates""" return self.notification_set.filter(read=False).count() @property def has_unread_mentions(self): """whether any of the unread notifications are conversations""" return self.notification_set.filter( read=False, notification_type__in=["REPLY", "MENTION", "TAG", "REPORT"], ).exists() activity_serializer = activitypub.Person def update_active_date(self): """this user is here! they are doing things!""" self.last_active_date = timezone.now() self.save(broadcast=False, update_fields=["last_active_date"]) def to_outbox(self, filter_type=None, **kwargs): """an ordered collection of statuses""" if filter_type: filter_class = apps.get_model(f"bookwyrm.{filter_type}", require_ready=True) if not issubclass(filter_class, Status): raise TypeError( "filter_status_class must be a subclass of models.Status" ) queryset = filter_class.objects else: queryset = Status.objects queryset = ( queryset.filter( user=self, deleted=False, privacy__in=["public", "unlisted"], ) .select_subclasses() .order_by("-published_date") ) return self.to_ordered_collection( queryset, collection_only=True, remote_id=self.outbox, **kwargs ).serialize() def to_following_activity(self, **kwargs): """activitypub following list""" remote_id = f"{self.remote_id}/following" return self.to_ordered_collection( self.following.order_by("-updated_date").all(), remote_id=remote_id, id_only=True, **kwargs, ) def to_followers_activity(self, **kwargs): """activitypub followers list""" remote_id = self.followers_url return self.to_ordered_collection( self.followers.order_by("-updated_date").all(), remote_id=remote_id, id_only=True, **kwargs, ) def to_activity(self, **kwargs): """override default AP serializer to add context object idk if this is the best way to go about this""" if not self.is_active: return self.remote_id activity_object = super().to_activity(**kwargs) activity_object["@context"] = [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", { "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", "schema": "http://schema.org#", "PropertyValue": "schema:PropertyValue", "value": "schema:value", }, ] return activity_object def save(self, *args, **kwargs): """populate fields for new local users""" created = not bool(self.id) if not self.local and not re.match(regex.FULL_USERNAME, self.username): # parse out the username that uses the domain (webfinger format) actor_parts = urlparse(self.remote_id) self.username = f"{self.username}@{actor_parts.netloc}" # this user already exists, no need to populate fields if not created: # make sure the deactivation state is correct in case it was updated if self.is_active: self.deactivation_date = None elif not self.deactivation_date: self.deactivation_date = timezone.now() super().save(*args, **kwargs) return with transaction.atomic(): # populate fields for local users link = site_link() self.remote_id = f"{link}/user/{self.localname}" self.shared_inbox = f"{link}/inbox" # an id needs to be set before we can proceed with related models super().save(*args, **kwargs) # make users editors by default try: self.groups.add(Group.objects.get(name="editor")) except Group.DoesNotExist: # this should only happen in tests pass self.create_shelves() def delete(self, *args, **kwargs): """deactivate rather than delete a user""" self.is_active = False # skip the logic in this class's save() super().save(*args, **kwargs) @property def local_path(self): """this model doesn't inherit bookwyrm model, so here we are""" # pylint: disable=consider-using-f-string return "/user/{:s}".format(self.localname or self.username) def create_shelves(self): """default shelves for a new user""" shelves = [ { "name": "To Read", "identifier": "to-read", }, { "name": "Currently Reading", "identifier": "reading", }, { "name": "Read", "identifier": "read", }, ] for shelf in shelves: Shelf( name=shelf["name"], identifier=shelf["identifier"], user=self, editable=False, ).save(broadcast=False) class AnnualGoal(BookWyrmModel): """set a goal for how many books you read in a year""" user = models.ForeignKey("User", on_delete=models.PROTECT) goal = models.IntegerField(validators=[MinValueValidator(1)]) year = models.IntegerField(default=timezone.now().year) privacy = models.CharField( max_length=255, default="public", choices=fields.PrivacyLevels.choices ) class Meta: """unqiueness constraint""" unique_together = ("user", "year") def get_remote_id(self): """put the year in the path""" return f"{self.user.remote_id}/goal/{self.year}" @property def books(self): """the books you've read this year""" return ( self.user.readthrough_set.filter( finish_date__year__gte=self.year, finish_date__year__lt=self.year + 1, ) .order_by("-finish_date") .all() ) @property def ratings(self): """ratings for books read this year""" book_ids = [r.book.id for r in self.books] reviews = Review.objects.filter( user=self.user, book__in=book_ids, ) return {r.book.id: r.rating for r in reviews} @property def progress(self): """how many books you've read this year""" count = self.user.readthrough_set.filter( finish_date__year__gte=self.year, finish_date__year__lt=self.year + 1, ).count() return { "count": count, "percent": int(float(count / self.goal) * 100), } # pylint: disable=unused-argument @receiver(models.signals.post_save, sender=User) def preview_image(instance, *args, **kwargs): """create preview images when user is updated""" if not ENABLE_PREVIEW_IMAGES: return changed_fields = instance.field_tracker.changed() if len(changed_fields) > 0: generate_user_preview_image_task.delay(instance.id)