- Adds Email consent screen as per the spec for existing and new users.
- Prevent random test failures
This commit is contained in:
Matjaž Horvat 2024-05-20 15:19:12 +02:00 коммит произвёл GitHub
Родитель 071829c820
Коммит 7f78be7d7f
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
17 изменённых файлов: 361 добавлений и 10 удалений

Просмотреть файл

@ -9,4 +9,11 @@ FXA_CLIENT_ID=727f0251c388a993
FXA_SECRET_KEY=e43fd751ca5687d28288098e3e9b1294792ed9954008388e39b1cdaac0a1ebd6
FXA_OAUTH_ENDPOINT=https://oauth.stage.mozaws.net/v1
FXA_PROFILE_ENDPOINT=https://profile.stage.mozaws.net/v1
EMAIL_CONSENT_ENABLED = False
EMAIL_CONSENT_TITLE = Lets keep in touch
EMAIL_CONSENT_MAIN_TEXT = Want to stay up to date and informed about all localization matters at Mozilla? Just hit the button below to get the latest updates, announcements about new Pontoon features, invitations to contributor events and more. We wont spam you — promise!
EMAIL_CONSENT_PRIVACY_NOTICE = By enabling email communications, I agree to Mozilla handling my personal information as explained in this <a href="https://www.mozilla.org/privacy/websites/">Privacy Notice</a>.
EMAIL_COMMUNICATIONS_HELP_TEXT = Get the latest updates about localization at Mozilla, announcements about new Pontoon features, invitations to contributor events and more.<br/><br/>By enabling email communications, I agree to Mozilla handling my personal information as explained in this <a href="https://www.mozilla.org/privacy/websites/">Privacy Notice</a>.
PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python

Просмотреть файл

@ -108,7 +108,7 @@ you create:
Adds some additional django apps that can be helpful during day to day development.
``EMAIL_HOST``
SMTP host (default: ``'smtp.sendgrid.net'``)
SMTP host (default: ``'smtp.sendgrid.net'``).
``EMAIL_HOST_PASSWORD``
Password for the SMTP connection.
@ -117,13 +117,29 @@ you create:
Username for the SMTP connection (default: ``'apikey'``).
``EMAIL_PORT``
SMTP port (default: ``587``)
SMTP port (default: ``587``).
``EMAIL_USE_TLS``
Use explicit TLS for the SMTP connection (default: ``True``)
Use explicit TLS for the SMTP connection (default: ``True``).
``EMAIL_USE_SSL``
Use implicit TLS for the SMTP connection (default: ``False``)
Use implicit TLS for the SMTP connection (default: ``False``).
``EMAIL_CONSENT_ENABLED``
Optional. Enables Email consent page (default: ``False``).
``EMAIL_CONSENT_TITLE``
Optional, unless ``EMAIL_CONSENT_ENABLED`` is ``True``.
Title of the Email consent page.
``EMAIL_CONSENT_MAIN_TEXT``
Optional, unless ``EMAIL_CONSENT_ENABLED`` is ``True``.
Main text of the Email consent page. You can use that to explain what type
of communication to expect among other things.
``EMAIL_CONSENT_PRIVACY_NOTICE``
Optional. Privacy notice on the Email consent page. It's possible to use HTML and
link to external privacy notice page.
``EMAIL_COMMUNICATIONS_HELP_TEXT``
Optional. Help text to use under the Email communications checkbox in user settings.

Просмотреть файл

@ -1,7 +1,10 @@
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.http import Http404, HttpResponseForbidden
from django.shortcuts import redirect
from django.urls import reverse
from django.utils.deprecation import MiddlewareMixin
from pontoon.base.utils import is_ajax
from raygun4py.middleware.django import Provider
@ -34,3 +37,30 @@ class BlockedIpMiddleware(MiddlewareMixin):
return HttpResponseForbidden("<h1>Forbidden</h1>")
return None
class EmailConsentMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
if not settings.EMAIL_CONSENT_ENABLED:
return response
if not request.user.is_authenticated:
return response
if request.user.profile.email_consent_dismissed_at is not None:
return response
if is_ajax(request):
return response
email_consent_url = "pontoon.messaging.email_consent"
if request.path == reverse(email_consent_url):
return response
request.session["next_path"] = request.get_full_path()
return redirect(email_consent_url)

Просмотреть файл

@ -0,0 +1,18 @@
# Generated by Django 4.2.11 on 2024-05-15 16:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("base", "0061_userprofile_email_communications_enabled"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="email_consent_dismissed_at",
field=models.DateTimeField(blank=True, null=True),
),
]

Просмотреть файл

@ -19,6 +19,7 @@ class UserProfile(models.Model):
contact_email = models.EmailField("Contact email address", blank=True, null=True)
contact_email_verified = models.BooleanField(default=False)
email_communications_enabled = models.BooleanField(default=False)
email_consent_dismissed_at = models.DateTimeField(null=True, blank=True)
# Theme
class Themes(models.TextChoices):

Просмотреть файл

