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

Feature/google shared drives #561

Merged
merged 46 commits into from
May 15, 2024
Merged
Changes from 1 commit
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
20bfe16
initial shared drive record models
mikeseibel Feb 26, 2024
005774b
mock shared drive data and initial templating
mikeseibel Feb 29, 2024
fc173ff
shared drive api tests
mikeseibel Feb 29, 2024
d002f3e
shared drive loader upsert
mikeseibel Feb 29, 2024
314ab80
Merge branch 'develop' into feature/google-shared-drives
mikeseibel Mar 19, 2024
da31dd4
Merge branch 'develop' into feature/google-shared-drives
mikeseibel Mar 20, 2024
485b97d
itbill restclient
mikeseibel Apr 9, 2024
72f3bfe
shared drive record to unique drive
mikeseibel Apr 9, 2024
cc60f54
random key_remote
mikeseibel Apr 9, 2024
4e25613
layout interactions align with wireframes
mikeseibel Apr 27, 2024
e56ff52
Merge pull request #545 from uw-it-aca/work/google-shared-drives-mikes
mikeseibel Apr 29, 2024
2a61d0a
Merge branch 'develop' into feature/google-shared-drives
mikeseibel Apr 30, 2024
c0fdf09
mock dates
mikeseibel Apr 30, 2024
23d6bdb
itbill payload integration
mikeseibel May 4, 2024
8e6bad7
Merge pull request #547 from uw-it-aca/work/google-shared-drives-mikes
mikeseibel May 4, 2024
ce4d1b8
subscription relationships
mikeseibel May 4, 2024
74c8d5e
Merge pull request #548 from uw-it-aca/work/google-shared-drives-mikes
mikeseibel May 4, 2024
42daf34
shared drive email warnings and tests
mikeseibel May 8, 2024
d4d5a11
shared drive notification templating
mikeseibel May 8, 2024
e9dbd64
Merge pull request #549 from uw-it-aca/work/google-shared-drives-mikes
mikeseibel May 8, 2024
e6c7768
shared drive warning email tweaking
mikeseibel May 8, 2024
1923ad2
Merge pull request #550 from uw-it-aca/work/google-shared-drives-mikes
mikeseibel May 8, 2024
8649e0c
misnamed metho
mikeseibel May 8, 2024
8b1dc57
Merge pull request #551 from uw-it-aca/work/google-shared-drives-mikes
mikeseibel May 8, 2024
1978096
consolidate notification logic, add access lifecycle warnings
mikeseibel May 9, 2024
3182223
Merge pull request #552 from uw-it-aca/work/google-shared-drives-mikes
mikeseibel May 9, 2024
9de0681
setting for conditional app exposure, prod value to prevent unintenti…
mikeseibel May 10, 2024
2acac2d
Merge pull request #553 from uw-it-aca/work/google-shared-drives-mikes
mikeseibel May 10, 2024
a88d308
support notification display bug
mikeseibel May 10, 2024
6652531
Merge pull request #554 from uw-it-aca/work/google-shared-drives-mikes
mikeseibel May 10, 2024
8f5acce
improve notification testing
mikeseibel May 10, 2024
8c4bb16
Merge pull request #555 from uw-it-aca/work/google-shared-drives-mikes
mikeseibel May 10, 2024
e459a50
unused constants
mikeseibel May 10, 2024
d8d8e1e
Merge pull request #556 from uw-it-aca/work/google-shared-drives-mikes
mikeseibel May 10, 2024
3c0a538
access notice tweaks
mikeseibel May 10, 2024
8212fa3
Merge pull request #557 from uw-it-aca/work/google-shared-drives-mikes
mikeseibel May 10, 2024
0c028c7
tabs as links
mikeseibel May 14, 2024
ca971ba
Merge pull request #559 from uw-it-aca/work/google-shared-drives-mikes
mikeseibel May 14, 2024
3be16be
over quota unsubsidized warning, handlebars logic cleanup
mikeseibel May 15, 2024
e566292
a11y updates
wmwash May 15, 2024
d5673e3
feature branch merge
mikeseibel May 15, 2024
98554c7
Merge pull request #560 from uw-it-aca/work/google-shared-drives-mikes
mikeseibel May 15, 2024
25b8a63
pycodestyle
mikeseibel May 15, 2024
83aa449
Merge pull request #562 from uw-it-aca/work/google-shared-drives-mikes
mikeseibel May 15, 2024
f70353d
jshint
mikeseibel May 15, 2024
d8ea610
Merge pull request #563 from uw-it-aca/work/google-shared-drives-mikes
mikeseibel May 15, 2024
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
Prev Previous commit
Next Next commit
shared drive email warnings and tests
mikeseibel committed May 8, 2024

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
commit 42daf3497c056be03d8a443e148d77019fe2b3f7
7 changes: 7 additions & 0 deletions endorsement/dao/shared_drive.py
Original file line number Diff line number Diff line change
@@ -15,6 +15,13 @@
r'^(?P<netid>[^@]+)@(uw|(u\.)?washington)\.edu$', re.I)


def shared_drive_lifecycle_expired(drive_record):
"""
Set lifecycle to expired for shared drive
"""
logger.info(f"Shared drive {drive_record} lifecycle expired")


