diff --git a/TWLight/applications/forms.py b/TWLight/applications/forms.py index 2ee92194dd..142a2c60ac 100644 --- a/TWLight/applications/forms.py +++ b/TWLight/applications/forms.py @@ -11,6 +11,7 @@ form that takes a dict of required fields, and constructs the form accordingly. (See the docstring of BaseApplicationForm for the expected dict format.) """ + from dal import autocomplete from crispy_forms.bootstrap import InlineField from crispy_forms.helper import FormHelper diff --git a/TWLight/applications/views.py b/TWLight/applications/views.py index 5446ba5d17..a39b4ec34c 100644 --- a/TWLight/applications/views.py +++ b/TWLight/applications/views.py @@ -4,6 +4,7 @@ Examples: users apply for access; coordinators evaluate applications and assign status. """ + import logging import urllib.error import urllib.parse diff --git a/TWLight/emails/tasks.py b/TWLight/emails/tasks.py index 0fbf3e0f83..7e283e95c4 100644 --- a/TWLight/emails/tasks.py +++ b/TWLight/emails/tasks.py @@ -40,16 +40,12 @@ from TWLight.applications.signals import Reminder from TWLight.resources.models import AccessCode, Partner from TWLight.users.groups import get_restricted -from TWLight.users.signals import Notice, UserLoginRetrieval +from TWLight.users.signals import Notice, Survey, UserLoginRetrieval logger = logging.getLogger(__name__) -# COMMENT NOTIFICATION -# ------------------------------------------------------------------------------ - - class CommentNotificationEmailEditors(template_mail.TemplateMail): name = "comment_notification_editors" @@ -74,6 +70,10 @@ class RejectionNotification(template_mail.TemplateMail): name = "rejection_notification" +class SurveyActiveUser(template_mail.TemplateMail): + name = "survey_active_user" + + class CoordinatorReminderNotification(template_mail.TemplateMail): name = "coordinator_reminder_notification" @@ -169,6 +169,40 @@ def send_user_renewal_notice_emails(sender, **kwargs): ) +@receiver(Survey.survey_active_user) +def send_survey_active_user_emails(sender, **kwargs): + """ + Any time the related managment command is run, this sends a survey + invitation to qualifying editors. + """ + user_email = kwargs["user_email"] + user_lang = kwargs["user_lang"] + survey_id = kwargs["survey_id"] + survey_langs = kwargs["survey_langs"] + + # Default survey language is english + survey_lang = "en" + + # Set survey language to user language if available + if user_lang in survey_langs: + survey_lang = user_lang + + base_url = "https://wikimediafoundation.limesurvey.net/" + link = "{base}{id}?lang={lang}".format( + base=base_url, id=survey_id, lang=survey_lang + ) + + email = SurveyActiveUser() + + email.send( + user_email, + { + "lang": user_lang, + "link": link, + }, + ) + + @receiver(comment_was_posted) def send_comment_notification_emails(sender, **kwargs): """ diff --git a/TWLight/emails/templates/emails/survey_active_user-body-html.html b/TWLight/emails/templates/emails/survey_active_user-body-html.html new file mode 100644 index 0000000000..76853ab0b6 --- /dev/null +++ b/TWLight/emails/templates/emails/survey_active_user-body-html.html @@ -0,0 +1,23 @@ +{% load i18n %} + + +{% comment %}Translators: This email is sent to users when we invite them to respond to a survey. Don't translate Jinja variables in curly braces like {{ link }}. Translate Wikipedia Library in the same way as the global branch is named (click through from https://meta.wikimedia.org/wiki/The_Wikipedia_Library).{% endcomment %} +{% blocktranslate trimmed %} +

Hi,

+

We are currently running a survey to understand how editors use +The Wikipedia Library. According to our records, you have logged in to +The Wikipedia Library at least once, so we would love to hear from you!

+

{{ link }}

+

We really appreciate your feedback - this survey will help us understand how +The Wikipedia Library is currently being used, and where it could use +improvement. Your responses will directly impact how the Wikimedia Foundation +prioritises technical and partnership work for this project.

