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 GroupForm(CustomForm):
class Meta: class Meta:
model = models.Group model = models.BookwyrmGroup
fields = ["user", "privacy", "name", "description"] fields = ["user", "privacy", "name", "description"]
class ReportForm(CustomForm): class ReportForm(CustomForm):

View file

@ -21,7 +21,7 @@ from .relationship import UserFollows, UserFollowRequest, UserBlocks
from .report import Report, ReportComment from .report import Report, ReportComment
from .federated_server import FederatedServer from .federated_server import FederatedServer
from .group import Group, GroupMember from .group import BookwyrmGroup, BookwyrmGroupMember, GroupMemberInvitation
from .import_job import ImportJob, ImportItem from .import_job import ImportJob, ImportItem

View file

@ -1,14 +1,14 @@
""" do book related things with other users """ """ do book related things with other users """
from django.apps import apps from django.apps import apps
from django.db import models from django.db import models, IntegrityError, models, transaction
from django.utils import timezone from django.db.models import Q
from bookwyrm.settings import DOMAIN from bookwyrm.settings import DOMAIN
from .base_model import BookWyrmModel from .base_model import BookWyrmModel
from . import fields from . import fields
from .relationship import UserBlocks
# from .user import User
class BookwyrmGroup(BookWyrmModel):
class Group(BookWyrmModel):
"""A group of users""" """A group of users"""
name = fields.CharField(max_length=100) name = fields.CharField(max_length=100)
@ -16,27 +16,138 @@ class Group(BookWyrmModel):
"User", on_delete=models.PROTECT) "User", on_delete=models.PROTECT)
description = fields.TextField(blank=True, null=True) description = fields.TextField(blank=True, null=True)
privacy = fields.PrivacyField() privacy = fields.PrivacyField()
members = models.ManyToManyField(
"User",
symmetrical=False,
through="GroupMember",
through_fields=("group", "user"),
related_name="bookwyrm_groups"
)
def get_remote_id(self): class BookwyrmGroupMember(models.Model):
"""don't want the user to be in there in this case"""
return f"https://{DOMAIN}/group/{self.id}"
class GroupMember(models.Model):
"""Users who are members of a group""" """Users who are members of a group"""
created_date = models.DateTimeField(auto_now_add=True)
group = models.ForeignKey("Group", on_delete=models.CASCADE) updated_date = models.DateTimeField(auto_now=True)
user = models.ForeignKey("User", on_delete=models.CASCADE) group = models.ForeignKey(
"BookwyrmGroup",
on_delete=models.CASCADE,
related_name="memberships"
)
user = models.ForeignKey(
"User",
on_delete=models.CASCADE,
related_name="memberships"
)
class Meta: class Meta:
constraints = [ constraints = [
models.UniqueConstraint( 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 max_length=255, default="closed", choices=CurationType.choices
) )
group = models.ForeignKey( group = models.ForeignKey(
"Group", "BookwyrmGroup",
on_delete=models.PROTECT, on_delete=models.PROTECT,
default=None, default=None,
blank=True, blank=True,
@ -101,6 +101,9 @@ class ListItem(CollectionItemMixin, BookWyrmModel):
notification_type="ADD", notification_type="ADD",
) )
# TODO: send a notification to all team members except the one who added the book
# for team curated lists
class Meta: class Meta:
"""A book may only be placed into a list once, """A book may only be placed into a list once,
and each order in the list may be used only 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 = models.TextChoices(
"NotificationType", "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( related_user = models.ForeignKey(
"User", on_delete=models.CASCADE, null=True, related_name="related_user" "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_status = models.ForeignKey("Status", on_delete=models.CASCADE, null=True)
related_import = models.ForeignKey("ImportJob", on_delete=models.CASCADE, null=True) related_import = models.ForeignKey("ImportJob", on_delete=models.CASCADE, null=True)
related_list_item = models.ForeignKey( related_list_item = models.ForeignKey(
@ -37,6 +43,8 @@ class Notification(BookWyrmModel):
user=self.user, user=self.user,
related_book=self.related_book, related_book=self.related_book,
related_user=self.related_user, related_user=self.related_user,
related_group_member=self.related_group_member,
related_group=self.related_group,
related_status=self.related_status, related_status=self.related_status,
related_import=self.related_import, related_import=self.related_import,
related_list_item=self.related_list_item, related_list_item=self.related_list_item,

View file

@ -143,6 +143,11 @@ class User(OrderedCollectionPageMixin, AbstractUser):
property_fields = [("following_link", "following")] property_fields = [("following_link", "following")]
field_tracker = FieldTracker(fields=["name", "avatar"]) 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 @property
def confirmation_link(self): def confirmation_link(self):
"""helper for generating confirmation links""" """helper for generating confirmation links"""

View file

@ -253,10 +253,13 @@ urlpatterns = [
re_path(r"^hide-suggestions/?$", views.hide_suggestions, name="hide-suggestions"), re_path(r"^hide-suggestions/?$", views.hide_suggestions, name="hide-suggestions"),
# groups # groups
re_path(rf"{USER_PATH}/groups/?$", views.UserGroups.as_view(), name="user-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"^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"^add-group-member/?$", views.invite_member, name="invite-group-member"),
re_path(r"^remove-group-member/?$", views.remove_member, name="remove-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 # lists
re_path(rf"{USER_PATH}/lists/?$", views.UserLists.as_view(), name="user-lists"), re_path(rf"{USER_PATH}/lists/?$", views.UserLists.as_view(), name="user-lists"),
re_path(r"^list/?$", views.Lists.as_view(), name="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 .follow import accept_follow_request, delete_follow_request
from .get_started import GetStartedBooks, GetStartedProfile, GetStartedUsers from .get_started import GetStartedBooks, GetStartedProfile, GetStartedUsers
from .goal import Goal, hide_goal 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 .import_data import Import, ImportStatus
from .inbox import Inbox from .inbox import Inbox
from .interaction import Favorite, Unfavorite, Boost, Unboost 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 .helpers import get_user_from_username
from bookwyrm.settings import DOMAIN from bookwyrm.settings import DOMAIN
class Group(View): class BookwyrmGroup(View):
"""group page""" """group page"""
def get(self, request, group_id): def get(self, request, group_id):
"""display a group""" """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 = models.List.objects.filter(group=group).order_by("-updated_date")
lists = privacy_filter(request.user, lists) lists = privacy_filter(request.user, lists)
@ -43,7 +43,7 @@ class Group(View):
@method_decorator(login_required, name="dispatch") @method_decorator(login_required, name="dispatch")
def post(self, request, group_id): def post(self, request, group_id):
"""edit a group""" """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) form = forms.GroupForm(request.POST, instance=user_group)
if not form.is_valid(): if not form.is_valid():
return redirect("group", user_group.id) return redirect("group", user_group.id)
@ -57,11 +57,12 @@ class UserGroups(View):
def get(self, request, username): def get(self, request, username):
"""display a group""" """display a group"""
user = get_user_from_username(request.user, username) user = get_user_from_username(request.user, username)
groups = models.Group.objects.filter(members=user).order_by("-updated_date") groups = user.bookwyrmgroup_set.all() # follow the relationship backwards, nice
# groups = privacy_filter(request.user, groups)
paginated = Paginator(groups, 12) paginated = Paginator(groups, 12)
data = { data = {
"groups": paginated.get_page(request.GET.get("page")),
"is_self": request.user.id == user.id,
"user": user, "user": user,
"group_form": forms.GroupForm(), "group_form": forms.GroupForm(),
"path": user.local_path + "/group", "path": user.local_path + "/group",
@ -77,7 +78,7 @@ class UserGroups(View):
return redirect(request.user.local_path + "groups") return redirect(request.user.local_path + "groups")
group = form.save() group = form.save()
# add the creator as a group member # 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) return redirect("group", group.id)
@method_decorator(login_required, name="dispatch") @method_decorator(login_required, name="dispatch")
@ -109,21 +110,22 @@ class FindUsers(View):
request.user 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 = {
data["group"] = group "suggested_users": user_results,
data["query"] = query "group": group,
data["requestor_is_manager"] = request.user == group.user "query": query,
"requestor_is_manager": request.user == group.user
}
return TemplateResponse(request, "groups/find_users.html", data) return TemplateResponse(request, "groups/find_users.html", data)
@require_POST @require_POST
@login_required @login_required
def add_member(request): def invite_member(request):
"""add a member to the group""" """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.BookwyrmGroup, id=request.POST.get("group"))
group = get_object_or_404(models.Group, id=request.POST.get("group"))
if not group: if not group:
return HttpResponseBadRequest() return HttpResponseBadRequest()
@ -135,28 +137,42 @@ def add_member(request):
return HttpResponseBadRequest() return HttpResponseBadRequest()
try: try:
models.GroupMember.objects.create( models.GroupMemberInvitation.objects.create(
group=group, user=user,
user=user group=group
) )
except IntegrityError: except IntegrityError:
pass pass
# TODO: actually this needs to be associated with the user ACCEPTING AN INVITE!!! DOH! return redirect(user.local_path)
"""create a notification too""" @require_POST
# notify all team members when a user is added to the group @login_required
model = apps.get_model("bookwyrm.Notification", require_ready=True) def uninvite_member(request):
for team_member in group.members.all(): """invite a member to the group"""
if team_member.local and team_member != request.user:
model.objects.create( group = get_object_or_404(models.BookwyrmGroup, id=request.POST.get("group"))
user=team_member, if not group:
related_user=request.user, return HttpResponseBadRequest()
related_group_member=user,
related_group=group, user = get_user_from_username(request.user, request.POST["user"])
notification_type="ADD", 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) 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: send notification to user telling them they have been removed
# TODO: remove yourself from a group!!!! (except owner) # TODO: remove yourself from a group!!!! (except owner)
# FUTURE TODO: transfer ownership of group # 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 # 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 = 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: if not group:
return HttpResponseBadRequest() return HttpResponseBadRequest()
@ -183,11 +200,52 @@ def remove_member(request):
return HttpResponseBadRequest() return HttpResponseBadRequest()
try: try:
membership = models.GroupMember.objects.get(group=group,user=user) membership = models.BookwyrmGroupMember.objects.get(group=group,user=user) # BUG: wrong
membership.delete() membership.delete()
except IntegrityError: except IntegrityError:
print("no integrity")
pass 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 """ """ non-interactive pages """
from bookwyrm.models.group import GroupMember
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.shortcuts import redirect from django.shortcuts import redirect
@ -83,7 +82,6 @@ class User(View):
data = { data = {
"user": user, "user": user,
"is_self": is_self, "is_self": is_self,
"has_groups": models.GroupMember.objects.filter(user=user).exists(),
"shelves": shelf_preview, "shelves": shelf_preview,
"shelf_count": shelves.count(), "shelf_count": shelves.count(),
"activities": paginated.get_page(request.GET.get("page", 1)), "activities": paginated.get_page(request.GET.get("page", 1)),
@ -142,7 +140,7 @@ class Groups(View):
user = get_user_from_username(request.user, username) user = get_user_from_username(request.user, username)
paginated = Paginator( paginated = Paginator(
GroupMember.objects.filter(user=user) models.BookwyrmGroup.memberships.filter(user=user)
) )
data = { data = {
"user": user, "user": user,