def load_shared_drives_from_csv(file_path):
"""
populate shared drive models
22 changes: 11 additions & 11 deletions endorsement/fixtures/test_data/member.json
Original file line number Diff line number Diff line change
@@ -2,54 +2,54 @@
"model": "endorsement.member",
"pk": 1,
"fields": {
"name": "javerage"}},
"netid": "javerage"}},
{
"model": "endorsement.member",
"pk": 2,
"fields": {
"name": "endorsee7"}},
"netid": "endorsee7"}},
{
"model": "endorsement.member",
"pk": 3,
"fields": {
"name": "[email protected]"}},
"netid": "[email protected]"}},
{
"model": "endorsement.member",
"pk": 4,
"fields": {
"name": "endorsee6"}},
"netid": "endorsee6"}},
{
"model": "endorsement.member",
"pk": 5,
"fields": {
"name": "jstaff"}},
"netid": "jstaff"}},
{
"model": "endorsement.member",
"pk": 6,
"fields": {
"name": "jinter"}},
"netid": "jinter"}},
{
"model": "endorsement.member",
"pk": 7,
"fields": {
"name": "endorsee5"}},
"netid": "endorsee5"}},
{
"model": "endorsement.member",
"pk": 8,
"fields": {
"name": "endorsee4"}},
"netid": "endorsee4"}},
{
"model": "endorsement.member",
"pk": 9,
"fields": {
"name": "endorsee3"}},
"netid": "endorsee3"}},
{
"model": "endorsement.member",
"pk": 10,
"fields": {
"name": "endorsee2"}},
"netid": "endorsee2"}},
{
"model": "endorsement.member",
"pk": 11,
"fields": {
"name": "endorsee1"}}]
"netid": "endorsee1"}}]
2 changes: 1 addition & 1 deletion endorsement/management/commands/expire_endorsees.py
Original file line number Diff line number Diff line change
@@ -4,7 +4,7 @@
from django.core.management.base import BaseCommand
from django.core.mail import mail_managers
from django.template import loader
from endorsement.policy import endorsements_to_expire
from endorsement.policy.endorsement import endorsements_to_expire
from endorsement.dao.endorse import clear_endorsement
import logging
import urllib3
23 changes: 23 additions & 0 deletions endorsement/migrations/0028_auto_20240506_1259.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 3.2.25 on 2024-05-06 19:59

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('endorsement', '0027_auto_20240503_1436'),
]

operations = [
migrations.RenameField(
model_name='member',
old_name='name',
new_name='netid',
),
migrations.AlterField(
model_name='shareddrive',
name='members',
field=models.ManyToManyField(blank=True, to='endorsement.SharedDriveMember'),
),
]
20 changes: 13 additions & 7 deletions endorsement/models/shared_drive.py
Original file line number Diff line number Diff line change
@@ -11,19 +11,19 @@


class MemberManager(models.Manager):
def get_member(self, name):
member, _ = self.get_or_create(name=name)
def get_member(self, netid):
member, _ = self.get_or_create(netid=netid)
return member


class Member(ExportModelOperationsMixin('member'), models.Model):
"""
Member represents user associated with a shared drive
"""
name = models.CharField(max_length=128)
netid = models.CharField(max_length=128)

def json_data(self):
return self.name
return self.netid

objects = MemberManager()

@@ -52,7 +52,7 @@ class SharedDriveMember(

def json_data(self):
return {
"name": self.member.json_data(),
"netid": self.member.json_data(),
"role": self.role.json_data()
}

@@ -84,6 +84,9 @@ def json_data(self):
"is_subsidized": self.is_subsidized
}

def __str__(self):
return json.dumps(self.json_data())


class SharedDrive(ExportModelOperationsMixin('shared_drive'), models.Model):
"""
@@ -94,9 +97,12 @@ class SharedDrive(ExportModelOperationsMixin('shared_drive'), models.Model):
drive_name = models.CharField(max_length=128)
drive_quota = models.ForeignKey(SharedDriveQuota, on_delete=models.PROTECT)
drive_usage = models.IntegerField(null=True)
members = models.ManyToManyField(SharedDriveMember)
members = models.ManyToManyField(SharedDriveMember, blank=True)
query_date = models.DateTimeField(null=True)

def get_members(self):
return [m.member.netid for m in self.members.all()]

