disambiguate groups and prep for group invitations

- rename Group to BookwyrmGroup
- create group memberships and invitations
- adjust all model name references accordingly
This commit is contained in:
Hugh Rundle 2021-10-02 10:10:37 +10:00
parent 66494e7788
commit 2f42161dda
10 changed files with 255 additions and 69 deletions

View file

@ -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):

View file

@ -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

View file

@ -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"
)
]
]
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

View file

@ -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"""

View file

@ -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,

View file

@ -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"""

View file

@ -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<group_id>\d+)(.json)?/?$", views.Group.as_view(), name="group"),
re_path(r"^group/(?P<group_id>\d+)(.json)?/?$", views.BookwyrmGroup.as_view(), name="group"),
re_path(r"^group/(?P<group_id>\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"),

View file

@ -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

View file

@ -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)
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)

View file

@ -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,