Repository: carpentries/amy

Issue:
Membership: add log of changes in members (removal, addition)
If an organization within a consortium is deleted or changed, add a log entry saying “<User name> deleted University X on 2021-05-05”. Store log entries as comments under membership.



Discussion:

Resolving patch:
diff --git a/amy/fiscal/views.py b/amy/fiscal/views.py
--- a/amy/fiscal/views.py
+++ b/amy/fiscal/views.py
@@ -248,7 +248,12 @@ def get_success_url(self):
 class MembershipMembers(
     OnlyForAdminsMixin, PermissionRequiredMixin, MembershipFormsetView
 ):
-    permission_required = "workshops.change_membership"
+    permission_required = (
+        "workshops.change_membership",
+        "workshops.add_member",
+        "workshops.change_member",
+        "workshops.delete_member",
+    )
 
     def get_formset(self, *args, **kwargs):
         return modelformset_factory(Member, MemberForm, *args, **kwargs)
@@ -278,6 +283,42 @@ def get_context_data(self, **kwargs):
             )
         return super().get_context_data(**kwargs)
 
+    def form_valid(self, formset):
+        result = super().form_valid(formset)
+
+        # Figure out changes in members and add comment listing them.
+        comment = "Changed members on {date}:\n\n{comments}"
+        added = "* Added {organization}"
+        removed = "* Removed {organization}"
+        changed = "* Replaced with {organization}"
+
+        comments = (
+            [
+                added.format(organization=member.organization)
+                for member in formset.new_objects
+            ]
+            + [
+                removed.format(organization=member.organization)
+                for member in formset.deleted_objects
+            ]
+            + [
+                # it's difficult to figure out previous value of member.organization,
+                # so the comment will only contain the new version
+                changed.format(organization=member.organization)
+                for member, fields in formset.changed_objects
+                if "organization" in fields
+            ]
+        )
+
+        if comments:
+            add_comment_for_object(
+                self.membership,
+                self.request.user,
+                comment.format(date=date.today(), comments="\n".join(comments)),
+            )
+
+        return result
+
 
 class MembershipTasks(
     OnlyForAdminsMixin, PermissionRequiredMixin, MembershipFormsetView
diff --git a/amy/workshops/management/commands/sync_usersocialauth.py b/amy/workshops/management/commands/sync_usersocialauth.py
--- a/amy/workshops/management/commands/sync_usersocialauth.py
+++ b/amy/workshops/management/commands/sync_usersocialauth.py
@@ -24,8 +24,8 @@ def handle(self, *args, **options):
                 person = Person.objects.get(username=username)
                 person.synchronize_usersocialauth()
             except Person.DoesNotExist:
-                print("{} -- no such person".format(username))
-            except GithubException:
-                print("{} -- failed due to errors with GitHub API".format(username))
+                print("Person not found")  # not disclosing username
+            except GithubException as e:
+                print(f"GitHub API exception {e}")  # not disclosing username
             else:
-                print("{} -- success; UserSocialAuth is now in sync".format(username))
+                print("Success")  # not disclosing username
diff --git a/amy/workshops/receivers.py b/amy/workshops/receivers.py
--- a/amy/workshops/receivers.py
+++ b/amy/workshops/receivers.py
@@ -11,10 +11,8 @@
 # a receiver for "failed login attempt" signal
 @receiver(user_login_failed)
 def log_user_login_failed(sender, **kwargs):
-    credentials = kwargs.get("credentials") or dict()
     request = kwargs.get("request") or HttpRequest()
 
     ip = request.META.get("REMOTE_ADDR") or "UNKNOWN"
-    username = credentials.get("username") or "UNKNOWN"
-    msg = "Login failure from IP {} for user '{}'".format(ip, username)
+    msg = f"Login failure from IP {ip}"
     logger.error(msg)


Test patch:
diff --git a/amy/fiscal/tests/test_membership_members.py b/amy/fiscal/tests/test_membership_members.py
--- a/amy/fiscal/tests/test_membership_members.py
+++ b/amy/fiscal/tests/test_membership_members.py
@@ -1,10 +1,15 @@
+from datetime import date
+
 from django.db import IntegrityError
 from django.urls import reverse
+import django_comments
 
 from fiscal.forms import MemberForm
 from workshops.models import Member, MemberRole, Membership
 from workshops.tests.base import TestBase
 
+CommentModel = django_comments.get_model()
+
 
 class MembershipTestMixin:
     def setUpMembership(self, consortium: bool):
@@ -310,6 +315,51 @@ def test_not_editing_noneditable_members_succeeds(self):
         )
         self.assertEqual(list(self.membership.organizations.all()), [self.org_alpha])
 
+    def test_mix_adding_removing_members_leaves_comment(self):
+        """Ensure a mixed-content formset for consortium membership members leaves
+        correct message in the comments."""
+        self.setUpMembership(consortium=True)
+        m1 = Member.objects.create(
+            organization=self.org_alpha,
+            membership=self.membership,
+            role=self.member_role,
+        )
+
+        data = {
+            "form-TOTAL_FORMS": 2,
+            "form-INITIAL_FORMS": 1,
+            "form-MIN_NUM_FORMS": 0,
+            "form-MAX_NUM_FORMS": 1000,
+            "form-0-membership": self.membership.pk,
+            "form-0-organization": m1.organization.pk,
+            "form-0-role": m1.role.pk,
+            "form-0-id": m1.pk,
+            "form-0-EDITABLE": True,
+            "form-0-DELETE": "on",  # marks org_alpha member for removal
+            "form-1-membership": self.membership.pk,
+            "form-1-organization": self.org_beta.pk,
+            "form-1-role": self.member_role.pk,
+            "form-1-id": "",  # no ID, so a new member for org_beta will be created
+            "form-1-EDITABLE": True,
+        }
+        response = self.client.post(
+            reverse("membership_members", args=[self.membership.pk]),
+            data=data,
+            follow=True,
+        )
+
+        self.assertRedirects(
+            response, reverse("membership_details", args=[self.membership.pk])
+        )
+        comment = CommentModel.objects.first()
+        self.assertEqual(
+            comment.comment,
+            f"""Changed members on {date.today():%Y-%m-%d}:
+
+* Added Beta Organization <beta.com>
+* Removed Alpha Organization <alpha.edu>""",
+        )
+
 
 class TestMemberUnique(MembershipTestMixin, TestBase):
     def test_duplicate_members_with_the_same_role_fail(self):