diff --git a/bookwyrm/forms.py b/bookwyrm/forms.py index ffb7581e5..290e01877 100644 --- a/bookwyrm/forms.py +++ b/bookwyrm/forms.py @@ -297,7 +297,7 @@ class ListForm(CustomForm): class GroupForm(CustomForm): class Meta: - model = models.Group + model = models.BookwyrmGroup fields = ["user", "privacy", "name", "description"] class ReportForm(CustomForm): diff --git a/bookwyrm/models/__init__.py b/bookwyrm/models/__init__.py index a4a06ebad..7ac41f1b9 100644 --- a/bookwyrm/models/__init__.py +++ b/bookwyrm/models/__init__.py @@ -21,7 +21,7 @@ from .relationship import UserFollows, UserFollowRequest, UserBlocks from .report import Report, ReportComment from .federated_server import FederatedServer -from .group import Group, GroupMember +from .group import BookwyrmGroup, BookwyrmGroupMember, GroupMemberInvitation from .import_job import ImportJob, ImportItem diff --git a/bookwyrm/models/group.py b/bookwyrm/models/group.py index ea162b2a4..103764d2d 100644 --- a/bookwyrm/models/group.py +++ b/bookwyrm/models/group.py @@ -1,14 +1,14 @@ """ do book related things with other users """ from django.apps import apps -from django.db import models -from django.utils import timezone - +from django.db import models, IntegrityError, models, transaction +from django.db.models import Q from bookwyrm.settings import DOMAIN from .base_model import BookWyrmModel from . import fields +from .relationship import UserBlocks +# from .user import User - -class Group(BookWyrmModel): +class BookwyrmGroup(BookWyrmModel): """A group of users""" name = fields.CharField(max_length=100) @@ -16,27 +16,138 @@ class Group(BookWyrmModel): "User", on_delete=models.PROTECT) description = fields.TextField(blank=True, null=True) privacy = fields.PrivacyField() - members = models.ManyToManyField( - "User", - symmetrical=False, - through="GroupMember", - through_fields=("group", "user"), - related_name="bookwyrm_groups" - ) - def get_remote_id(self): - """don't want the user to be in there in this case""" - return f"https://{DOMAIN}/group/{self.id}" - -class GroupMember(models.Model): +class BookwyrmGroupMember(models.Model): """Users who are members of a group""" - - group = models.ForeignKey("Group", on_delete=models.CASCADE) - user = models.ForeignKey("User", on_delete=models.CASCADE) + created_date = models.DateTimeField(auto_now_add=True) + updated_date = models.DateTimeField(auto_now=True) + group = models.ForeignKey( + "BookwyrmGroup", + on_delete=models.CASCADE, + related_name="memberships" + ) + user = models.ForeignKey( + "User", + on_delete=models.CASCADE, + related_name="memberships" + ) class Meta: constraints = [ models.UniqueConstraint( - fields=["group", "user"], name="unique_member" + fields=["group", "user"], name="unique_membership" ) - ] \ No newline at end of file + ] + + def save(self, *args, **kwargs): + """don't let a user invite someone who blocked them""" + # blocking in either direction is a no-go + if UserBlocks.objects.filter( + Q( + user_subject=self.group.user, + user_object=self.user, + ) + | Q( + user_subject=self.user, + user_object=self.group.user, + ) + ).exists(): + raise IntegrityError() + # accepts and requests are handled by the BookwyrmGroupInvitation model + super().save(*args, **kwargs) + + @classmethod + def from_request(cls, join_request): + """converts a join request into a member relationship""" + + # remove the invite + join_request.delete() + + # make a group member + return cls.objects.create( + user=join_request.user, + group=join_request.group, + ) + + +class GroupMemberInvitation(models.Model): + """adding a user to a group requires manual confirmation""" + created_date = models.DateTimeField(auto_now_add=True) + group = models.ForeignKey( + "BookwyrmGroup", + on_delete=models.CASCADE, + related_name="user_invitations" + ) + user = models.ForeignKey( + "User", + on_delete=models.CASCADE, + related_name="group_invitations" + ) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["group", "user"], name="unique_invitation" + ) + ] + def save(self, *args, **kwargs): # pylint: disable=arguments-differ + """make sure the membership doesn't already exist""" + # if there's an invitation for a membership that already exists, accept it + # without changing the local database state + if BookwyrmGroupMember.objects.filter( + user=self.user, + group=self.group + ).exists(): + self.accept() + return + + # blocking in either direction is a no-go + if UserBlocks.objects.filter( + Q( + user_subject=self.group.user, + user_object=self.user, + ) + | Q( + user_subject=self.user, + user_object=self.group.user, + ) + ).exists(): + raise IntegrityError() + + # make an invitation + super().save(*args, **kwargs) + + # now send the invite + model = apps.get_model("bookwyrm.Notification", require_ready=True) + notification_type = "INVITE" + model.objects.create( + user=self.user, + related_user=self.group.user, + related_group=self.group, + notification_type=notification_type, + ) + + def accept(self): + """turn this request into the real deal""" + + with transaction.atomic(): + BookwyrmGroupMember.from_request(self) + self.delete() + + # let the other members know about it + model = apps.get_model("bookwyrm.Notification", require_ready=True) + for member in self.group.members.all: + if member != self.user: + model.objects.create( + user=member, + related_user=self.user, + related_group=self.group, + notification_type="ACCEPT", + ) + + def reject(self): + """generate a Reject for this membership request""" + + self.delete() + + # TODO: send notification \ No newline at end of file diff --git a/bookwyrm/models/list.py b/bookwyrm/models/list.py index 7ea33a8b6..498026326 100644 --- a/bookwyrm/models/list.py +++ b/bookwyrm/models/list.py @@ -35,7 +35,7 @@ class List(OrderedCollectionMixin, BookWyrmModel): max_length=255, default="closed", choices=CurationType.choices ) group = models.ForeignKey( - "Group", + "BookwyrmGroup", on_delete=models.PROTECT, default=None, blank=True, @@ -101,6 +101,9 @@ class ListItem(CollectionItemMixin, BookWyrmModel): notification_type="ADD", ) + # TODO: send a notification to all team members except the one who added the book + # for team curated lists + class Meta: """A book may only be placed into a list once, and each order in the list may be used only once""" diff --git a/bookwyrm/models/notification.py b/bookwyrm/models/notification.py index a4968f61f..3632fa10f 100644 --- a/bookwyrm/models/notification.py +++ b/bookwyrm/models/notification.py @@ -7,7 +7,7 @@ from . import Boost, Favorite, ImportJob, Report, Status, User NotificationType = models.TextChoices( "NotificationType", - "FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT ADD REPORT", + "FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT ADD REPORT INVITE ACCEPT", ) @@ -19,6 +19,12 @@ class Notification(BookWyrmModel): related_user = models.ForeignKey( "User", on_delete=models.CASCADE, null=True, related_name="related_user" ) + related_group_member = models.ForeignKey( + "User", on_delete=models.CASCADE, null=True, related_name="related_group_member" + ) + related_group = models.ForeignKey( + "BookwyrmGroup", on_delete=models.CASCADE, null=True, related_name="notifications" + ) related_status = models.ForeignKey("Status", on_delete=models.CASCADE, null=True) related_import = models.ForeignKey("ImportJob", on_delete=models.CASCADE, null=True) related_list_item = models.ForeignKey( @@ -37,6 +43,8 @@ class Notification(BookWyrmModel): user=self.user, related_book=self.related_book, related_user=self.related_user, + related_group_member=self.related_group_member, + related_group=self.related_group, related_status=self.related_status, related_import=self.related_import, related_list_item=self.related_list_item, diff --git a/bookwyrm/models/user.py b/bookwyrm/models/user.py index 637baa6ee..0e1397949 100644 --- a/bookwyrm/models/user.py +++ b/bookwyrm/models/user.py @@ -143,6 +143,11 @@ class User(OrderedCollectionPageMixin, AbstractUser): property_fields = [("following_link", "following")] field_tracker = FieldTracker(fields=["name", "avatar"]) + # @property + # def bookwyrm_groups(self): + # group_ids = bookwyrm_group_membership.values_list("user", flat=True) + # return BookwyrmGroup.objects.in_bulk(group_ids).values() + @property def confirmation_link(self): """helper for generating confirmation links""" diff --git a/bookwyrm/urls.py b/bookwyrm/urls.py index 30ffc8689..05f8ceffe 100644 --- a/bookwyrm/urls.py +++ b/bookwyrm/urls.py @@ -253,10 +253,13 @@ urlpatterns = [ re_path(r"^hide-suggestions/?$", views.hide_suggestions, name="hide-suggestions"), # groups re_path(rf"{USER_PATH}/groups/?$", views.UserGroups.as_view(), name="user-groups"), - re_path(r"^group/(?P\d+)(.json)?/?$", views.Group.as_view(), name="group"), + re_path(r"^group/(?P\d+)(.json)?/?$", views.BookwyrmGroup.as_view(), name="group"), re_path(r"^group/(?P\d+)/add-users/?$", views.FindUsers.as_view(), name="group-find-users"), - re_path(r"^add-group-member/?$", views.add_member, name="add-group-member"), - re_path(r"^remove-group-member/?$", views.remove_member, name="remove-group-member"), + re_path(r"^add-group-member/?$", views.invite_member, name="invite-group-member"), + re_path(r"^add-group-member/?$", views.uninvite_member, name="uninvite-group-member"), + re_path(r"^add-group-member/?$", views.uninvite_member, name="uninvite-group-member"), + re_path(r"^accept-group-invitation/?$", views.accept_membership, name="accept-group-invitation"), + re_path(r"^reject-group-invitation/?$", views.reject_membership, name="reject-group-invitation"), # lists re_path(rf"{USER_PATH}/lists/?$", views.UserLists.as_view(), name="user-lists"), re_path(r"^list/?$", views.Lists.as_view(), name="lists"), diff --git a/bookwyrm/views/__init__.py b/bookwyrm/views/__init__.py index 4d93c5973..fb9e72bc2 100644 --- a/bookwyrm/views/__init__.py +++ b/bookwyrm/views/__init__.py @@ -32,7 +32,7 @@ from .follow import follow, unfollow from .follow import accept_follow_request, delete_follow_request from .get_started import GetStartedBooks, GetStartedProfile, GetStartedUsers from .goal import Goal, hide_goal -from .group import Group, UserGroups, FindUsers, add_member, remove_member +from .group import BookwyrmGroup, UserGroups, FindUsers, invite_member, remove_member, uninvite_member, accept_membership, reject_membership from .import_data import Import, ImportStatus from .inbox import Inbox from .interaction import Favorite, Unfavorite, Boost, Unboost diff --git a/bookwyrm/views/group.py b/bookwyrm/views/group.py index 3e1785cce..09bb0dcad 100644 --- a/bookwyrm/views/group.py +++ b/bookwyrm/views/group.py @@ -18,13 +18,13 @@ from .helpers import privacy_filter from .helpers import get_user_from_username from bookwyrm.settings import DOMAIN -class Group(View): +class BookwyrmGroup(View): """group page""" def get(self, request, group_id): """display a group""" - group = get_object_or_404(models.Group, id=group_id) + group = get_object_or_404(models.BookwyrmGroup, id=group_id) lists = models.List.objects.filter(group=group).order_by("-updated_date") lists = privacy_filter(request.user, lists) @@ -43,7 +43,7 @@ class Group(View): @method_decorator(login_required, name="dispatch") def post(self, request, group_id): """edit a group""" - user_group = get_object_or_404(models.Group, id=group_id) + user_group = get_object_or_404(models.BookwyrmGroup, id=group_id) form = forms.GroupForm(request.POST, instance=user_group) if not form.is_valid(): return redirect("group", user_group.id) @@ -57,11 +57,12 @@ class UserGroups(View): def get(self, request, username): """display a group""" user = get_user_from_username(request.user, username) - groups = models.Group.objects.filter(members=user).order_by("-updated_date") - # groups = privacy_filter(request.user, groups) + groups = user.bookwyrmgroup_set.all() # follow the relationship backwards, nice paginated = Paginator(groups, 12) data = { + "groups": paginated.get_page(request.GET.get("page")), + "is_self": request.user.id == user.id, "user": user, "group_form": forms.GroupForm(), "path": user.local_path + "/group", @@ -77,7 +78,7 @@ class UserGroups(View): return redirect(request.user.local_path + "groups") group = form.save() # add the creator as a group member - models.GroupMember.objects.create(group=group, user=request.user) + models.BookwyrmGroupMember.objects.create(group=group, user=request.user) return redirect("group", group.id) @method_decorator(login_required, name="dispatch") @@ -109,21 +110,22 @@ class FindUsers(View): request.user ) - group = get_object_or_404(models.Group, id=group_id) + group = get_object_or_404(models.BookwyrmGroup, id=group_id) - data["suggested_users"] = user_results - data["group"] = group - data["query"] = query - data["requestor_is_manager"] = request.user == group.user + data = { + "suggested_users": user_results, + "group": group, + "query": query, + "requestor_is_manager": request.user == group.user + } return TemplateResponse(request, "groups/find_users.html", data) @require_POST @login_required -def add_member(request): - """add a member to the group""" +def invite_member(request): + """invite a member to the group""" - # TODO: if groups become AP values we need something like get_group_from_group_fullname - group = get_object_or_404(models.Group, id=request.POST.get("group")) + group = get_object_or_404(models.BookwyrmGroup, id=request.POST.get("group")) if not group: return HttpResponseBadRequest() @@ -135,28 +137,42 @@ def add_member(request): return HttpResponseBadRequest() try: - models.GroupMember.objects.create( - group=group, - user=user - ) + models.GroupMemberInvitation.objects.create( + user=user, + group=group + ) except IntegrityError: pass -# TODO: actually this needs to be associated with the user ACCEPTING AN INVITE!!! DOH! + return redirect(user.local_path) - """create a notification too""" - # notify all team members when a user is added to the group - model = apps.get_model("bookwyrm.Notification", require_ready=True) - for team_member in group.members.all(): - if team_member.local and team_member != request.user: - model.objects.create( - user=team_member, - related_user=request.user, - related_group_member=user, - related_group=group, - notification_type="ADD", - ) +@require_POST +@login_required +def uninvite_member(request): + """invite a member to the group""" + + group = get_object_or_404(models.BookwyrmGroup, id=request.POST.get("group")) + if not group: + return HttpResponseBadRequest() + + user = get_user_from_username(request.user, request.POST["user"]) + if not user: + return HttpResponseBadRequest() + + if not group.user == request.user: + return HttpResponseBadRequest() + + try: + invitation = models.GroupMemberInvitation.objects.get( + user=user, + group=group + ) + + invitation.reject() + + except IntegrityError: + pass return redirect(user.local_path) @@ -168,10 +184,11 @@ def remove_member(request): # TODO: send notification to user telling them they have been removed # TODO: remove yourself from a group!!!! (except owner) # FUTURE TODO: transfer ownership of group + # THIS LOGIC SHOULD BE IN MODEL # TODO: if groups become AP values we need something like get_group_from_group_fullname # group = get_object_or_404(models.Group, id=request.POST.get("group")) # NOTE: does this not work? - group = models.Group.objects.get(id=request.POST["group"]) + group = models.BookwyrmGroup.objects.get(id=request.POST["group"]) if not group: return HttpResponseBadRequest() @@ -183,11 +200,52 @@ def remove_member(request): return HttpResponseBadRequest() try: - membership = models.GroupMember.objects.get(group=group,user=user) + membership = models.BookwyrmGroupMember.objects.get(group=group,user=user) # BUG: wrong membership.delete() except IntegrityError: - print("no integrity") pass - return redirect(user.local_path) \ No newline at end of file + return redirect(user.local_path) + +@require_POST +@login_required +def accept_membership(request): + """accept an invitation to join a group""" + + group = models.BookwyrmGroup.objects.get(id=request.POST["group"]) + if not group: + return HttpResponseBadRequest() + + invite = models.GroupMemberInvitation.objects.get(group=group,user=request.user) + if not invite: + return HttpResponseBadRequest() + + try: + invite.accept() + + except IntegrityError: + pass + + return redirect(request.user.local_path) + +@require_POST +@login_required +def reject_membership(request): + """reject an invitation to join a group""" + + group = models.BookwyrmGroup.objects.get(id=request.POST["group"]) + if not group: + return HttpResponseBadRequest() + + invite = models.GroupMemberInvitation.objects.get(group=group,user=request.user) + if not invite: + return HttpResponseBadRequest() + + try: + invite.reject() + + except IntegrityError: + pass + + return redirect(request.user.local_path) \ No newline at end of file diff --git a/bookwyrm/views/user.py b/bookwyrm/views/user.py index d3f52e729..562c49335 100644 --- a/bookwyrm/views/user.py +++ b/bookwyrm/views/user.py @@ -1,5 +1,4 @@ """ non-interactive pages """ -from bookwyrm.models.group import GroupMember from django.contrib.auth.decorators import login_required from django.core.paginator import Paginator from django.shortcuts import redirect @@ -83,7 +82,6 @@ class User(View): data = { "user": user, "is_self": is_self, - "has_groups": models.GroupMember.objects.filter(user=user).exists(), "shelves": shelf_preview, "shelf_count": shelves.count(), "activities": paginated.get_page(request.GET.get("page", 1)), @@ -142,7 +140,7 @@ class Groups(View): user = get_user_from_username(request.user, username) paginated = Paginator( - GroupMember.objects.filter(user=user) + models.BookwyrmGroup.memberships.filter(user=user) ) data = { "user": user,