From 20fc54711f43a793ac5c357ed85b24ce45bee224 Mon Sep 17 00:00:00 2001 From: Matt Frazier Date: Tue, 6 Jun 2023 18:29:11 -0400 Subject: [PATCH 1/2] Swap Mailchimp library for their API v3 --- osf/models/user.py | 9 +---- osf_tests/test_user.py | 20 ++--------- requirements.txt | 2 +- tests/test_mailchimp.py | 55 +++++++++++------------------- tests/test_views.py | 65 +++++++++++++++++------------------- website/mailchimp_utils.py | 58 +++++++++++++++++++------------- website/profile/views.py | 8 ++--- website/settings/defaults.py | 3 ++ 8 files changed, 96 insertions(+), 124 deletions(-) diff --git a/osf/models/user.py b/osf/models/user.py index 71f84228f61..83677c108fe 100644 --- a/osf/models/user.py +++ b/osf/models/user.py @@ -972,14 +972,7 @@ def deactivate_account(self): user_id=self._id, username=self.username ) - except mailchimp_utils.mailchimp.ListNotSubscribedError: - pass - except mailchimp_utils.mailchimp.InvalidApiKeyError: - if not website_settings.ENABLE_EMAIL_SUBSCRIPTIONS: - pass - else: - raise - except mailchimp_utils.mailchimp.EmailNotExistsError: + except mailchimp_utils.OSFError: pass # Call to `unsubscribe` above saves, and can lead to stale data self.reload() diff --git a/osf_tests/test_user.py b/osf_tests/test_user.py index 39afbd02249..0c67bc4ed43 100644 --- a/osf_tests/test_user.py +++ b/osf_tests/test_user.py @@ -23,7 +23,6 @@ from framework.celery_tasks import handlers from website import settings from website import filters -from website import mailchimp_utils from website.project.signals import contributor_added from website.project.views.contributor import notify_added_contributor from website.views import find_bookmark_collection @@ -1509,7 +1508,7 @@ def test_send_user_merged_signal(self, mock_get_mailchimp_api, dupe, merge_dupe) @mock.patch('website.mailchimp_utils.get_mailchimp_api') @mock.patch('website.mailchimp_utils.unsubscribe_mailchimp_async') def test_merged_user_unsubscribed_from_mailing_lists(self, mock_mailchimp_api, mock_unsubscribe, dupe, merge_dupe, email_subscriptions_enabled): - list_name = 'foo' + list_name = settings.MAILCHIMP_GENERAL_LIST dupe.mailchimp_mailing_lists[list_name] = True dupe.save() merge_dupe() @@ -1694,10 +1693,6 @@ def test_deactivate_account_and_remove_sessions(self, mock_mail): assert not SessionStore().exists(session_key=session1.session_key) assert not SessionStore().exists(session_key=session2.session_key) - def test_deactivate_account_api(self): - settings.ENABLE_EMAIL_SUBSCRIPTIONS = True - with pytest.raises(mailchimp_utils.mailchimp.InvalidApiKeyError): - self.user.deactivate_account() # Copied from tests/modes/test_user.py @pytest.mark.enable_bookmark_creation @@ -1963,14 +1958,9 @@ def is_mrm_field(value): other_user.external_accounts.add(ExternalAccountFactory()) self.user.mailchimp_mailing_lists = { - 'user': True, - 'shared_gt': True, - 'shared_lt': False, } other_user.mailchimp_mailing_lists = { - 'other': True, - 'shared_gt': False, - 'shared_lt': True, + settings.MAILCHIMP_GENERAL_LIST: True } self.user.security_messages = { @@ -2044,10 +2034,7 @@ def is_mrm_field(value): ]), 'recently_added': set(), 'mailchimp_mailing_lists': { - 'user': True, - 'other': True, - 'shared_gt': True, - 'shared_lt': True, + settings.MAILCHIMP_GENERAL_LIST: True }, 'osf_mailing_lists': { 'Open Science Framework Help': True @@ -2076,7 +2063,6 @@ def is_mrm_field(value): # mock mailchimp mock_client = mock.MagicMock() mock_get_mailchimp_api.return_value = mock_client - mock_client.lists.list.return_value = {'data': [{'id': x, 'list_name': list_name} for x, list_name in enumerate(self.user.mailchimp_mailing_lists)]} with run_celery_tasks(): # perform the merge diff --git a/requirements.txt b/requirements.txt index 343f21a7933..ab0940f9803 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,7 +21,7 @@ git+https://github.com/cos-forks/celery@v4.1.1+cos0 kombu==4.2.0 itsdangerous==1.1.0 lxml==4.6.5 -mailchimp==2.0.9 +mailchimp3==3.0.18 nameparser==0.5.3 bcrypt==3.1.4 python-dateutil==2.8.1 diff --git a/tests/test_mailchimp.py b/tests/test_mailchimp.py index e0e00899dc7..e5cf477ba9e 100644 --- a/tests/test_mailchimp.py +++ b/tests/test_mailchimp.py @@ -1,13 +1,15 @@ # -*- coding: utf-8 -*- +from hashlib import md5 import mock import pytest -from website import mailchimp_utils -from tests.base import OsfTestCase +from mailchimp3.mailchimpclient import MailChimpError from nose.tools import * # noqa; PEP8 asserts -from osf_tests.factories import UserFactory -import mailchimp from framework.celery_tasks import handlers +from osf_tests.factories import UserFactory +from tests.base import OsfTestCase +from website import mailchimp_utils +from website.settings import MAILCHIMP_GENERAL_LIST, MAILCHIMP_LIST_MAP @pytest.mark.enable_enqueue_task @@ -18,55 +20,36 @@ def setUp(self, *args, **kwargs): with self.context: handlers.celery_before_request() - @mock.patch('website.mailchimp_utils.get_mailchimp_api') - def test_get_list_id_from_name(self, mock_get_mailchimp_api): - list_name = 'foo' - mock_client = mock.MagicMock() - mock_get_mailchimp_api.return_value = mock_client - mock_client.lists.list.return_value = {'data': [{'id': 1, 'list_name': list_name}]} - list_id = mailchimp_utils.get_list_id_from_name(list_name) - mock_client.lists.list.assert_called_with(filters={'list_name': list_name}) - assert_equal(list_id, 1) - @mock.patch('website.mailchimp_utils.get_mailchimp_api') def test_get_list_name_from_id(self, mock_get_mailchimp_api): list_id = '12345' mock_client = mock.MagicMock() mock_get_mailchimp_api.return_value = mock_client - mock_client.lists.list.return_value = {'data': [{'id': list_id, 'name': 'foo'}]} + mock_client.lists.get.return_value = {'id': list_id, 'name': 'foo'} list_name = mailchimp_utils.get_list_name_from_id(list_id) - mock_client.lists.list.assert_called_with(filters={'list_id': list_id}) + mock_client.lists.get.assert_called_with(list_id=list_id) assert_equal(list_name, 'foo') @mock.patch('website.mailchimp_utils.get_mailchimp_api') - def test_subscribe_called_with_correct_arguments(self, mock_get_mailchimp_api): - list_name = 'foo' + def test_subscribe_called(self, mock_get_mailchimp_api): + list_name = MAILCHIMP_GENERAL_LIST user = UserFactory() mock_client = mock.MagicMock() mock_get_mailchimp_api.return_value = mock_client - mock_client.lists.list.return_value = {'data': [{'id': 1, 'list_name': list_name}]} + mock_client.lists.get.return_value = {'id': 1, 'list_name': list_name} list_id = mailchimp_utils.get_list_id_from_name(list_name) mailchimp_utils.subscribe_mailchimp(list_name, user._id) handlers.celery_teardown_request() - mock_client.lists.subscribe.assert_called_with( - id=list_id, - email={'email': user.username}, - merge_vars={ - 'fname': user.given_name, - 'lname': user.family_name, - }, - double_optin=False, - update_existing=True, - ) + mock_client.lists.members.create_or_update.assert_called() @mock.patch('website.mailchimp_utils.get_mailchimp_api') def test_subscribe_fake_email_does_not_throw_validation_error(self, mock_get_mailchimp_api): - list_name = 'foo' + list_name = MAILCHIMP_GENERAL_LIST user = UserFactory(username='fake@fake.com') + assert list_name not in user.mailchimp_mailing_lists mock_client = mock.MagicMock() mock_get_mailchimp_api.return_value = mock_client - mock_client.lists.list.return_value = {'data': [{'id': 1, 'list_name': list_name}]} - mock_client.lists.subscribe.side_effect = mailchimp.ValidationError + mock_client.lists.members.create.side_effect = MailChimpError mailchimp_utils.subscribe_mailchimp(list_name, user._id) handlers.celery_teardown_request() user.reload() @@ -74,13 +57,13 @@ def test_subscribe_fake_email_does_not_throw_validation_error(self, mock_get_mai @mock.patch('website.mailchimp_utils.get_mailchimp_api') def test_unsubscribe_called_with_correct_arguments(self, mock_get_mailchimp_api): - list_name = 'foo' + list_name = MAILCHIMP_GENERAL_LIST + list_id = MAILCHIMP_LIST_MAP[MAILCHIMP_GENERAL_LIST] user = UserFactory() + user_hash = md5(user.username.lower().encode()).hexdigest() mock_client = mock.MagicMock() mock_get_mailchimp_api.return_value = mock_client - mock_client.lists.list.return_value = {'data': [{'id': 2, 'list_name': list_name}]} - list_id = mailchimp_utils.get_list_id_from_name(list_name) mailchimp_utils.unsubscribe_mailchimp_async(list_name, user._id) handlers.celery_teardown_request() - mock_client.lists.unsubscribe.assert_called_with(id=list_id, email={'email': user.username}, send_goodbye=True) + mock_client.lists.members.delete.assert_called_with(list_id=list_id, subscriber_hash=user_hash) diff --git a/tests/test_views.py b/tests/test_views.py index 794893c918c..009ddaefdc1 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -3,6 +3,7 @@ """Views tests for the OSF.""" from __future__ import absolute_import +from hashlib import md5 import datetime as dt from rest_framework import status as http_status @@ -58,7 +59,7 @@ send_claim_email, send_claim_registered_email, ) -from website.settings import EXTERNAL_EMBER_APPS +from website.settings import EXTERNAL_EMBER_APPS, MAILCHIMP_GENERAL_LIST from website.project.views.node import _should_show_wiki_widget, abbrev_authors from website.util import api_url_for, web_url_for from website.util import rubeus @@ -1464,13 +1465,14 @@ def test_resend_confirmation_return_emails(self, send_mail): def test_update_user_mailing_lists(self, mock_get_mailchimp_api, send_mail): email = fake_email() self.user.emails.create(address=email) - list_name = 'foo' + list_name = MAILCHIMP_GENERAL_LIST self.user.mailchimp_mailing_lists[list_name] = True self.user.save() + user_hash = md5(self.user.username.lower().encode()).hexdigest() mock_client = mock.MagicMock() mock_get_mailchimp_api.return_value = mock_client - mock_client.lists.list.return_value = {'data': [{'id': 1, 'list_name': list_name}]} + mock_client.lists.get.return_value = {'id': 1, 'list_name': list_name} list_id = mailchimp_utils.get_list_id_from_name(list_name) url = api_url_for('update_user', uid=self.user._id) @@ -1482,21 +1484,21 @@ def test_update_user_mailing_lists(self, mock_get_mailchimp_api, send_mail): # the test app doesn't have celery handlers attached, so we need to call this manually. handlers.celery_teardown_request() - assert mock_client.lists.unsubscribe.called - mock_client.lists.unsubscribe.assert_called_with( - id=list_id, - email={'email': self.user.username}, - send_goodbye=True + assert mock_client.lists.members.delete.called + mock_client.lists.members.delete.assert_called_with( + list_id=list_id, + subscriber_hash=user_hash ) - mock_client.lists.subscribe.assert_called_with( - id=list_id, - email={'email': email}, - merge_vars={ - 'fname': self.user.given_name, - 'lname': self.user.family_name, - }, - double_optin=False, - update_existing=True + mock_client.lists.members.create.assert_called_with( + list_id=list_id, + data={ + 'status': 'subscribed', + 'email_address': email, + 'merge_fields': { + 'FNAME': self.user.given_name, + 'LNAME': self.user.family_name + } + } ) handlers.celery_teardown_request() @@ -1505,13 +1507,13 @@ def test_update_user_mailing_lists(self, mock_get_mailchimp_api, send_mail): def test_unsubscribe_mailchimp_not_called_if_user_not_subscribed(self, mock_get_mailchimp_api, send_mail): email = fake_email() self.user.emails.create(address=email) - list_name = 'foo' + list_name = MAILCHIMP_GENERAL_LIST self.user.mailchimp_mailing_lists[list_name] = False self.user.save() mock_client = mock.MagicMock() mock_get_mailchimp_api.return_value = mock_client - mock_client.lists.list.return_value = {'data': [{'id': 1, 'list_name': list_name}]} + mock_client.lists.get.return_value = {'id': 1, 'list_name': list_name} url = api_url_for('update_user', uid=self.user._id) emails = [ @@ -1520,8 +1522,8 @@ def test_unsubscribe_mailchimp_not_called_if_user_not_subscribed(self, mock_get_ payload = {'locale': '', 'id': self.user._id, 'emails': emails} self.app.put_json(url, payload, auth=self.user.auth) - assert_equal(mock_client.lists.unsubscribe.call_count, 0) - assert_equal(mock_client.lists.subscribe.call_count, 0) + assert_equal(mock_client.lists.members.delete.call_count, 0) + assert_equal(mock_client.lists.members.create.call_count, 0) handlers.celery_teardown_request() def test_user_update_region(self): @@ -4269,10 +4271,10 @@ def test_osf_help_mails_unsubscribe(self): @mock.patch('website.mailchimp_utils.get_mailchimp_api') def test_user_choose_mailing_lists_updates_user_dict(self, mock_get_mailchimp_api): user = AuthUserFactory() - list_name = 'OSF General' + list_name = MAILCHIMP_GENERAL_LIST mock_client = mock.MagicMock() mock_get_mailchimp_api.return_value = mock_client - mock_client.lists.list.return_value = {'data': [{'id': 1, 'list_name': list_name}]} + mock_client.lists.get.return_value = {'id': 1, 'list_name': list_name} list_id = mailchimp_utils.get_list_id_from_name(list_name) payload = {settings.MAILCHIMP_GENERAL_LIST: True} @@ -4290,14 +4292,7 @@ def test_user_choose_mailing_lists_updates_user_dict(self, mock_get_mailchimp_ap ) # check that user is subscribed - mock_client.lists.subscribe.assert_called_with(id=list_id, - email={'email': user.username}, - merge_vars={ - 'fname': user.given_name, - 'lname': user.family_name, - }, - double_optin=False, - update_existing=True) + mock_client.lists.members.create_or_update.assert_called() def test_get_mailchimp_get_endpoint_returns_200(self): url = api_url_for('mailchimp_get_endpoint') @@ -4310,14 +4305,14 @@ def test_mailchimp_webhook_subscribe_action_does_not_change_user(self, mock_get_ webhooks update the OSF database. """ list_id = '12345' - list_name = 'OSF General' + list_name = MAILCHIMP_GENERAL_LIST mock_client = mock.MagicMock() mock_get_mailchimp_api.return_value = mock_client - mock_client.lists.list.return_value = {'data': [{'id': list_id, 'name': list_name}]} + mock_client.lists.get.return_value = {'id': list_id, 'name': list_name} # user is not subscribed to a list user = AuthUserFactory() - user.mailchimp_mailing_lists = {'OSF General': False} + user.mailchimp_mailing_lists = {MAILCHIMP_GENERAL_LIST: False} user.save() # user subscribes and webhook sends request to OSF @@ -4375,7 +4370,7 @@ def test_sync_data_from_mailchimp_unsubscribes_user(self, mock_get_mailchimp_api list_name = 'OSF General' mock_client = mock.MagicMock() mock_get_mailchimp_api.return_value = mock_client - mock_client.lists.list.return_value = {'data': [{'id': list_id, 'name': list_name}]} + mock_client.lists.get.return_value = {'id': list_id, 'name': list_name} # user is subscribed to a list user = AuthUserFactory() diff --git a/website/mailchimp_utils.py b/website/mailchimp_utils.py index beca3f21d0e..71835b231e7 100644 --- a/website/mailchimp_utils.py +++ b/website/mailchimp_utils.py @@ -1,34 +1,41 @@ # -*- coding: utf-8 -*- from django.db import transaction -import mailchimp +import hashlib +import mailchimp3 +from mailchimp3.mailchimpclient import MailChimpError from framework import sentry from framework.celery_tasks import app from framework.celery_tasks.handlers import queued_task from framework.auth.signals import user_confirmed +from osf.exceptions import OSFError from osf.models import OSFUser from website import settings def get_mailchimp_api(): if not settings.MAILCHIMP_API_KEY: - raise mailchimp.InvalidApiKeyError( + raise OSFError( 'An API key is required to connect to Mailchimp.' ) - return mailchimp.Mailchimp(settings.MAILCHIMP_API_KEY) + return mailchimp3.Mailchimp(settings.MAILCHIMP_API_KEY) def get_list_id_from_name(list_name): - m = get_mailchimp_api() - mailing_list = m.lists.list(filters={'list_name': list_name}) - return mailing_list['data'][0]['id'] + # Mailchimp appears to have deprecated lists filtering in v3 + try: + return settings.MAILCHIMP_LIST_MAP[list_name] + except KeyError: + raise OSFError( + 'List not found.' + ) def get_list_name_from_id(list_id): m = get_mailchimp_api() - mailing_list = m.lists.list(filters={'list_id': list_id}) - return mailing_list['data'][0]['name'] + mailing_list = m.lists.get(list_id=list_id) + return mailing_list['name'] @queued_task @@ -43,18 +50,18 @@ def subscribe_mailchimp(list_name, user_id): user.mailchimp_mailing_lists = {} try: - m.lists.subscribe( - id=list_id, - email={'email': user.username}, - merge_vars={ - 'fname': user.given_name, - 'lname': user.family_name, - }, - double_optin=False, - update_existing=True, + m.lists.members.create( + list_id=list_id, + data={ + 'status': 'subscribed', + 'email_address': user.username, + 'merge_fields': { + 'FNAME': user.given_name, + 'LNAME': user.family_name + } + } ) - - except (mailchimp.ValidationError, mailchimp.ListInvalidBounceMemberError, mailchimp.ListInvalidUnsubMemberError) as error: + except MailChimpError as error: sentry.log_exception() sentry.log_message(error) user.mailchimp_mailing_lists[list_name] = False @@ -74,17 +81,22 @@ def unsubscribe_mailchimp(list_name, user_id, username=None, send_goodbye=True): :raises: ListNotSubscribed if user not already subscribed """ user = OSFUser.load(user_id) + if not username: + username = user.username + user_hash = hashlib.md5(username.lower().encode()).hexdigest() m = get_mailchimp_api() list_id = get_list_id_from_name(list_name=list_name) # pass the error for unsubscribing a user from the mailchimp who has already been unsubscribed # and allow update mailing_list user field try: - m.lists.unsubscribe( - id=list_id, email={'email': username or user.username}, - send_goodbye=send_goodbye + m.lists.members.delete( + list_id=list_id, + subscriber_hash=user_hash ) - except mailchimp.ListNotSubscribedError: + except MailChimpError as error: + sentry.log_exception() + sentry.log_message(error) pass # Update mailing_list user field diff --git a/website/profile/views.py b/website/profile/views.py index f5e9f8320bc..c929c5a3c20 100644 --- a/website/profile/views.py +++ b/website/profile/views.py @@ -5,7 +5,7 @@ from django.utils import timezone from django.core.exceptions import ValidationError from flask import request -import mailchimp +from mailchimp3.mailchimpclient import MailChimpError from framework import sentry from framework.auth import utils as auth_utils @@ -24,7 +24,7 @@ from osf import features from osf.models import ApiOAuth2Application, ApiOAuth2PersonalToken, OSFUser -from osf.exceptions import BlockedEmailError +from osf.exceptions import BlockedEmailError, OSFError from osf.utils.requests import string_type_request_headers from website import mails from website import mailchimp_utils @@ -517,12 +517,12 @@ def update_mailchimp_subscription(user, list_name, subscription, send_goodbye=Tr if subscription: try: mailchimp_utils.subscribe_mailchimp(list_name, user._id) - except mailchimp.Error: + except (MailChimpError, OSFError): pass else: try: mailchimp_utils.unsubscribe_mailchimp_async(list_name, user._id, username=user.username, send_goodbye=send_goodbye) - except mailchimp.Error: + except (MailChimpError, OSFError): # User has already unsubscribed, so nothing to do pass diff --git a/website/settings/defaults.py b/website/settings/defaults.py index 4e623b93e41..80ccaa4ee58 100644 --- a/website/settings/defaults.py +++ b/website/settings/defaults.py @@ -167,6 +167,9 @@ def parent_dir(path): MAILCHIMP_WEBHOOK_SECRET_KEY = 'CHANGEME' # OSF secret key to ensure webhook is secure ENABLE_EMAIL_SUBSCRIPTIONS = True MAILCHIMP_GENERAL_LIST = 'Open Science Framework General' +MAILCHIMP_LIST_MAP = { + MAILCHIMP_GENERAL_LIST: '123', +} #Triggered emails OSF_HELP_LIST = 'Open Science Framework Help' From df06174d5c56c7836dfd03d9a9391dd2eec1be6c Mon Sep 17 00:00:00 2001 From: Matt Frazier Date: Wed, 7 Jun 2023 10:00:52 -0400 Subject: [PATCH 2/2] Allow MailChimp resubscribes --- tests/test_mailchimp.py | 2 +- tests/test_views.py | 7 +++++-- website/mailchimp_utils.py | 5 ++++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/test_mailchimp.py b/tests/test_mailchimp.py index e5cf477ba9e..a881513d7cd 100644 --- a/tests/test_mailchimp.py +++ b/tests/test_mailchimp.py @@ -49,7 +49,7 @@ def test_subscribe_fake_email_does_not_throw_validation_error(self, mock_get_mai assert list_name not in user.mailchimp_mailing_lists mock_client = mock.MagicMock() mock_get_mailchimp_api.return_value = mock_client - mock_client.lists.members.create.side_effect = MailChimpError + mock_client.lists.members.create_or_update.side_effect = MailChimpError mailchimp_utils.subscribe_mailchimp(list_name, user._id) handlers.celery_teardown_request() user.reload() diff --git a/tests/test_views.py b/tests/test_views.py index 009ddaefdc1..920d46a0d07 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1464,6 +1464,7 @@ def test_resend_confirmation_return_emails(self, send_mail): @mock.patch('website.mailchimp_utils.get_mailchimp_api') def test_update_user_mailing_lists(self, mock_get_mailchimp_api, send_mail): email = fake_email() + email_hash = md5(email.lower().encode()).hexdigest() self.user.emails.create(address=email) list_name = MAILCHIMP_GENERAL_LIST self.user.mailchimp_mailing_lists[list_name] = True @@ -1489,10 +1490,12 @@ def test_update_user_mailing_lists(self, mock_get_mailchimp_api, send_mail): list_id=list_id, subscriber_hash=user_hash ) - mock_client.lists.members.create.assert_called_with( + mock_client.lists.members.create_or_update.assert_called_with( list_id=list_id, + subscriber_hash=email_hash, data={ 'status': 'subscribed', + 'status_if_new': 'subscribed', 'email_address': email, 'merge_fields': { 'FNAME': self.user.given_name, @@ -1523,7 +1526,7 @@ def test_unsubscribe_mailchimp_not_called_if_user_not_subscribed(self, mock_get_ self.app.put_json(url, payload, auth=self.user.auth) assert_equal(mock_client.lists.members.delete.call_count, 0) - assert_equal(mock_client.lists.members.create.call_count, 0) + assert_equal(mock_client.lists.members.create_or_update.call_count, 0) handlers.celery_teardown_request() def test_user_update_region(self): diff --git a/website/mailchimp_utils.py b/website/mailchimp_utils.py index 71835b231e7..c60035769a8 100644 --- a/website/mailchimp_utils.py +++ b/website/mailchimp_utils.py @@ -43,6 +43,7 @@ def get_list_name_from_id(list_id): @transaction.atomic def subscribe_mailchimp(list_name, user_id): user = OSFUser.load(user_id) + user_hash = hashlib.md5(user.username.lower().encode()).hexdigest() m = get_mailchimp_api() list_id = get_list_id_from_name(list_name=list_name) @@ -50,10 +51,12 @@ def subscribe_mailchimp(list_name, user_id): user.mailchimp_mailing_lists = {} try: - m.lists.members.create( + m.lists.members.create_or_update( list_id=list_id, + subscriber_hash=user_hash, data={ 'status': 'subscribed', + 'status_if_new': 'subscribed', 'email_address': user.username, 'merge_fields': { 'FNAME': user.given_name,