@ -0,0 +1,31 @@
import pytest
from django.urls import reverse
from django.utils import timezone
@pytest.mark.django_db
def test_EmailConsentMiddleware(client, member, settings):
# By default, Email consent page is disabled
response = member.client.get("/")
assert response.status_code == 200
# If Email consent page is enabled, redirect any view to it
settings.EMAIL_CONSENT_ENABLED = True
response = member.client.get("/")
assert response.status_code == 302
# Unless that view is the Email consent page itself
response = member.client.get(reverse("pontoon.messaging.email_consent"))
assert response.status_code == 200
# Or the request is AJAX
response = member.client.get("/", headers={"x-requested-with": "XMLHttpRequest"})
assert response.status_code == 200
# Or the user has already dismissed the Email consent
profile = member.user.profile
profile.email_consent_dismissed_at = timezone.now()
profile.save()
response = member.client.get("/")
assert response.status_code == 200

Просмотреть файл

@ -556,20 +556,28 @@ def test_get_m2m_removed(user_a, user_b):
@pytest.mark.django_db
def test_get_m2m_mixed(user_a, user_b, user_c):
assert get_m2m_changes(
changes = get_m2m_changes(
get_user_model().objects.filter(pk__in=[user_b.pk, user_c.pk]),
get_user_model().objects.filter(pk__in=[user_a.pk, user_b.pk]),
) == ([user_a], [user_c])
)
assert [user_a] == changes[0]
assert [user_c] == changes[1]
assert get_m2m_changes(
changes = get_m2m_changes(
get_user_model().objects.filter(pk__in=[user_a.pk, user_b.pk]),
get_user_model().objects.filter(pk__in=[user_c.pk]),
) == ([user_c], [user_a, user_b])
)
assert [user_c] == changes[0]
assert user_a in changes[1]
assert user_b in changes[1]
assert get_m2m_changes(
changes = get_m2m_changes(
get_user_model().objects.filter(pk__in=[user_b.pk]),
get_user_model().objects.filter(pk__in=[user_c.pk, user_a.pk]),
) == ([user_a, user_c], [user_b])
)
assert user_a in changes[0]
assert user_c in changes[0]
assert [user_b] == changes[1]
def test_util_base_extension_in():

Просмотреть файл

Просмотреть файл

@ -0,0 +1,78 @@
body > header {
background: transparent;
border-color: transparent;
position: fixed;
width: 100%;
z-index: 10;
}
body > header.menu-opened {
border-color: var(--main-border-1);
}
#main section {
display: flex;
flex-direction: column;
height: 100vh;
background-image: var(--homepage-background-image);
background-attachment: fixed;
background-size: cover;
}
#main section .container {
display: flex;
flex: 1;
flex-direction: column;
align-items: start;
justify-content: center;
overflow: hidden;
}
#main .buttons {
display: flex;
margin-bottom: 10px;
}
#main .buttons button {
background: transparent;
border: none;
border-radius: 2px;
box-sizing: content-box;
color: var(--white-1);
display: flex;
font-size: 16px;
width: 120px;
height: 40px;
padding: 4px;
justify-content: center;
align-items: center;
font-weight: 400;
}
#main .buttons button.enable {
background-color: var(--status-translated);
color: var(--homepage-tour-button-color);
width: 320px;
}
#main h1 {
font-size: 64px;
margin-bottom: 10px;
}
#main p {
font-size: 22px;
font-weight: 300;
line-height: 36px;
margin-bottom: 60px;
width: 900px;
}
#main p.privacy-notice {
font-size: 14px;
}
#main p.privacy-notice a {
color: var(--status-translated);
}

Просмотреть файл

@ -0,0 +1,25 @@
$(function () {
$('.buttons button').click(function (e) {
e.preventDefault();
const self = $(this);
$.ajax({
url: '/dismiss-email-consent/',
type: 'POST',
data: {
csrfmiddlewaretoken: $('body').data('csrf'),
value: self.is('.enable'),
},
success: function (data) {
window.location.href = data.next;
},
error: function (request) {
if (request.responseText === 'error') {
Pontoon.endLoader('Oops, something went wrong.', 'error');
} else {
Pontoon.endLoader(request.responseText, 'error');
}
},
});
});
});

Просмотреть файл

@ -0,0 +1,29 @@
{% extends 'base.html' %}
{% block title %}Email consent{% endblock %}
{% block class %}email-consent{% endblock %}
{% block middle %}
<section id="main">
<section>
<div class="container">
<h1>{{ settings.EMAIL_CONSENT_TITLE }}</h1>
<p class="main-text">{{ settings.EMAIL_CONSENT_MAIN_TEXT|safe }}</p>
<div class="buttons">
<button class="enable">Enable email communications</button>
<button class="disable">No, thanks</button>
</div>
<p class="privacy-notice">{{ settings.EMAIL_CONSENT_PRIVACY_NOTICE|safe }}</p>
</div>
</section>
</section>
{% endblock %}
{% block extend_css %}
{% stylesheet 'email_consent' %}
{% endblock %}
{% block extend_js %}
{% javascript 'email_consent' %}
{% endblock %}

