Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add cron urls for user verification #2160

Merged
merged 23 commits into from
Aug 31, 2023
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ example_project/static/
codeforlife_portal.egg-info
*.egg-info/
build/
.vscode/
# .vscode/
dist/
node_modules

Expand Down
20 changes: 20 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Django Server",
"type": "python",
"request": "launch",
"django": true,
"justMyCode": false,
"program": "${workspaceFolder}/example_project/manage.py",
"args": [
"runserver",
"localhost:8000"
]
}
]
}
5 changes: 4 additions & 1 deletion cfl_common/common/helpers/emails.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,12 @@ def generate_token(user, new_email="", preverified=False):
user.userprofile.is_verified = preverified
user.userprofile.save()

return generate_token_for_email(user.email, new_email)

def generate_token_for_email(email: str, new_email: str = ""):
return jwt.encode(
{
"email": user.email,
"email": email,
"new_email": new_email,
"email_verification_token": uuid4().hex[:30],
"expires": (timezone.now() + datetime.timedelta(hours=1)).timestamp(),
Expand Down
1 change: 1 addition & 0 deletions portal/mixins/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .cron_mixin import CronMixin
12 changes: 12 additions & 0 deletions portal/mixins/cron_mixin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from rest_framework.request import Request
from rest_framework.response import Response

from ..permissions import IsCronRequestFromGoogle


class CronMixin:
http_method_names = ["get"]
permission_classes = [IsCronRequestFromGoogle]

def get(self, request: Request) -> Response:
raise NotImplementedError()
1 change: 1 addition & 0 deletions portal/permissions/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .is_cron_request_from_google import IsCronRequestFromGoogle
14 changes: 14 additions & 0 deletions portal/permissions/is_cron_request_from_google.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from django.conf import settings
from rest_framework.permissions import BasePermission
from rest_framework.request import Request
from rest_framework.views import View


class IsCronRequestFromGoogle(BasePermission):
"""
Validate that requests to your cron URLs are coming from App Engine and not from another source.
https://cloud.google.com/appengine/docs/flexible/scheduling-jobs-with-cron-yaml#securing_urls_for_cron
"""

def has_permission(self, request: Request, view: View):
return settings.DEBUG or request.META.get("HTTP_X_APPENGINE_CRON") == "true"
83 changes: 82 additions & 1 deletion portal/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@
import io
import json
from datetime import timedelta, date
from unittest.mock import patch, Mock, ANY

import PyPDF2
import pytest
from aimmo.models import Game
from common.models import Teacher, UserSession, Student, Class, DailyActivity, School
from common.models import Teacher, UserSession, Student, Class, DailyActivity, School, UserProfile
from common.tests.utils.classes import create_class_directly
from common.tests.utils.organisation import create_organisation_directly, join_teacher_to_organisation
from common.tests.utils.student import (
Expand All @@ -15,13 +16,15 @@
create_independent_student_directly,
)
from common.tests.utils.teacher import signup_teacher_directly
from cfl_common.common.helpers.emails import NOTIFICATION_EMAIL
from django.contrib.auth.models import User
from django.test import Client, TestCase
from django.urls import reverse
from django.utils import timezone
from game.models import Level
from game.tests.utils.attempt import create_attempt
from game.tests.utils.level import create_save_level
from rest_framework.test import APIClient, APITestCase

from portal.templatetags.app_tags import is_logged_in_as_admin_teacher

Expand Down Expand Up @@ -706,3 +709,81 @@ def test_logged_in_as_admin_check(self):
assert not is_logged_in_as_admin_teacher(teacher2.new_user)

c.logout()


# CRON view tests


class CronTestClient(APIClient):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs, HTTP_X_APPENGINE_CRON="true")

def generic(
self,
method,
path,
data="",
content_type="application/octet-stream",
secure=False,
**extra,
):
wsgi_response = super().generic(method, path, data, content_type, secure, **extra)
assert 200 <= wsgi_response.status_code < 300, f"Response has error status code: {wsgi_response.status_code}"

return wsgi_response


class CronTestCase(APITestCase):
client_class = CronTestClient


class TestUser(CronTestCase):
# TODO: use fixtures
def setUp(self):
self.user = User.objects.create_user(
username="johndoe",
email="[email protected]",
password="password",
first_name="John",
last_name="Doe",
)
self.user_profile = UserProfile.objects.create(user=self.user)

@patch("cfl_common.common.helpers.emails.send_email")
def test_first_verify_email_reminder_view(self, send_email: Mock):
self.user.date_joined = timezone.now() - timedelta(days=7, hours=12)
self.user.save()

self.client.get(reverse("first-verify-email-reminder"))

send_email.assert_called_once_with(
sender=NOTIFICATION_EMAIL,
recipients=[self.user.email],
subject=ANY,
title=ANY,
text_content=ANY,
)

@patch("cfl_common.common.helpers.emails.send_email")
def test_second_verify_email_reminder_view(self, send_email: Mock):
self.user.date_joined = timezone.now() - timedelta(days=14, hours=12)
self.user.save()

self.client.get(reverse("second-verify-email-reminder"))

send_email.assert_called_once_with(
sender=NOTIFICATION_EMAIL,
recipients=[self.user.email],
subject=ANY,
title=ANY,
text_content=ANY,
)

def test_delete_unverified_accounts_view(self):
self.user.date_joined = timezone.now() - timedelta(days=19, hours=12)
self.user.save()

self.client.get(reverse("delete-unverified-accounts"))

with self.assertRaises(User.DoesNotExist):
User.objects.get(id=self.user.id)
31 changes: 31 additions & 0 deletions portal/urls.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from aimmo.urls import HOMEPAGE_REGEX
from common.permissions import teacher_verified
from django.conf.urls import include, url
from django.urls import path
from django.http import HttpResponse
from django.views.generic import RedirectView
from django.views.generic.base import TemplateView
Expand Down Expand Up @@ -88,6 +89,7 @@
teacher_download_csv,
teacher_view_class,
)
from portal.views import cron