+

The survey should take 5-10 minutes to complete.

+

If you would like to opt-out of being contacted with future Wikipedia Library +surveys, please let us know by emailing wikipedialibrary@wikimedia.org and we +will remove you from any future mailing.

+

Thanks,

+

The Wikipedia Library team

+{% endblocktranslate %} + + diff --git a/TWLight/emails/templates/emails/survey_active_user-body-text.html b/TWLight/emails/templates/emails/survey_active_user-body-text.html new file mode 100644 index 0000000000..1a0b383bfe --- /dev/null +++ b/TWLight/emails/templates/emails/survey_active_user-body-text.html @@ -0,0 +1,26 @@ +{% load i18n %} +{% comment %}Translators: This email is sent to users when we invite them to respond to a survey. Don't translate Jinja variables in curly braces like {{ link }}. Translate Wikipedia Library in the same way as the global branch is named (click through from https://meta.wikimedia.org/wiki/The_Wikipedia_Library).{% endcomment %} +{% blocktranslate trimmed %} +Hi, + +We are currently running a survey to understand how editors use +The Wikipedia Library. According to our records, you have logged in to +The Wikipedia Library at least once, so we would love to hear from you! + +{{ link }} + +We really appreciate your feedback - this survey will help us understand how +The Wikipedia Library is currently being used, and where it could use +improvement. Your responses will directly impact how the Wikimedia Foundation +prioritises technical and partnership work for this project. + +The survey should take 5-10 minutes to complete. + +If you would like to opt-out of being contacted with future Wikipedia Library +surveys, please let us know by emailing wikipedialibrary@wikimedia.org and we +will remove you from any future mailing. + +Thanks, + +The Wikipedia Library team +{% endblocktranslate %} diff --git a/TWLight/emails/templates/emails/survey_active_user-subject.html b/TWLight/emails/templates/emails/survey_active_user-subject.html new file mode 100644 index 0000000000..847dd6783f --- /dev/null +++ b/TWLight/emails/templates/emails/survey_active_user-subject.html @@ -0,0 +1,4 @@ +{% load i18n %} + +{% comment %}Translators: This is the subject of an email sent to users when we invite them to respond to a survey. Translate Wikipedia Library in the same way as the global branch is named (click through from https://meta.wikimedia.org/wiki/The_Wikipedia_Library).{% endcomment %} +{% trans 'The Wikipedia Library needs your help!' %} diff --git a/TWLight/emails/tests.py b/TWLight/emails/tests.py index 157940f1dc..b4ef0cdd21 100644 --- a/TWLight/emails/tests.py +++ b/TWLight/emails/tests.py @@ -12,6 +12,7 @@ from django.core import mail from django.core.management import call_command from django.urls import reverse +from django.utils import timezone from django.test import TestCase, RequestFactory from TWLight.applications.factories import ( @@ -33,6 +34,7 @@ send_approval_notification_email, send_rejection_notification_email, send_user_renewal_notice_emails, + send_survey_active_user_emails, ) @@ -712,3 +714,142 @@ def test_send_coordinator_reminder_email(self): self.assertIn("1 pending application", mail.outbox[0].body) self.assertIn("1 under discussion application", mail.outbox[0].body) self.assertIn("1 approved application", mail.outbox[0].body) + + +class SurveyActiveUsersEmailTest(TestCase): + @classmethod + def setUpTestData(cls): + """ + Creates a survey-eligible user and several eligible users. + Returns + ------- + None + """ + + now = timezone.now() + + eligible = EditorFactory(user__email="editor@example.com") + eligible.wp_not_blocked = True + eligible.wp_bundle_eligible = True + eligible.wp_account_old_enough = True + eligible.wp_registered = now - timedelta(days=182) + eligible.wp_enough_edits = True + eligible.user.userprofile.terms_of_use = True + eligible.user.userprofile.save() + eligible.user.last_login = now + eligible.user.save() + eligible.save() + cls.eligible = eligible.user + + blocked = EditorFactory(user__email="blocked@example.com") + blocked.wp_not_blocked = False + blocked.wp_bundle_eligible = True + blocked.wp_account_old_enough = True + blocked.wp_registered = now - timedelta(days=182) + blocked.wp_enough_edits = True + blocked.user.userprofile.terms_of_use = True + blocked.user.userprofile.save() + blocked.user.last_login = now + blocked.user.save() + blocked.save() + + already_sent = EditorFactory(user__email="alreadysent@example.com") + already_sent.wp_not_blocked = True + already_sent.wp_bundle_eligible = True + already_sent.wp_account_old_enough = True + already_sent.wp_registered = now - timedelta(days=182) + already_sent.wp_enough_edits = True + already_sent.user.userprofile.terms_of_use = True + already_sent.user.userprofile.survey_email_sent = True + already_sent.user.userprofile.save() + already_sent.user.last_login = now + already_sent.user.save() + already_sent.save() + + wmf_email = EditorFactory(user__email="editor@wikimedia.org") + wmf_email.wp_not_blocked = True + wmf_email.wp_bundle_eligible = True + wmf_email.wp_account_old_enough = True + wmf_email.wp_registered = now - timedelta(days=182) + wmf_email.wp_enough_edits = True + wmf_email.user.userprofile.terms_of_use = True + wmf_email.user.userprofile.save() + wmf_email.user.last_login = now + wmf_email.user.save() + wmf_email.save() + + too_new_at_login = EditorFactory(user__email="toonew@example.com") + too_new_at_login.wp_not_blocked = True + too_new_at_login.wp_bundle_eligible = True + too_new_at_login.wp_account_old_enough = True + too_new_at_login.wp_registered = now - timedelta(days=182) + too_new_at_login.wp_enough_edits = True + too_new_at_login.user.userprofile.terms_of_use = True + too_new_at_login.user.userprofile.save() + too_new_at_login.user.last_login = now - timedelta(days=30) + too_new_at_login.user.save() + too_new_at_login.save() + + not_enough_edits = EditorFactory(user__email="notenoughedits@example.com") + not_enough_edits.wp_not_blocked = True + not_enough_edits.wp_bundle_eligible = True + not_enough_edits.wp_account_old_enough = True + not_enough_edits.wp_registered = now - timedelta(days=182) + not_enough_edits.wp_enough_edits = False + not_enough_edits.user.userprofile.terms_of_use = True + not_enough_edits.user.userprofile.save() + not_enough_edits.user.last_login = now + not_enough_edits.user.save() + not_enough_edits.save() + + inactive = EditorFactory(user__email="inactive@example.com") + inactive.wp_not_blocked = True + inactive.wp_bundle_eligible = True + inactive.wp_account_old_enough = True + inactive.wp_registered = now - timedelta(days=182) + inactive.wp_enough_edits = True + inactive.user.userprofile.terms_of_use = True + inactive.user.userprofile.save() + inactive.user.last_login = now + inactive.user.is_active = False + inactive.user.save() + inactive.save() + + staff = EditorFactory(user__email="staff@example.com") + staff.wp_not_blocked = True + staff.wp_bundle_eligible = True + staff.wp_account_old_enough = True + staff.wp_registered = now - timedelta(days=182) + staff.wp_enough_edits = True + staff.user.userprofile.terms_of_use = True + staff.user.userprofile.save() + staff.user.last_login = now + staff.user.is_staff = True + staff.user.save() + staff.save() + + superuser = EditorFactory(user__email="superuser@example.com") + superuser.wp_not_blocked = True + superuser.wp_bundle_eligible = True + superuser.wp_account_old_enough = True + superuser.wp_registered = now - timedelta(days=182) + superuser.wp_enough_edits = True + superuser.user.userprofile.terms_of_use = True + superuser.user.userprofile.save() + superuser.user.last_login = now + superuser.user.is_superuser = True + superuser.user.save() + superuser.save() + + def test_survey_active_users_command(self): + self.assertFalse(self.eligible.userprofile.survey_email_sent) + call_command( + "survey_active_users", + "000001", + "en", + ) + + self.eligible.refresh_from_db() + self.assertTrue(self.eligible.userprofile.survey_email_sent) + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].to, [self.eligible.email]) diff --git a/TWLight/users/management/commands/survey_active_users.py b/TWLight/users/management/commands/survey_active_users.py new file mode 100644 index 0000000000..9903deeedc --- /dev/null +++ b/TWLight/users/management/commands/survey_active_users.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +from django.contrib.auth.models import User +from django.core.management.base import BaseCommand +from django.db.models import DurationField, ExpressionWrapper, F, Q +from django.db.models.functions import TruncDate +from django.utils.timezone import timedelta + +from TWLight.users.groups import get_restricted +from TWLight.users.signals import Survey + + +class Command(BaseCommand): + help = "Sends survey invitation email to active users." + + def add_arguments(self, parser): + parser.add_argument( + "--staff_test", + action="store_true", + help="A flag to email only to staff users who qualify other than staff status", + ) + parser.add_argument( + "survey_id", type=int, help="ID number for corresponding survey" + ) + parser.add_argument( + "lang", + nargs="+", + type=str, + help="List of localized language codes for this survey", + ) + + def handle(self, *args, **options): + staff_check = options["staff_test"] + + # All Wikipedia Library users who: + for user in ( + User.objects.select_related("editor", "userprofile") + .annotate( + # calculate account age at last login + last_login_age=ExpressionWrapper( + TruncDate(F("last_login")) - F("editor__wp_registered"), + output_field=DurationField(), + ) + ) + .filter( + # have not restricted data processing + ~Q(groups__name__in=[get_restricted()]), + # meet the block criterion or have the 'ignore wp blocks' exemption + Q(editor__wp_not_blocked=True) | Q(editor__ignore_wp_blocks=True), + # have an non-wikimedia.org email address + Q(email__isnull=False) & ~Q(email__endswith="@wikimedia.org"), + # have not already received the email + userprofile__survey_email_sent=False, + # meet the 6 month criterion as of last login + last_login_age__gte=timedelta(days=182), + # meet the 500 edit criterion + editor__wp_enough_edits=True, + # are 'active' + is_active=True, + # are not staff + is_staff=staff_check, + # are not superusers + is_superuser=False, + ) + .order_by("last_login") + ): + # Send the email + Survey.survey_active_user.send( + sender=self.__class__, + user_email=user.email, + user_lang=user.userprofile.lang, + survey_id=options["survey_id"], + survey_langs=options["lang"], + ) + + # Record that we sent the email so that we only send one. + user.userprofile.survey_email_sent = True + user.userprofile.save() diff --git a/TWLight/users/models.py b/TWLight/users/models.py index 216298752e..93bfb7e0c0 100644 --- a/TWLight/users/models.py +++ b/TWLight/users/models.py @@ -190,12 +190,14 @@ class Meta: default=True, help_text="Does this coordinator want approved app reminder notices?", ) - favorites = models.ManyToManyField( Partner, blank=True, help_text="The partner(s) that the user has marked as favorite.", ) + survey_email_sent = models.BooleanField( + default=False, help_text="Has this user recieved the most recent survey email?" + ) def delete_my_library_cache(self): """ diff --git a/TWLight/users/signals.py b/TWLight/users/signals.py index 3020dc9b21..ab346d8104 100644 --- a/TWLight/users/signals.py +++ b/TWLight/users/signals.py @@ -25,6 +25,10 @@ class Notice(object): user_renewal_notice = Signal() +class Survey(object): + survey_active_user = Signal() + + class UserLoginRetrieval(object): user_retrieve_monthly_logins = Signal() diff --git a/TWLight/view_mixins.py b/TWLight/view_mixins.py index c768aa4548..4682680147 100644 --- a/TWLight/view_mixins.py +++ b/TWLight/view_mixins.py @@ -7,6 +7,7 @@ test functions and login URLs would overwrite each other. Using the dispatch function and super() means we can chain as many access tests as we'd like. """ + from itertools import chain from urllib.parse import urlencode from urllib.parse import ParseResult