Просмотреть файл

Просмотреть файл

@ -0,0 +1,32 @@
import pytest
from pontoon.base.models import User
@pytest.mark.django_db
def test_dismiss_email_consent(member):
"""Test if dismiss_email_consent view works and fails as expected."""
params = {}
response = member.client.post(f"/dismiss-email-consent/", params)
assert response.status_code == 400
assert response.json()["message"] == "Bad Request: Value not set"
params = {
"value": "false",
}
response = member.client.post(f"/dismiss-email-consent/", params)
profile = User.objects.get(pk=member.user.pk).profile
assert profile.email_communications_enabled is False
assert profile.email_consent_dismissed_at is not None
assert response.status_code == 200
assert response.json()["next"] == "/"
params = {
"value": "true",
}
response = member.client.post(f"/dismiss-email-consent/", params)
profile = User.objects.get(pk=member.user.pk).profile
assert profile.email_communications_enabled is True
assert profile.email_consent_dismissed_at is not None
assert response.status_code == 200
assert response.json()["next"] == "/"

17
pontoon/messaging/urls.py Normal file
Просмотреть файл

@ -0,0 +1,17 @@
from django.urls import path
from . import views
urlpatterns = [
# Email consent
path(
"email-consent/",
views.email_consent,
name="pontoon.messaging.email_consent",
),
path(
"dismiss-email-consent/",
views.dismiss_email_consent,
name="pontoon.messaging.dismiss_email_consent",
),
]

Просмотреть файл

@ -0,0 +1,44 @@
import json
from django.contrib.auth.decorators import login_required
from django.db import transaction
from django.http import JsonResponse
from django.shortcuts import render
from django.utils import timezone
from django.views.decorators.http import require_POST
@login_required(redirect_field_name="", login_url="/403")
def email_consent(request):
return render(
request,
"messaging/email_consent.html",
)
@login_required(redirect_field_name="", login_url="/403")
@require_POST
@transaction.atomic
def dismiss_email_consent(request):
value = request.POST.get("value", None)
if not value:
return JsonResponse(
{
"status": False,
"message": "Bad Request: Value not set",
},
status=400,
)
profile = request.user.profile
profile.email_communications_enabled = json.loads(value)
profile.email_consent_dismissed_at = timezone.now()
profile.save()
return JsonResponse(
{
"status": True,
"next": request.session.get("next_path", "/"),
}
)

Просмотреть файл

@ -212,6 +212,10 @@ EMAIL_USE_SSL = os.environ.get("EMAIL_USE_SSL", "False") != "False"
EMAIL_HOST_PASSWORD = os.environ.get(
"EMAIL_HOST_PASSWORD", os.environ.get("SENDGRID_PASSWORD", "")
)
EMAIL_CONSENT_ENABLED = os.environ.get("EMAIL_CONSENT_ENABLED", "False") != "False"
EMAIL_CONSENT_TITLE = os.environ.get("EMAIL_CONSENT_TITLE", "")
EMAIL_CONSENT_MAIN_TEXT = os.environ.get("EMAIL_CONSENT_MAIN_TEXT", "")
EMAIL_CONSENT_PRIVACY_NOTICE = os.environ.get("EMAIL_CONSENT_PRIVACY_NOTICE", "")
EMAIL_COMMUNICATIONS_HELP_TEXT = os.environ.get("EMAIL_COMMUNICATIONS_HELP_TEXT", "")
# Log emails to console if the SendGrid credentials are missing.
@ -233,6 +237,7 @@ INSTALLED_APPS = (
"pontoon.insights",
"pontoon.localizations",
"pontoon.machinery",
"pontoon.messaging",
"pontoon.projects",
"pontoon.sync",
"pontoon.tags",
@ -287,6 +292,7 @@ MIDDLEWARE = (
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"csp.middleware.CSPMiddleware",
"pontoon.base.middleware.EmailConsentMiddleware",
)
CONTEXT_PROCESSORS = (
@ -511,6 +517,10 @@ PIPELINE_CSS = {
"source_filenames": ("css/homepage.css",),
"output_filename": "css/homepage.min.css",
},
"email_consent": {
"source_filenames": ("css/email_consent.css",),
"output_filename": "css/email_consent.min.css",
},
}
PIPELINE_JS = {
@ -645,6 +655,10 @@ PIPELINE_JS = {
"source_filenames": ("js/homepage.js",),
"output_filename": "js/homepage.min.js",
},
"email_consent": {
"source_filenames": ("js/email_consent.js",),
"output_filename": "js/email_consent.min.js",
},
}
PIPELINE = {

Просмотреть файл

@ -70,6 +70,7 @@ urlpatterns = [
path("", include("pontoon.api.urls")),
path("", include("pontoon.homepage.urls")),
path("", include("pontoon.uxactionlog.urls")),
path("", include("pontoon.messaging.urls")),
# Team page: Must be at the end
path("<locale:locale>/", team, name="pontoon.teams.team"),
]