from portal.views.two_factor.core import CustomSetupView
from portal.views.two_factor.profile import CustomDisableView
Expand All @@ -105,6 +107,35 @@


urlpatterns = [
path(
"cron/",
include(
[
path(
"user/",
include(
[
path(
"unverified/send-first-reminder/",
cron.user.FirstVerifyEmailReminderView.as_view(),
name="first-verify-email-reminder",
),
path(
"unverified/send-second-reminder/",
cron.user.SecondVerifyEmailReminderView.as_view(),
name="second-verify-email-reminder",
),
path(
"unverified/delete/",
cron.user.DeleteUnverifiedAccounts.as_view(),
name="delete-unverified-accounts",
),
]
),
),
]
),
),
url(HOMEPAGE_REGEX, include("aimmo.urls")),
url(r"^teach/kurono/dashboard/$", TeacherAimmoDashboard.as_view(), name="teacher_aimmo_dashboard"),
url(r"^play/kurono/dashboard/$", StudentAimmoDashboard.as_view(), name="student_aimmo_dashboard"),
Expand Down
1 change: 1 addition & 0 deletions portal/views/cron/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .user import *
139 changes: 139 additions & 0 deletions portal/views/cron/user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import logging
from datetime import timedelta

from django.contrib.auth.models import User
from django.urls import reverse
from django.utils import timezone
from rest_framework.response import Response
from rest_framework.views import APIView

from cfl_common.common.helpers.emails import (
NOTIFICATION_EMAIL,
generate_token_for_email,
send_email,
)

from ...mixins import CronMixin

# TODO: move email templates to DotDigital.
USER_1ST_VERIFY_EMAIL_REMINDER_DAYS = 7
USER_1ST_VERIFY_EMAIL_REMINDER_TEXT = (
"Please go to the link below to verify your email address:"
"\n{email_verification_url}."
"\nYou will not be able to use your account until it is verified."
"\n\nBy activating the account you confirm that you have read and agreed to"
" our terms ({terms_url}) and our privacy notice ({privacy_notice_url}). If"
" your account is not verified within 12 days we will delete it."
)
USER_2ND_VERIFY_EMAIL_REMINDER_DAYS = 14
USER_2ND_VERIFY_EMAIL_REMINDER_TEXT = (
"Please go to the link below to verify your email address:"
"\n{email_verification_url}."
"You will not be able to use your account until it is verified."
"\n\nBy activating the account you confirm that you have read and agreed to"
" our terms ({terms_url}) and our privacy notice ({privacy_notice_url}). If"
" your account is not verified within 5 days we will delete it."
)
USER_DELETE_UNVERIFIED_ACCOUNT_DAYS = 19


class FirstVerifyEmailReminderView(CronMixin, APIView):
def get(self, request):
now = timezone.now()

emails = User.objects.filter(
userprofile__is_verified=False,
date_joined__lte=now - timedelta(days=USER_1ST_VERIFY_EMAIL_REMINDER_DAYS),
date_joined__gt=now - timedelta(days=USER_1ST_VERIFY_EMAIL_REMINDER_DAYS + 1),
).values_list("email", flat=True)

logging.info(f"{len(emails)} emails unverified.")

if emails:
terms_url = request.build_absolute_uri(reverse("terms"))
privacy_notice_url = request.build_absolute_uri(reverse("privacy_notice"))

sent_email_count = 0
for email in emails:
try:
send_email(
sender=NOTIFICATION_EMAIL,
recipients=[email],
subject="Awaiting verification",
title="Awaiting verification",
text_content=USER_1ST_VERIFY_EMAIL_REMINDER_TEXT.format(
email_verification_url=request.build_absolute_uri(
reverse(
"verify_email",
kwargs={"token": generate_token_for_email(email)},
)
),
terms_url=terms_url,
privacy_notice_url=privacy_notice_url,
),
)

sent_email_count += 1
except Exception as ex:
logging.exception(ex)

logging.info(f"Sent {sent_email_count}/{len(emails)} emails.")

return Response()


class SecondVerifyEmailReminderView(CronMixin, APIView):
def get(self, request):
now = timezone.now()

emails = User.objects.filter(
userprofile__is_verified=False,
date_joined__lte=now - timedelta(days=USER_2ND_VERIFY_EMAIL_REMINDER_DAYS),
date_joined__gt=now - timedelta(days=USER_2ND_VERIFY_EMAIL_REMINDER_DAYS + 1),
).values_list("email", flat=True)

logging.info(f"{len(emails)} emails unverified.")

if emails:
terms_url = request.build_absolute_uri(reverse("terms"))
privacy_notice_url = request.build_absolute_uri(reverse("privacy_notice"))

sent_email_count = 0
for email in emails:
try:
send_email(
sender=NOTIFICATION_EMAIL,
recipients=[email],
subject="Your account needs verification",
title="Your account needs verification",
text_content=USER_2ND_VERIFY_EMAIL_REMINDER_TEXT.format(
email_verification_url=request.build_absolute_uri(
reverse(
"verify_email",
kwargs={"token": generate_token_for_email(email)},
)
),
terms_url=terms_url,
privacy_notice_url=privacy_notice_url,
),
)

sent_email_count += 1
except Exception as ex:
logging.exception(ex)

logging.info(f"Sent {sent_email_count}/{len(emails)} emails.")

return Response()


class DeleteUnverifiedAccounts(CronMixin, APIView):
def get(self, request):
user_count, _ = User.objects.filter(
userprofile__is_verified=False,
date_joined__lte=timezone.now() - timedelta(days=USER_DELETE_UNVERIFIED_ACCOUNT_DAYS),
).delete()

logging.info(f"{user_count} unverified users deleted.")

return Response()
Loading