def json_data(self):
return {
"drive_id": self.drive_id,
@@ -142,7 +148,7 @@ def __str__(self):
class SharedDriveRecordManager(models.Manager):
def get_member_drives(self, member_netid, drive_id=None):
parms = {
"shared_drive__members__member__name": member_netid,
"shared_drive__members__member__netid": member_netid,
"is_deleted__isnull": True}

if drive_id:
1 change: 1 addition & 0 deletions endorsement/notifications/access.py
Original file line number Diff line number Diff line change
@@ -4,6 +4,7 @@
from endorsement.models import AccessRecord
from endorsement.dao.notification import send_notification
from endorsement.dao.accessors import get_accessor_email
from endorsement.exceptions import EmailFailureException
from django.template import loader, Template, Context
import logging

3 changes: 2 additions & 1 deletion endorsement/notifications/endorsement.py
Original file line number Diff line number Diff line change
@@ -8,9 +8,10 @@
from endorsement.dao.user import get_endorsee_email_model
from endorsement.dao import display_datetime
from endorsement.dao.endorse import clear_endorsement
from endorsement.policy import endorsements_to_warn
from endorsement.policy.endorsement import endorsements_to_warn
from endorsement.util.email import uw_email_address
from endorsement.util.string import listed_list
from endorsement.exceptions import EmailFailureException
from django.template import loader, Template, Context
from django.utils import timezone
import re
70 changes: 70 additions & 0 deletions endorsement/notifications/shared_drive.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Copyright 2024 UW-IT, University of Washington
# SPDX-License-Identifier: Apache-2.0

from endorsement.dao.notification import send_notification
from endorsement.models import SharedDriveRecord
from endorsement.dao.user import get_endorsee_email_model
from endorsement.dao import display_datetime
from endorsement.policy.shared_drive import (
shared_drives_to_warn, expiration_warning,
DEFAULT_SHARED_DRIVE_LIFETIME)
from endorsement.util.email import uw_email_address
from endorsement.exceptions import EmailFailureException
from django.template import loader, Template, Context
from django.utils import timezone
import re
import logging


logger = logging.getLogger(__name__)


def _email_template(template_name):
return "email/shared_drive/{}".format(template_name)


def _create_expire_notice_message(notice_level, lifetime, drive):
context = {
'drive': drive,
'lifetime': lifetime,
'notice_time': expiration_warning(notice_level)
}

if notice_level < 4:
subject = ("Action Required: Shared Drive "
"service will expire soon")
text_template = _email_template("notice_warning.txt")
html_template = _email_template("notice_warning.html")
else:
subject = "Action Required: Shared Drive services have expired"
text_template = _email_template("notice_warning_final.txt")
html_template = _email_template("notice_warning_final.html")

return (subject,
loader.render_to_string(text_template, context),
loader.render_to_string(html_template, context))


def warn_members(notice_level):
drives = shared_drives_to_warn(notice_level)
lifetime = DEFAULT_SHARED_DRIVE_LIFETIME

for drive in drives:
try:
members = [uw_email_address(netid) for (
netid) in drive.shared_drive.get_members()]
(subject,
text_body,
html_body) = _create_expire_notice_message(
notice_level, lifetime, drive)
send_notification(
members, subject, text_body, html_body,
"Shared Drive Warning")

sent_date = {
'datetime_notice_{}_emailed'.format(
notice_level): timezone.now()
}
drives.update(**sent_date)
except EmailFailureException as ex:
pass
125 changes: 125 additions & 0 deletions endorsement/policy/access.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# Copyright 2024 UW-IT, University of Washington
# SPDX-License-Identifier: Apache-2.0

"""
Office Mailbox Access lifecycle policy
Basic notions:
* Intial and subsequent warnings are sent prior to expiration.
* The expiration clock starts on the date of the first warning notice.
"""
from django.utils import timezone
from django.db.models import Q
from endorsement.models import AccessRecord
from endorsement.dao.access import revoke_access
from datetime import timedelta


# Default lifecycle day counts
DEFAULT_ACCESS_LIFETIME = 365
DEFAULT_ACCESS_GRACETIME = 90
PRIOR_DAYS_NOTICE_WARNING_1 = 90
PRIOR_DAYS_NOTICE_WARNING_2 = 30
PRIOR_DAYS_NOTICE_WARNING_3 = 7
PRIOR_DAYS_NOTICE_WARNING_4 = 0


def accessees_to_warn(level):
"""
"""
return _accessees_to_warn(timezone.now(), level)


def _accessees_to_warn(now, level):
"""
Gather records to receive expiration warning messages where
level is the index of the warning message: first, second and so forth
The expiration clock starts on the date of the first warning notice
"""
if level < 1 or level > 4:
raise Exception('bad warning level {}'.format(level))

q = Q()

# select on appropriate time span for warning notice index (level)
days_prior = _expiration_warning(level)
if days_prior is None:
return

if level == 1:
granted = now - timedelta(
days=DEFAULT_ACCESS_LIFETIME - days_prior)
q = q | Q(datetime_granted__lt=granted,
datetime_notice_1_emailed__isnull=True,
is_deleted__isnull=True)
else:
prev_days_prior = _expiration_warning(level - 1)
prev_warning_date = now - timedelta(
days=prev_days_prior - days_prior)

if level == 2:
q = q | Q(datetime_notice_1_emailed__lt=prev_warning_date,
datetime_notice_2_emailed__isnull=True,
is_deleted__isnull=True)
elif level == 3:
q = q | Q(datetime_notice_2_emailed__lt=prev_warning_date,
datetime_notice_3_emailed__isnull=True,
is_deleted__isnull=True)
else:
q = q | Q(datetime_notice_3_emailed__lt=prev_warning_date,
datetime_notice_4_emailed__isnull=True,
is_deleted__isnull=True)

return AccessRecord.objects.filter(q)


def access_to_expire():
"""
Return query set of access records to expire
"""
return _access_to_expire(timezone.now())


def _access_to_expire(now):
"""
Return query set of accees to expire for each service
"""
q = Q()

expiration_date = now - timedelta(days=DEFAULT_ACCESS_GRACETIME)
q = q | Q(datetime_notice_4_emailed__lt=expiration_date,
datetime_notice_3_emailed__isnull=False,
datetime_notice_2_emailed__isnull=False,
datetime_notice_1_emailed__isnull=False,
is_deleted__isnull=True)

return AccessRecord.objects.filter(q)


def expire_office_access(gracetime, lifetime):
"""
"""
access = access_to_expire(gracetime, lifetime)
if len(access):
for a in acccess:
revoke_access(a)


def _expiration_warning(self, level=1):
"""
for the given warning message level, return days prior to
expiration that a warning should be sent.
level 1 is the first warning, level 2 the second and so on
to final warning at 0 days before expiration
"""
try:
return [
PRIOR_DAYS_NOTICE_WARNING_1,
PRIOR_DAYS_NOTICE_WARNING_2,
PRIOR_DAYS_NOTICE_WARNING_3,
PRIOR_DAYS_NOTICE_WARNING_4
][level - 1]
except IndexError:
return None
10 changes: 5 additions & 5 deletions endorsement/policy.py → endorsement/policy/endorsement.py
Original file line number Diff line number Diff line change
@@ -45,7 +45,7 @@ def _endorsements_to_warn(now, level):
if level == 1:
endorsed = now - timedelta(
days=service.endorsement_lifetime - days_prior)
q = q | Q(datetime_endorsed__lt=endorsed,
q = q | Q(datetime_endorsed__lte=endorsed,
datetime_notice_1_emailed__isnull=True,
category_code=service.category_code,
is_deleted__isnull=True)
@@ -55,17 +55,17 @@ def _endorsements_to_warn(now, level):
days=prev_days_prior - days_prior)

if level == 2:
q = q | Q(datetime_notice_1_emailed__lt=prev_warning_date,
q = q | Q(datetime_notice_1_emailed__lte=prev_warning_date,
datetime_notice_2_emailed__isnull=True,
category_code=service.category_code,
is_deleted__isnull=True)
elif level == 3:
q = q | Q(datetime_notice_2_emailed__lt=prev_warning_date,
q = q | Q(datetime_notice_2_emailed__lte=prev_warning_date,
datetime_notice_3_emailed__isnull=True,
category_code=service.category_code,
is_deleted__isnull=True)
else:
q = q | Q(datetime_notice_3_emailed__lt=prev_warning_date,
q = q | Q(datetime_notice_3_emailed__lte=prev_warning_date,
datetime_notice_4_emailed__isnull=True,
category_code=service.category_code,
is_deleted__isnull=True)
@@ -88,7 +88,7 @@ def _endorsements_to_expire(now):

for service in endorsement_services():
expiration_date = now - timedelta(days=service.endorsement_graceperiod)
q = q | Q(datetime_notice_4_emailed__lt=expiration_date,
q = q | Q(datetime_notice_4_emailed__lte=expiration_date,
datetime_notice_3_emailed__isnull=False,
datetime_notice_2_emailed__isnull=False,
datetime_notice_1_emailed__isnull=False,
125 changes: 125 additions & 0 deletions endorsement/policy/shared_drive.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# Copyright 2024 UW-IT, University of Washington
# SPDX-License-Identifier: Apache-2.0

"""
Shared Drive lifecycle policy
Basic notions:
* Intial and subsequent warnings are sent prior to expiration.
* The expiration clock starts on the date of the first warning notice.
"""
from django.utils import timezone
from django.db.models import Q
from endorsement.models import SharedDriveRecord
from endorsement.dao.shared_drive import shared_drive_lifecycle_expired
from datetime import timedelta


# Default lifecycle day counts
DEFAULT_SHARED_DRIVE_LIFETIME = 365
DEFAULT_SHARED_DRIVE_GRACETIME = 0
PRIOR_DAYS_NOTICE_WARNING_1 = 90
PRIOR_DAYS_NOTICE_WARNING_2 = 30
PRIOR_DAYS_NOTICE_WARNING_3 = 7
PRIOR_DAYS_NOTICE_WARNING_4 = 0


def shared_drives_to_warn(level):
"""
"""
return _shared_drives_to_warn(timezone.now(), level)


def _shared_drives_to_warn(now, level):
"""
Gather records to receive expiration warning messages where
level is the index of the warning message: first, second and so forth
The expiration clock starts on the date of the first warning notice
"""
if level < 1 or level > 4:
raise Exception('bad warning level {}'.format(level))

q = Q()

# select on appropriate time span for warning notice index (level)
days_prior = expiration_warning(level)
if days_prior is None:
return SharedDriveRecord.objects.none()

if level == 1:
accepted = now - timedelta(
days=DEFAULT_SHARED_DRIVE_LIFETIME - days_prior)
q = q | Q(datetime_accepted__lte=accepted,
datetime_notice_1_emailed__isnull=True,
is_deleted__isnull=True)
else:
prev_days_prior = expiration_warning(level - 1)
prev_warning_date = now - timedelta(
days=prev_days_prior - days_prior)

if level == 2:
q = q | Q(datetime_notice_1_emailed__lte=prev_warning_date,
datetime_notice_2_emailed__isnull=True,
is_deleted__isnull=True)
elif level == 3:
q = q | Q(datetime_notice_2_emailed__lte=prev_warning_date,
datetime_notice_3_emailed__isnull=True,
is_deleted__isnull=True)
else:
q = q | Q(datetime_notice_3_emailed__lte=prev_warning_date,
datetime_notice_4_emailed__isnull=True,
is_deleted__isnull=True)

return SharedDriveRecord.objects.filter(q)


def shared_drives_to_expire():
"""
Return query set of shared drive records to expire
"""
return _shared_drives_to_expire(timezone.now())


def _shared_drives_to_expire(now):
"""
Return query set of shared drives to expire for each service
"""
q = Q()

expiration_date = now - timedelta(days=DEFAULT_SHARED_DRIVE_GRACETIME)
q = q | Q(datetime_notice_4_emailed__lte=expiration_date,
datetime_notice_3_emailed__isnull=False,
datetime_notice_2_emailed__isnull=False,
datetime_notice_1_emailed__isnull=False,
is_deleted__isnull=True)

return SharedDriveRecord.objects.filter(q)


def expire_shared_drives(gracetime, lifetime):
"""
"""
drives = shared_drives_to_expire(gracetime, lifetime)
if len(drives):
for drive in drives:
shared_drive_lifecycle_expired(drive)


def expiration_warning(level=1):
"""
for the given warning message level, return days prior to
expiration that a warning should be sent.
level 1 is the first warning, level 2 the second and so on
to final warning at 0 days before expiration
"""
try:
return [
PRIOR_DAYS_NOTICE_WARNING_1,
PRIOR_DAYS_NOTICE_WARNING_2,
PRIOR_DAYS_NOTICE_WARNING_3,
PRIOR_DAYS_NOTICE_WARNING_4
][level - 1]
except IndexError:
return None
21 changes: 21 additions & 0 deletions endorsement/templates/email/shared_drive/notice_warning.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
Hello,
<p>
You are receiving this email because you are identified as a Manager of the
Google Shared Drive "{{drive.shared_drive.drive_name}}".
</p>
<p>
To maintain service, Google Shared Drive use must be actively
acknowledged every {{lifetime}} days. The drive "{{drive.shared_drive.drive_name}}"
is within {{notice_time}} days of its required renewal.
</p>
<p>
To log in to the Provisioning Request Tool (PRT) and renew the expiring shared drive visit:
<p>
<p>
<a href="https://itconnect.uw.edu/connect/productivity-platforms/provisioning-request-tool/#prt" target="_blank">Click here to log in to the Provisioning Request Tool (PRT) to renew expiring services.</a>
</p>
<p>
If you have any questions, please contact <a href="mailto:help@uw.edu" target="_blank">help@uw.edu</a> or 206-221-5000.
</p>
Thank you,<br />
UW-IT<br />
17 changes: 17 additions & 0 deletions endorsement/templates/email/shared_drive/notice_warning.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
Hello,

You are receiving this email because you are identified as a Manager of the
Google Shared Drive "{{drive.shared_drive.drive_name}}".

To maintain service, Google Shared Drive use must be actively
acknowledged every {{lifetime}} days. The drive "{{drive.shared_drive.drive_name}}"
is within {{notice_time}} days of its required renewal.

To log in to the Provisioning Request Tool (PRT) and renew the expiring shared drive visit:

https://itconnect.uw.edu/connect/productivity-platforms/provisioning-request-tool/#prt

If you have any questions, please contact help@uw.edu or 206-221-5000.

Thank you,
UW-IT
18 changes: 18 additions & 0 deletions endorsement/templates/email/shared_drive/notice_warning_final.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
Hello,
<p>
You are receiving this email because you are identified as a Manager of the
Google Shared Drive "{{drive.shared_drive.drive_name}}".
</p>
<p>
To maintain service, Google Shared Drive use must be actively acknowledged
every {{lifetime}} days. The drive "{{drive.shared_drive.drive_name}}"
has expired and will be marked for deletion soon.
</p>
<p>
<a href="https://itconnect.uw.edu/connect/productivity-platforms/provisioning-request-tool/#prt" target="_blank">Click here to log in to the Provisioning Request Tool (PRT) to renew expiring services.</a>
</p>
<p>
If you have any questions, please contact <a href="mailto:help@uw.edu" target="_blank">help@uw.edu</a> or 206-221-5000.
</p>
Thank you,<br />
UW-IT<br />
18 changes: 18 additions & 0 deletions endorsement/templates/email/shared_drive/notice_warning_final.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
Hello,

You are receiving this email because you are identified as a Manager of the
Google Shared Drive "{{drive.shared_drive.drive_name}}".

To maintain service, Google Shared Drive use must be actively acknowledged
every {{lifetime}} days. The drive "{{drive.shared_drive.drive_name}}"
has expired and will be marked for deletion soon.

To log in to the Provisioning Request Tool (PRT) and renew expiring services visit:

https://itconnect.uw.edu/connect/productivity-platforms/provisioning-request-tool/#prt

If you have any questions, please contact help@uw.edu or 206-221-5000.

Thank you,
UW-IT Help Center
help@uw.edu
6 changes: 3 additions & 3 deletions endorsement/templates/handlebars/tab/drives/google.html
Original file line number Diff line number Diff line change
@@ -76,13 +76,13 @@
<ul class="comma-list">
{{#gt drive.members.length 5}}
{{#slice drive.members 0 4}}
{{name}},
{{netid}},
{{/slice}}
<span class="prt-data-popover manager-list" aria-hidden="true" tabindex="0" data-placement="right" data-html="true" title data-content="<div class='manager-list'>{{#each drive.members}}{{name}}<br />{{/each}}</div>" data-original-title="All Eligibile Managers">
<span class="prt-data-popover manager-list" aria-hidden="true" tabindex="0" data-placement="right" data-html="true" title data-content="<div class='manager-list'>{{#each drive.members}}{{netid}}<br />{{/each}}</div>" data-original-title="All Eligibile Managers">
view&nbsp;all</span>
{{else}}
{{#each drive.members}}
<li>{{name}}</li>
<li>{{netid}}</li>
{{/each}}
{{/gt}}
</ul>
38 changes: 37 additions & 1 deletion endorsement/templates/support/notifications.html
Original file line number Diff line number Diff line change
@@ -27,7 +27,8 @@ <h2>Lifecycle Notifications</h2>

<div class="tabs">
<ul class="tabs-list">
<li class="active" data-tab="service"><span>Services</span></li>
<li class="active" data-tab="service"><span>Services for NetIDs</span></li>
<li data-tab="shared_drive"><span>Google Shared Drives</span></li>
<li data-tab="access"><span>Elevated Access</span></li>
</ul>
<div id="service" class="tab active">
@@ -96,6 +97,41 @@ <h4>Provisioned Services</h4>
</div> -->
</form>
</div>
<div id="shared_drive" class="tab active">
<p>
Choose a notification type to see the email contents that will be sent. Notification email is sent as multipart messages containing a text and html representation.
</p>

<form>
{% csrf_token %}
<div class="form-group">
<h4>Notification Message Type</h4>
<div class="form-group">
<select class="form-control" id="notification">
<option value="">Select Notification Type...</option>
<option value="warning_1">Initial Renewal Notice</option>
<option value="warning_2">Penultimate Renewal Notice</option>
<option value="warning_3">Final Renewal Notice</option>
<option value="warning_4">Provision Expiration</option>
</select>
</div>
</div>

<div class="form-group info" id="info_warning_1">
First renewal notice sent to provisioner {{ warning_1 }} days prior to service expiration.
</div>
<div class="form-group info" id="info_warning_2">
Second renewal notice sent to provisioner {{ warning_2 }} days prior to service expiration.
</div>
<div class="form-group info" id="info_warning_3">
Third and final renewal notice sent to provisioner {{ warning_3 }} days before expiration.
</div>
<div class="form-group info" id="info_warning_4">
Notice that provisioned services have expired sent to the provisioner on expiration day.
</div>
</form>

</div>
<div id="access" class="tab">
<p>
Choose an access type to see the the email text sent to the new delegate. Notification email is sent as multipart messages containing a text and html representation.
52 changes: 52 additions & 0 deletions endorsement/test/notifications/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Copyright 2024 UW-IT, University of Washington
# SPDX-License-Identifier: Apache-2.0

from django.test import TestCase
from django.utils import timezone
from datetime import timedelta


class NotificationsTestCase(TestCase):
def days_ago(self, days):
return self.now - timedelta(days=days)

def notice_and_expire_test(self, expirer, warner, offset, expected):
test_date = self.now - timedelta(days=offset[1])
days = self.lifetime - offset[1]

models = expirer(test_date)
self.assertEqual(
models.count(), expected[0],
f"test expired at offset level {offset[0]} (day {days})")
models.update(datetime_expired=test_date, is_deleted=True)

for level in range(1, 5):
models = warner(test_date, level)
self.assertEqual(
models.count(), expected[level],
(f"level {level} test at offset "
f"{offset[0]} days ago (day {days})"))
models.update(**{f"datetime_notice_{level}_emailed": test_date})

def message_timing(self, warning_level, results):
offsets = [
('one', warning_level(1)),
('one + 1 day', warning_level(1) - 1),
('two - 1 day', warning_level(2) + 1),
('two', warning_level(2)),
('two + 1 day', warning_level(2) - 1),
('three - 1 day', warning_level(3) + 1),
('three', warning_level(3)),
('three + 1 day', warning_level(3) - 1),
('four - 1 day', warning_level(4) + 1),
('four', warning_level(4)),
('four + 1 day', warning_level(4) - 1),
('four + 2 days', warning_level(4) - 2),
('grace - 1 day', -(self.graceperiod - 1)),
('grace', -(self.graceperiod)),
('grace + 1 day', -(self.graceperiod + 1))]

self.assertEqual(len(offsets), len(results))

for i, offset in enumerate(offsets):
self.notice_and_expire(offset, results[i])
104 changes: 104 additions & 0 deletions endorsement/test/notifications/test_drive_expiration_warnings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Copyright 2024 UW-IT, University of Washington
# SPDX-License-Identifier: Apache-2.0

from django.test import TestCase
from django.core import mail
from django.db.models import F
from django.utils import timezone
from endorsement.test.notifications import NotificationsTestCase
from endorsement.models import SharedDriveRecord
from endorsement.policy.shared_drive import (
_shared_drives_to_warn, _shared_drives_to_expire, expiration_warning,
DEFAULT_SHARED_DRIVE_LIFETIME, DEFAULT_SHARED_DRIVE_GRACETIME)
from endorsement.notifications.shared_drive import warn_members
from datetime import timedelta


class TestSharedDriveExpirationNotices(NotificationsTestCase):
fixtures = [
'test_data/member.json',
'test_data/role.json',
'test_data/itbill_subscription.json',
'test_data/itbill_provision.json',
'test_data/itbill_quantity.json',
'test_data/shared_drive_member.json',
'test_data/shared_drive_quota.json',
'test_data/shared_drive.json',
'test_data/shared_drive_record.json'
]

def setUp(self):
self.now = timezone.now()
self.lifetime = DEFAULT_SHARED_DRIVE_LIFETIME
self.graceperiod = DEFAULT_SHARED_DRIVE_GRACETIME

# reset all mock dates
SharedDriveRecord.objects.all().update(datetime_accepted=self.now)

# accepted date long ago
drive = SharedDriveRecord.objects.get(pk=1)
drive.datetime_accepted = self.days_ago(self.lifetime + 200)
drive.save()

# expire date today
drive = SharedDriveRecord.objects.get(pk=2)
drive.datetime_accepted = self.days_ago(self.lifetime)
drive.save()

# expire date tomorrow
drive = SharedDriveRecord.objects.get(pk=6)
drive.datetime_accepted = self.days_ago(self.lifetime - 1)
drive.save()

def notice_and_expire(self, offset_days, expected_results):
self.notice_and_expire_test(
_shared_drives_to_expire, _shared_drives_to_warn,
offset_days, expected_results)

def test_expiration_and_notices(self):
expected_results = [
[0, 2, 0, 0, 0], # level one
[0, 1, 0, 0, 0], # one plus a day
[0, 0, 0, 0, 0], # two minus a day
[0, 0, 2, 0, 0], # level two
[0, 0, 1, 0, 0], # two plus a day
[0, 0, 0, 0, 0], # three minus a day
[0, 0, 0, 2, 0], # level three
[0, 0, 0, 1, 0], # three plus a day
[0, 0, 0, 0, 0], # four minus a day
[0, 0, 0, 0, 2], # level four
[2, 0, 0, 0, 1], # four plus a day
[1, 0, 0, 0, 0], # four plus two days
[0, 0, 0, 0, 0], # grace minus a day
[0, 0, 0, 0, 0], # grace
[0, 0, 0, 0, 0]] # grace plus a day

self.message_timing(expiration_warning, expected_results)

def test_expiration_and_notice_email(self):
warn_members(1)
self.assertEqual(len(mail.outbox), 3)

SharedDriveRecord.objects.filter(
datetime_notice_1_emailed__isnull=False).update(
datetime_notice_1_emailed=F(
'datetime_notice_1_emailed')-timedelta(days=61))

warn_members(2)
self.assertEqual(len(mail.outbox), 6)

SharedDriveRecord.objects.filter(
datetime_notice_2_emailed__isnull=False).update(
datetime_notice_2_emailed=F(
'datetime_notice_2_emailed')-timedelta(days=30))

warn_members(3)
self.assertEqual(len(mail.outbox), 9)

SharedDriveRecord.objects.filter(
datetime_notice_3_emailed__isnull=False).update(
datetime_notice_3_emailed=F(
'datetime_notice_2_emailed')-timedelta(days=23))

warn_members(4)
self.assertEqual(len(mail.outbox), 12)
120 changes: 120 additions & 0 deletions endorsement/test/notifications/test_expiration_warnings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# Copyright 2024 UW-IT, University of Washington
# SPDX-License-Identifier: Apache-2.0

from django.test import TestCase
from django.core import mail
from django.db.models import F
from django.utils import timezone
from endorsement.test.notifications import NotificationsTestCase
from endorsement.models import Endorser, Endorsee, EndorsementRecord
from endorsement.policy.endorsement import (
_endorsements_to_warn, _endorsements_to_expire)
from endorsement.services import get_endorsement_service
from endorsement.notifications.endorsement import warn_endorsers
from datetime import timedelta


class TestProvisioneExpirationNotices(NotificationsTestCase):
def setUp(self):
self.now = timezone.now()

self.endorser1 = Endorser.objects.create(
netid='endorser1', regid='aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
display_name='Not Valid', is_valid=True)
self.endorser2 = Endorser.objects.create(
netid='endorser2', regid='bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
display_name='Not Valid', is_valid=True)
self.endorsee1 = Endorsee.objects.create(
netid='endorsee1', regid='cccccccccccccccccccccccccccccccc',
display_name='Endorsee Six', is_person=True)
self.endorsee2 = Endorsee.objects.create(
netid='endorsee2', regid='dddddddddddddddddddddddddddddddd',
display_name='Endorsee Seven', is_person=True)

# common lifecycle dates, two services to test combined email
o365 = get_endorsement_service('o365')
google = get_endorsement_service('google')

self.assertEqual(o365.endorsement_lifetime,
google.endorsement_lifetime)

self.lifetime = o365.endorsement_lifetime
self.graceperiod = o365.endorsement_graceperiod

# expire date long ago
EndorsementRecord.objects.create(
endorser=self.endorser1, endorsee=self.endorsee1,
category_code=o365.category_code,
reason="Just Because",
datetime_endorsed=self.days_ago(self.lifetime + 200))


# expire date today
EndorsementRecord.objects.create(
endorser=self.endorser1, endorsee=self.endorsee1,
category_code=google.category_code, reason="Just Because",
datetime_endorsed=self.days_ago(self.lifetime))

# expire date tomorrow
EndorsementRecord.objects.create(
endorser=self.endorser2, endorsee=self.endorsee2,
category_code=o365.category_code,
reason="I said so",
datetime_endorsed=self.days_ago(self.lifetime - 1))

def notice_and_expire(self, offset_days, expected_results):
self.notice_and_expire_test(
_endorsements_to_expire, _endorsements_to_warn,
offset_days, expected_results)

def test_expiration_and_notices(self):
service = get_endorsement_service('o365')

# use first service to get lifecycle dates
expected_results = [
[0, 2, 0, 0, 0], # level one
[0, 1, 0, 0, 0], # one plus a day
[0, 0, 0, 0, 0], # two minus a day
[0, 0, 2, 0, 0], # level two
[0, 0, 1, 0, 0], # two plus a day
[0, 0, 0, 0, 0], # three minus a day
[0, 0, 0, 2, 0], # level three
[0, 0, 0, 1, 0], # three plus a day
[0, 0, 0, 0, 0], # four minus a day
[0, 0, 0, 0, 2], # level four
[0, 0, 0, 0, 1], # four plus a day
[0, 0, 0, 0, 0], # four plus two days
[0, 0, 0, 0, 0], # grace minus a day
[2, 0, 0, 0, 0], # grace
[1, 0, 0, 0, 0]] # grace plus a day

self.message_timing(
service.endorsement_expiration_warning, expected_results)

def test_expiration_and_notice_email(self):
warn_endorsers(1)
self.assertEqual(len(mail.outbox), 2)

EndorsementRecord.objects.filter(
datetime_notice_1_emailed__isnull=False).update(
datetime_notice_1_emailed=F(
'datetime_notice_1_emailed')-timedelta(days=61))

warn_endorsers(2)
self.assertEqual(len(mail.outbox), 4)

EndorsementRecord.objects.filter(
datetime_notice_2_emailed__isnull=False).update(
datetime_notice_2_emailed=F(
'datetime_notice_2_emailed')-timedelta(days=30))

warn_endorsers(3)
self.assertEqual(len(mail.outbox), 6)

EndorsementRecord.objects.filter(
datetime_notice_3_emailed__isnull=False).update(
datetime_notice_3_emailed=F(
'datetime_notice_2_emailed')-timedelta(days=23))

warn_endorsers(4)
self.assertEqual(len(mail.outbox), 8)
166 changes: 0 additions & 166 deletions endorsement/test/test_expiration_warnings.py

This file was deleted.

36 changes: 35 additions & 1 deletion endorsement/views/api/notification.py
Original file line number Diff line number Diff line change
@@ -5,7 +5,9 @@
from endorsement.views.rest_dispatch import RESTDispatch
from endorsement.models import (
Endorser, Endorsee, EndorsementRecord,
Accessor, Accessee, AccessRight, AccessRecord)
Accessor, Accessee, AccessRight, AccessRecord,
Member, Role, SharedDriveMember, SharedDrive,
SharedDriveQuota, SharedDriveRecord, SharedDriveRecord)
from endorsement.services import endorsement_services, get_endorsement_service
from endorsement.util.auth import SupportGroupAuthentication
from endorsement.notifications.endorsement import (
@@ -15,6 +17,8 @@
_create_warn_shared_owner_message)
from endorsement.notifications.access import (
_create_accessor_message)
from endorsement.notifications.shared_drive import (
_create_expire_notice_message)
from endorsement.dao.accessors import get_accessor_email
from datetime import datetime, timedelta
import re
@@ -39,6 +43,8 @@ def post(self, request, *args, **kwargs):
return self._service_notification(request)
elif notice_type == 'access':
return self._access_notification(request)
elif notice_type == 'shared_drive':
return self._shared_drive_notification(request)

return self.error_response(400, "Incomplete or unknown notification.")

@@ -162,6 +168,34 @@ def _access_notification(self, request):
'html': html_body
})

def _shared_drive_notification(self, request):
notification = request.data.get('notification', None)

warning_level = None
m = re.match(r'^warning_([1-4])$', notification)
if m:
warning_level = int(m.group(1))

shared_drive = SharedDrive(
drive_id='1234567890abcdef1234567890abcdef',
drive_name='My shared drive',
drive_quota=SharedDriveQuota.objects.get(quota_limit=200),
drive_usage=200)

record = SharedDriveRecord(shared_drive=shared_drive)

if warning_level:
subject, text, html = _create_expire_notice_message(
warning_level, 365, record)
else:
return self.error_response(400, "Unknown notification.")

return self.json_response({
'subject': subject,
'text': text,
'html': html
})


# mimic function in dao.notification
def _get_unendorsed_unnotified(unendorsed):