Skip to content
Snippets Groups Projects
Commit ed0510dd authored by Andreas Ellewsen's avatar Andreas Ellewsen
Browse files

Merge branch 'GREG-252-handle-email-errors' into 'master'

Notify sponsors of bad emails in sent invites

See merge request !326
parents 97229573 d5984d30
No related branches found
No related tags found
1 merge request!326Notify sponsors of bad emails in sent invites
Pipeline #136888 passed with stages
in 10 minutes and 26 seconds
Showing
with 283 additions and 200 deletions
......@@ -82,7 +82,7 @@
"name": "Name",
"role": "Guest role",
"period": "Period",
"host": "Host",
"host": "Host",
"ou": "Organisation",
"department": "Department",
"choice": "Choices",
......@@ -103,6 +103,7 @@
"expiring_other": "Expiring in {{count}} days",
"expiring_one": "Expiring in {{count}} day",
"waitingForGuest": "Waiting for guest",
"invalidEmail": "Invalid e-mail address",
"waitingForSponsor": "Needs confirmation",
"invitationExpired": "Invitation expired"
},
......
......@@ -82,7 +82,7 @@
"name": "Navn",
"role": "Gjesterolle",
"period": "Periode",
"host": "Vert",
"host": "Vert",
"ou": "Organisasjon",
"department": "Avdeling",
"choice": "Valg",
......@@ -103,6 +103,7 @@
"expiring_other": "Utløper om {{count}} dager",
"expiring_one": "Utløper om {{count}} dag",
"waitingForGuest": "Venter på gjest",
"invalidEmail": "Ugyldig e-postadresse",
"waitingForSponsor": "Trenger godkjenning",
"invitationExpired": "Invitasjon utløpt"
},
......
......@@ -82,7 +82,7 @@
"name": "Namn",
"role": "Gjesterolle",
"period": "Periode",
"host": "Vert",
"host": "Vert",
"ou": "Organisasjonsenhet",
"department": "Enhet",
"choice": "Val",
......@@ -103,6 +103,7 @@
"expiring_other": "Utløper om {{count}} dagar",
"expiring_one": "Utløper om {{count}} dag",
"waitingForGuest": "Venter på gjest",
"invalidEmail": "Ugyldig e-postadresse",
"waitingForSponsor": "Trenger godkjenning",
"invitationExpired": "Invitasjon utløpt"
},
......
......@@ -82,16 +82,17 @@ const StyledTableHead = styled(TableHead)(({ theme }) => ({
borderRadius: '0',
}))
const Status = ({ person, role }: StatusProps) => {
const calculateStatus = (person: Guest, role: Role): [string, number] => {
const today = new Date()
today.setHours(0, 0, 0, 0)
const { t } = useTranslation('common')
let status = ''
const days = differenceInDays(role.end_date, today)
if (!person.registered) {
status = 'waitingForGuest'
if (person.invitation_status !== 'active') {
if (person.invitation_status === 'invalidEmail') {
status = 'invalidEmail'
} else if (person.invitation_status !== 'active') {
status = 'invitationExpired'
}
} else if (person.registered && !person.verified) {
......@@ -105,6 +106,12 @@ const Status = ({ person, role }: StatusProps) => {
status = 'active'
}
}
return [status, days]
}
const Status = ({ person, role }: StatusProps) => {
const { t } = useTranslation('common')
const [status, days] = calculateStatus(person, role)
switch (status) {
case 'active':
......@@ -143,6 +150,12 @@ const Status = ({ person, role }: StatusProps) => {
<Trans t={t} i18nKey="statusText.expiring" count={days} />
</TableCell>
)
case 'invalidEmail':
return (
<TableCell sx={{ color: 'error.main' }} align="left">
<Trans t={t} i18nKey="statusText.invalidEmail" count={days} />
</TableCell>
)
default:
return (
<TableCell sx={{ color: 'error.main' }} align="left">
......
......@@ -172,6 +172,9 @@ export default function GuestInfo({
})
}
})
// Reload guests to update status on sponsor frontpage
// in case the email was invalid before update
reloadGuests()
}
const onSubmit = handleSubmit(submit)
......
# Generated by Django 4.0.2 on 2022-05-24 08:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('greg', '0022_alter_identity_type'),
]
operations = [
migrations.AddField(
model_name='identity',
name='invalid',
field=models.BooleanField(null=True),
),
]
......@@ -264,6 +264,21 @@ class Notification(BaseModel):
class Identity(BaseModel):
"""
Model used for storing identifiers of a person
A person must have at least one verified PASSPORT_NUMBER or
NORWEGIAN_NATIONAL_ID_NUMBER to be active.
If for some reason we find that a value is invalid, even if it has
been verified, it can be marked as invalid by setting the invalid
field to True.
MIGRATION_ID is used to identify persons imported from another
source system.
PRIVATE_EMAIL is used when sending invitation emails.
"""
class IdentityType(models.TextChoices):
FEIDE_ID = "feide_id"
FEIDE_EMAIL = "feide_email"
......@@ -294,6 +309,7 @@ class Identity(BaseModel):
blank=True,
)
verified_at = models.DateTimeField(null=True, blank=True)
invalid = models.BooleanField(null=True)
def __str__(self) -> str:
return "{}(id={!r}, type={!r}, value={!r})".format(
......
......@@ -9,10 +9,10 @@ from django.conf import settings
from greg.importers.orgreg import OrgregImporter
from greg.models import Role, Sponsor
from gregui import mailutils
from gregui.mailutils.role_ending import RolesEnding
def notify_sponsors_roles_ending() -> None:
def notify_sponsors_roles_ending() -> list[str]:
"""
This task notifies sponsors of roles (that are accessible to them)
that are about to expire.
......@@ -51,11 +51,16 @@ def notify_sponsors_roles_ending() -> None:
sp2roles[sp].append(role)
# Send emails to sponsors
remindermailer = RolesEnding()
task_ids = []
for sp, roles in sp2roles.items():
mailutils.send_role_ending_mail(
mail_to=sponsors.get(id=sp).work_email, # type: ignore
num_roles=len(roles),
task_ids.append(
remindermailer.queue_mail(
mail_to=sponsors.get(id=sp).work_email, # type: ignore
num_roles=len(roles),
)
)
return task_ids
def import_from_orgreg():
......
......@@ -31,19 +31,8 @@ def test_notify_sponsors_roles_ending(
role_end_reminder_template, role_person_foo2, sponsor_org_unit
):
mail.outbox = []
notify_sponsors_roles_ending()
assert len(mail.outbox) == 1
assert mail.outbox[0].to == ["sponsor_guy@example.com"]
assert (
mail.outbox[0].body
== """Dette er en automatisk generert melding fra gjesteregistreringstjenesten.
Du administrerer 1 gjesteroller som snart utløper.
Dersom det er ønskelig å forlenge rollene kan dette gjøres i webgrensesnittet.
This message has been automatically generated by the guest registration system.
You can maintan 1 guest roles about to expire.
To extend the guest role please use the web interface."""
)
task_ids = notify_sponsors_roles_ending()
assert len(task_ids) == 1
@pytest.fixture
......
......@@ -40,6 +40,7 @@ def create_identity_or_update(
)
else:
existing_identity.value = value
existing_identity.invalid = None
existing_identity.save()
......@@ -257,6 +258,9 @@ class GuestSerializer(serializers.ModelSerializer):
return obj.feide_id and obj.feide_id.value
def get_invitation_status(self, obj):
if obj.private_email and obj.private_email.invalid:
return "invalidEmail"
invitation_links = InvitationLink.objects.filter(
invitation__role__person__id=obj.id
)
......
File moved
......@@ -23,7 +23,8 @@ from greg.permissions import IsSponsor
from greg.utils import get_default_invitation_expire_date_from_now
from gregui.api.serializers.guest import GuestRegisterSerializer
from gregui.api.serializers.invitation import InviteGuestSerializer
from gregui.mailutils import send_confirmation_mail_from_link, send_invite_mail
from gregui.mailutils.confirm_guest import ConfirmGuest
from gregui.mailutils.invite_guest import InviteGuest
from gregui.models import GregUserProfile
logger = structlog.getLogger(__name__)
......@@ -77,20 +78,8 @@ class InvitationView(CreateAPIView, DestroyAPIView):
invitation__role__person_id=person.id,
invitation__role__sponsor_id=sponsor_user.sponsor_id,
):
try:
send_invite_mail(invitationlink)
except Exception:
logger.exception("send_invite_mail_failed", exc_info=True)
# The invite has been created, so send 201, but include some data saying the
# e-mail was not sent properly
return Response(
status=status.HTTP_201_CREATED,
data={
"code": "invite_email_failed",
"message": "Failed to send invite e-mail",
},
)
invitemailer = InviteGuest()
invitemailer.queue_mail(invitationlink)
# Empty json-body to avoid client complaining about non-json data in response
return Response(status=status.HTTP_201_CREATED, data={})
......@@ -293,19 +282,19 @@ class InvitedGuestView(GenericAPIView):
if request.user.is_anonymous:
# If the user is not logged in then tell the client to take him through the manual registration process
return SessionType.INVITE
elif person.fnr and person.fnr.source == "idporten":
if person.fnr and person.fnr.source == "idporten":
# If the user has logged in through ID-porten the national ID number should have been
# added to the person at this stage
return SessionType.ID_PORTEN
elif person.feide_id:
if person.feide_id:
# User is logged in and has a Feide ID attached to him, assume information about him has come from Feide
return SessionType.FEIDE
else:
# Not expected, default to invite
logger.warning(
"unexpected_state_when_determining_session_type", person_id=person.id
)
return SessionType.INVITE
# Not expected, default to invite
logger.warning(
"unexpected_state_when_determining_session_type", person_id=person.id
)
return SessionType.INVITE
def post(self, request, *args, **kwargs):
"""
......@@ -353,7 +342,8 @@ class InvitedGuestView(GenericAPIView):
invite_link.save()
# Send an email to the sponsor
send_confirmation_mail_from_link(invite_link)
confirmmailer = ConfirmGuest()
confirmmailer.send_confirmation_mail_from_link(invite_link)
return Response(status=status.HTTP_200_OK)
def _verify_only_allowed_updates_in_request(
......@@ -395,6 +385,7 @@ class InvitedGuestView(GenericAPIView):
"message": f"cannot_update_fields: {illegal_fields}",
},
)
return None
@staticmethod
def _illegal_updates(
......@@ -410,8 +401,9 @@ class InvitedGuestView(GenericAPIView):
continue
if (
changed_field == "first_name" or changed_field == "last_name"
) and session_type == SessionType.ID_PORTEN:
changed_field in ("first_name", "last_name")
and session_type == SessionType.ID_PORTEN
):
# From ID-porten only the national ID-number is given, so the name must be what the
# sponsor wrote, and can be changed
continue
......@@ -429,15 +421,14 @@ class InvitedGuestView(GenericAPIView):
if not attribute:
# No existing value for field, so allow change
continue
else:
# Quick fix to be able compare date with string
if changed_field == "date_of_birth":
if attribute.strftime("%Y-%m-%d") != person_data[changed_field]:
# There is an existing date value
illegal_field_updates.append(changed_field)
elif attribute != person_data[changed_field]:
# There is an existing value
# Quick fix to be able compare date with string
if changed_field == "date_of_birth":
if attribute.strftime("%Y-%m-%d") != person_data[changed_field]:
# There is an existing date value
illegal_field_updates.append(changed_field)
elif attribute != person_data[changed_field]:
# There is an existing value
illegal_field_updates.append(changed_field)
return illegal_field_updates
......@@ -477,7 +468,7 @@ class ResendInvitationView(UpdateModelMixin, APIView):
return Response(status=status.HTTP_400_BAD_REQUEST)
non_expired_links = invitation_links.filter(expire__gt=timezone.now())
invitemailer = InviteGuest()
if non_expired_links.count() > 0:
if non_expired_links.count() > 1:
# Do not expect this to happen
......@@ -485,12 +476,12 @@ class ResendInvitationView(UpdateModelMixin, APIView):
# Just resend all and do not create a new one
for link in non_expired_links:
send_invite_mail(link)
invitemailer.queue_mail(link)
else:
# All the invitation links have expired, create a new one
invitations_to_resend = set(
[invitation_link.invitation for invitation_link in invitation_links]
)
invitations_to_resend = {
invitation_link.invitation for invitation_link in invitation_links
}
if len(invitations_to_resend) > 1:
# Do not expected that a person has several open invitations, it could happen
......@@ -503,6 +494,6 @@ class ResendInvitationView(UpdateModelMixin, APIView):
invitation=invitation,
expire=get_default_invitation_expire_date_from_now(),
)
send_invite_mail(invitation_link)
invitemailer.queue_mail(invitation_link)
return Response(status=status.HTTP_200_OK)
......@@ -58,7 +58,7 @@ class PersonViewSet(mixins.RetrieveModelMixin, mixins.UpdateModelMixin, GenericV
if getattr(instance, "_prefetched_objects_cache", None):
# If 'prefetch_related' has been applied to a queryset, we need to
# forcibly invalidate the prefetch cache on the instance.
instance._prefetched_objects_cache = {}
instance._prefetched_objects_cache = {} # pylint: disable=protected-access
return Response(serializer.data)
......
......@@ -17,9 +17,9 @@ class RoleInfoViewSet(ModelViewSet):
permission_classes = [IsAuthenticated, IsSponsor]
serializer_class = RoleSerializerUi
def partial_update(self, request, pk):
def partial_update(self, request, *args, **kwargs):
try:
role = Role.objects.get(pk=pk)
role = Role.objects.get(pk=kwargs["pk"])
except Role.DoesNotExist:
return Response(status=status.HTTP_400_BAD_REQUEST)
sponsor = GregUserProfile.objects.get(user=self.request.user).sponsor
......@@ -34,7 +34,7 @@ class RoleInfoViewSet(ModelViewSet):
serializer.update(role, serializer.validated_data)
return Response(status=status.HTTP_200_OK)
def create(self, request):
def create(self, request, *args, **kwargs):
sponsor = GregUserProfile.objects.get(user=self.request.user).sponsor
with transaction.atomic():
serializer = self.serializer_class(
......
from rest_framework.authentication import BaseAuthentication, SessionAuthentication
from rest_framework.permissions import AllowAny, BasePermission
from rest_framework.authentication import SessionAuthentication
from rest_framework.permissions import AllowAny
from rest_framework.status import HTTP_403_FORBIDDEN
from rest_framework.views import APIView
from rest_framework.response import Response
......@@ -19,7 +19,7 @@ class UserInfoView(APIView):
authentication_classes = [SessionAuthentication]
permission_classes = [AllowAny]
def get(self, request, format=None):
def get(self, request, format=None): # pylint: disable=redefined-builtin
"""
Get info about the visiting user.
......
import logging
from typing import Optional, Union
from django.conf import settings
from django.template.context import Context
from django_q.tasks import async_task
from greg.models import InvitationLink
from gregui.models import EmailTemplate
logger = logging.getLogger(__name__)
def prepare_arguments(
template: EmailTemplate, context: dict[str, str], mail_to: str
) -> dict[str, Union[str, list[str]]]:
"""Combine input to a dict ready for use as arguments ti django's send_mail"""
return {
"subject": template.get_subject(context),
"message": template.get_body(context),
"from_email": template.from_email or None,
"recipient_list": [mail_to],
}
def make_registration_url(token: str) -> str:
return "{base}/invite#{token}".format(
base=settings.BASE_URL,
token=token,
)
def make_guest_profile_url(person_id: Union[str, int]) -> str:
return "{base}/sponsor/guest/{person_id}".format(
base=settings.BASE_URL,
person_id=person_id,
)
def registration_template(
institution: str, sponsor: str, mail_to: str, token: str
) -> dict[str, Union[str, list[str]]]:
"""
Prepare email for registration
Produces a complete set of arguments ready for use with django.core.mail.send_mail
when sending a registration email to the guest.
"""
template = EmailTemplate.objects.get(
template_key=EmailTemplate.EmailType.GUEST_REGISTRATION
)
registration_link = make_registration_url(token)
context = Context(
{
"institution": institution,
"sponsor": sponsor,
"registration_link": registration_link,
}
)
return prepare_arguments(template, context, mail_to)
def confirmation_template(
guest_name: str, mail_to: str, guest_person_id: Union[str, int]
) -> dict[str, Union[str, list[str]]]:
"""
Prepare email for confirmation
Produces a complete set of arguments ready for use with django.core.mail.send_mail
when sending a confirmation email to the sponsor.
"""
template = EmailTemplate.objects.get(
template_key=EmailTemplate.EmailType.SPONSOR_CONFIRMATION
)
confirmation_link = make_guest_profile_url(guest_person_id)
context = Context({"guest": guest_name, "confirmation_link": confirmation_link})
return prepare_arguments(template, context, mail_to)
def reminder_template(mail_to: str, num_roles: int) -> dict[str, Union[str, list[str]]]:
template = EmailTemplate.objects.get(
template_key=EmailTemplate.EmailType.ROLE_END_REMINDER
)
context = Context({"num_roles": num_roles})
return prepare_arguments(template, context, mail_to)
def send_registration_mail(mail_to: str, sponsor: str, token: str) -> str:
arguments = registration_template(settings.INSTANCE_NAME, sponsor, mail_to, token)
return async_task("django.core.mail.send_mail", **arguments)
def send_confirmation_mail(
mail_to: str, guest_name: str, guest_person_id: Union[int, str]
) -> str:
arguments = confirmation_template(
guest_name=guest_name, mail_to=mail_to, guest_person_id=guest_person_id
)
return async_task("django.core.mail.send_mail", **arguments)
def send_role_ending_mail(mail_to: str, num_roles: int) -> str:
arguments = reminder_template(mail_to, num_roles)
return async_task("django.core.mail.send_mail", **arguments)
def send_invite_mail(link: InvitationLink) -> Optional[str]:
email_address = link.invitation.role.person.private_email
if not email_address:
logger.warning(
"No e-mail address found for invitation link with ID: {%s}", link.id
)
return None
sponsor = link.invitation.role.sponsor
return send_registration_mail(
mail_to=email_address.value,
sponsor=f"{sponsor.first_name} {sponsor.last_name}",
token=link.uuid,
)
def send_confirmation_mail_from_link(link: InvitationLink) -> Optional[str]:
email_address = link.invitation.role.sponsor.work_email
guest = link.invitation.role.person
guest_name = f"{guest.first_name} {guest.last_name}"
return send_confirmation_mail(
mail_to=email_address, guest_name=guest_name, guest_person_id=guest.id
)
from typing import Optional, Union
from django.conf import settings
from django.template.context import Context
from django_q.tasks import async_task
from greg.models import InvitationLink
from gregui.mailutils.protocol import prepare_arguments
from gregui.models import EmailTemplate
class ConfirmGuest:
def __init__(self):
pass
def _make_guest_profile_url(self, person_id: Union[str, int]) -> str:
return "{base}/sponsor/guest/{person_id}".format(
base=settings.BASE_URL,
person_id=person_id,
)
def get_template(self):
return EmailTemplate.objects.get(
template_key=EmailTemplate.EmailType.SPONSOR_CONFIRMATION
)
def queue_mail(
self, mail_to: str, guest_name: str, guest_person_id: Union[int, str]
) -> str:
arguments = prepare_arguments(
template=self.get_template(),
context=Context(
{
"guest": guest_name,
"confirmation_link": self._make_guest_profile_url(guest_person_id),
}
),
mail_to=mail_to,
)
return async_task("django.core.mail.send_mail", **arguments)
def send_confirmation_mail_from_link(self, link: InvitationLink) -> Optional[str]:
guest = link.invitation.role.person
return self.queue_mail(
mail_to=link.invitation.role.sponsor.work_email,
guest_name=f"{guest.first_name} {guest.last_name}",
guest_person_id=guest.id,
)
from smtplib import SMTPRecipientsRefused
from typing import Optional
import structlog
from django.conf import settings
from django.core import mail
from django.template.context import Context
from django_q.tasks import async_task
from greg.models import InvitationLink, Sponsor
from gregui.models import EmailTemplate
logger = structlog.getLogger(__name__)
class InviteGuest:
def make_registration_url(self, token: str) -> str:
return "{base}/invite#{token}".format(
base=settings.BASE_URL,
token=token,
)
def get_template(self):
return EmailTemplate.objects.get(
template_key=EmailTemplate.EmailType.GUEST_REGISTRATION
)
def get_notice_template(self):
return EmailTemplate.objects.get(
template_key=EmailTemplate.EmailType.INVALID_EMAIL
)
def send_bad_email_notice(
self, guest_name: str, guest_mail: str, sponsor_mail: str
):
template = self.get_notice_template()
context = Context(
{
"guest_name": guest_name,
"guest_email": guest_mail,
}
)
mail.send_mail(
subject=template.get_subject(context),
message=template.get_body(context),
from_email=template.from_email or None,
recipient_list=[sponsor_mail],
)
def queue_mail(self, link: InvitationLink) -> Optional[str]:
guest = link.invitation.role.person
email_address = guest.private_email
if not email_address:
logger.warning(
"No e-mail address found for invitation link with ID: {%s}", link.id
)
return None
sponsor = link.invitation.role.sponsor
return async_task(
"gregui.mailutils.invite_guest.try_send_registration_mail",
**{
"guest_name": f"{guest.first_name} {guest.last_name}",
"guest_mail": email_address.value,
"sponsor_id": sponsor.id,
"sponsor_name": f"{sponsor.first_name} {sponsor.last_name}",
"token": link.uuid,
},
)
def try_send_registration_mail(
guest_name: str,
guest_mail: str,
sponsor_id: int,
sponsor_name: str,
token: str,
):
ig = InviteGuest()
template = ig.get_template()
context = Context(
{
"institution": settings.INSTANCE_NAME,
"sponsor": sponsor_name,
"registration_link": ig.make_registration_url(token),
}
)
try:
mail.send_mail(
subject=template.get_subject(context),
message=template.get_body(context),
from_email=template.from_email or None,
recipient_list=[guest_mail],
)
except SMTPRecipientsRefused:
sp = Sponsor.objects.get(pk=sponsor_id)
# TODO: Mark the invite in the frontend as bad
if sp.work_email:
ig.send_bad_email_notice(
guest_name=guest_name,
guest_mail=guest_mail,
sponsor_mail=sp.work_email,
)
from typing import Protocol, Union
from gregui.models import EmailTemplate
def prepare_arguments(
template: EmailTemplate, context: dict[str, str], mail_to: str
) -> dict[str, Union[str, list[str]]]:
"""Combine input to a dict ready for use as arguments to django's send_mail"""
return {
"subject": template.get_subject(context),
"message": template.get_body(context),
"from_email": template.from_email or None,
"recipient_list": [mail_to],
}
class EmailSender(Protocol):
def queue_mail(self, *args):
pass
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment