From 20bfe1676d42bb52f4bea8836a9374d678b1658f Mon Sep 17 00:00:00 2001 From: mike seibel Date: Mon, 26 Feb 2024 10:26:41 -0800 Subject: [PATCH 01/26] initial shared drive record models --- endorsement/models/__init__.py | 1 + endorsement/models/shared_drive.py | 107 +++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 endorsement/models/shared_drive.py diff --git a/endorsement/models/__init__.py b/endorsement/models/__init__.py index a97a1697..29ec7b52 100644 --- a/endorsement/models/__init__.py +++ b/endorsement/models/__init__.py @@ -3,3 +3,4 @@ from endorsement.models.core import * from endorsement.models.access import * +from endorsement.models.shared_drive import * diff --git a/endorsement/models/shared_drive.py b/endorsement/models/shared_drive.py new file mode 100644 index 00000000..78192c20 --- /dev/null +++ b/endorsement/models/shared_drive.py @@ -0,0 +1,107 @@ +# Copyright 2024 UW-IT, University of Washington +# SPDX-License-Identifier: Apache-2.0 + +from django.db import models +from django.utils import timezone +from django_prometheus.models import ExportModelOperationsMixin +from endorsement.util.date import datetime_to_str +import json + + +class Manager(ExportModelOperationsMixin('manager'), models.Model): + """ + Manager model represents user who is responsible the given + shared drive and corresponding subscription. + """ + netid = models.SlugField(max_length=32, + db_index=True, + unique=True) + display_name = models.CharField(max_length=256, + null=True) + regid = models.CharField(max_length=32, + db_index=True, + unique=True) + is_valid = models.BooleanField(default=False) + + def __eq__(self, other): + return other is not None and self.regid == other.regid + + def json_data(self): + return { + "netid": self.netid, + "display_name": self.display_name, + "regid": self.regid, + "is_valid": self.is_valid + } + + def __str__(self): + return json.dumps(self.json_data()) + + +class SharedDriveTier( + ExportModelOperationsMixin('shared_drive_tier'), models.Model): + """ + SharedDriveTier model represents the shared drive tier. + It mainly serves to cache specific shared drive values + for quickly displaying details for the given manager's tier. + """ + tier_id = models.SlugField(max_length=32, null=True) + quota = models.IntegerField(null=True) + name = models.CharField(max_length=64, null=True) + def json_data(self): + return { + "tier_id": self.tier_id, + "quota": self.quota, + "name": self.name + } + + +class SharedDriveRecord( + ExportModelOperationsMixin('shared_drive_record'), models.Model): + """ + SharedDriveRecord model represents the binding between a + shared drive and its corresponding subscription, and preserves + various states and timestamps to manage its lifecycle. + """ + manager = models.ForeignKey( + Manager, on_delete=models.PROTECT) + subscription_id = models.SlugField(max_length=32, null=True) + shared_drive_id = models.SlugField(max_length=32, null=True) + shared_drive_tier = models.ForeignKey( + SharedDriveTier, on_delete=models.PROTECT) + state = models.CharField(max_length=128, null=True) + acted_as = models.SlugField(max_length=32, null=True) + datetime_created = models.DateTimeField(null=True) + datetime_emailed = models.DateTimeField(null=True) + datetime_notice_1_emailed = models.DateTimeField(null=True) + datetime_notice_2_emailed = models.DateTimeField(null=True) + datetime_notice_3_emailed = models.DateTimeField(null=True) + datetime_notice_4_emailed = models.DateTimeField(null=True) + datetime_granted = models.DateTimeField(null=True) + datetime_renewed = models.DateTimeField(null=True) + datetime_expired = models.DateTimeField(null=True) + is_deleted = models.BooleanField(null=True) + + def json_data(self): + return { + "manager": self.manager.json_data(), + "subscription_id": self.subscription_id, + "shared_drive_tier": self.shared_drive_tier.json_data(), + "shared_drive_id": self.shared_drive_id, + "state": self.state, + "acted_as": self.acted_as, + "datetime_created": datetime_to_str(self.datetime_created), + "datetime_emailed": datetime_to_str(self.datetime_emailed), + "datetime_notice_1_emailed": datetime_to_str( + self.datetime_notice_1_emailed), + "datetime_notice_2_emailed": datetime_to_str( + self.datetime_notice_2_emailed), + "datetime_notice_3_emailed": datetime_to_str( + self.datetime_notice_3_emailed), + "datetime_notice_4_emailed": datetime_to_str( + self.datetime_notice_4_emailed), + "datetime_granted": datetime_to_str(self.datetime_granted), + "datetime_renewed": datetime_to_str(self.datetime_renewed), + "datetime_expired": datetime_to_str(self.datetime_expired), + "is_deleted": self.is_deleted + } From 005774b9e8827e7661978191346350a9ac344d60 Mon Sep 17 00:00:00 2001 From: mike seibel Date: Wed, 28 Feb 2024 21:06:34 -0800 Subject: [PATCH 02/26] mock shared drive data and initial templating --- endorsement/dao/shared_drive.py | 104 +++++++++++++++++ endorsement/exceptions.py | 4 + endorsement/fixtures/test_data/member.json | 1 + endorsement/fixtures/test_data/role.json | 1 + .../fixtures/test_data/shared_drive.json | 1 + .../test_data/shared_drive_member.json | 1 + .../test_data/shared_drive_quota.json | 1 + .../test_data/shared_drive_record.json | 1 + .../management/commands/initialize_db.py | 7 ++ .../management/commands/load_shared_drives.py | 17 +++ .../migrations/0025_auto_20240228_1850.py | 90 ++++++++++++++ endorsement/models/shared_drive.py | 110 ++++++++++++------ .../static/endorsement/css/critical.scss | 7 +- .../endorsement/js/handlebars-helpers.js | 4 +- endorsement/static/endorsement/js/main.js | 4 +- .../static/endorsement/js/tab/google.js | 97 +++++++++++++++ .../handlebars/tab/drives/google.html | 51 ++++++++ endorsement/templates/index.html | 6 + endorsement/urls.py | 3 + endorsement/views/api/google/shared_drive.py | 46 ++++++++ 20 files changed, 517 insertions(+), 39 deletions(-) create mode 100644 endorsement/dao/shared_drive.py create mode 100644 endorsement/fixtures/test_data/member.json create mode 100644 endorsement/fixtures/test_data/role.json create mode 100644 endorsement/fixtures/test_data/shared_drive.json create mode 100644 endorsement/fixtures/test_data/shared_drive_member.json create mode 100644 endorsement/fixtures/test_data/shared_drive_quota.json create mode 100644 endorsement/fixtures/test_data/shared_drive_record.json create mode 100644 endorsement/management/commands/load_shared_drives.py create mode 100644 endorsement/migrations/0025_auto_20240228_1850.py create mode 100644 endorsement/static/endorsement/js/tab/google.js create mode 100644 endorsement/templates/handlebars/tab/drives/google.html create mode 100644 endorsement/views/api/google/shared_drive.py diff --git a/endorsement/dao/shared_drive.py b/endorsement/dao/shared_drive.py new file mode 100644 index 00000000..a367196f --- /dev/null +++ b/endorsement/dao/shared_drive.py @@ -0,0 +1,104 @@ +# Copyright 2024 UW-IT, University of Washington +# SPDX-License-Identifier: Apache-2.0 + +from endorsement.models.shared_drive import ( + SharedDriveMember, Member, Role, SharedDrive, + SharedDriveQuota, SharedDriveRecord) +from endorsement.exceptions import SharedDriveNonPrivilegedMember +import csv +import re +import logging + + +logger = logging.getLogger(__name__) + + +def load_shared_drives(file_path): + """ + populate shared drive models + """ + with open(file_path, 'r') as csvfile: + next(csvfile, None) + for row in csv.reader(csvfile, delimiter=","): + a = SharedDriveData(row) + try: + shared_drive_record = get_shared_drive_record(a) + except SharedDriveNonPrivilegedMember as ex: + logger.info(f"{ex}") + except Exception as ex: + logger.error(f"shared drive record: {a}: {ex}") + + +def get_shared_drive_record(a): + """ + ensure shared drive record is created + """ + shared_drive = get_shared_drive(a) + shared_drive_record, _ = SharedDriveRecord.objects.get_or_create( + shared_drive=shared_drive) + return shared_drive_record + + +def get_shared_drive(a): + """ + return a shared drive model + """ + drive_quota = get_drive_quota(a) + shared_drive_member = get_shared_drive_member(a) + shared_drive, _ = SharedDrive.objects.get_or_create( + drive_id=a.DriveId, drive_name=a.DriveName, drive_quota=drive_quota) + shared_drive.members.add(shared_drive_member) + return shared_drive + + +def get_drive_quota(a): + """ + return a shared drive quota model + """ + drive_quota, _ = SharedDriveQuota.objects.get_or_create(org_unit=a.OrgUnit) + return drive_quota + + +def get_shared_drive_member(a): + """ + return a shared drive member model + """ + member = get_member(a) + role = get_role(a) + shared_drive_member, _ = SharedDriveMember.objects.get_or_create( + member=member, role=role) + return shared_drive_member + + +def get_member(a): + """ + return a member model, netid-ing as necessary + """ + is_netid = re.match( + r'^(?P[^@]+)@(uw|(u\.)?washington)\.edu$', a.Member, re.I) + member, _ = Member.objects.get_or_create( + name=is_netid.group('netid') if is_netid else a.Member) + return member + + +def get_role(a): + """ + return a role model + """ + # cull non-manager roles until others are interesting + if a.Role != SharedDriveRecord.MANAGER_ROLE: + raise SharedDriveNonPrivilegedMember( + f"Shared drive member {a.Member} is not a manager") + + role, _ = Role.objects.get_or_create(role=a.Role) + return role + + +class SharedDriveData(object): + SHARED_DRIVE_COLUMNS = [ + "DriveId", "DriveName", "TotalMembers", "OrgUnit", "Member", + "Role", "QueryDate"] + + def __init__(self, row): + for i, k in enumerate(self.SHARED_DRIVE_COLUMNS): + setattr(self, k, row[i]) diff --git a/endorsement/exceptions.py b/endorsement/exceptions.py index 624b965c..ba1433d3 100644 --- a/endorsement/exceptions.py +++ b/endorsement/exceptions.py @@ -41,3 +41,7 @@ class TooManyUWNetids(Exception): class EmailFailureException(Exception): pass + + +class SharedDriveNonPrivilegedMember(Exception): + pass diff --git a/endorsement/fixtures/test_data/member.json b/endorsement/fixtures/test_data/member.json new file mode 100644 index 00000000..83a566cb --- /dev/null +++ b/endorsement/fixtures/test_data/member.json @@ -0,0 +1 @@ +[{"model": "endorsement.member", "pk": 1, "fields": {"name": "javerage"}}, {"model": "endorsement.member", "pk": 2, "fields": {"name": "endorsee7"}}, {"model": "endorsement.member", "pk": 3, "fields": {"name": "yahoodood@yahoo.com"}}, {"model": "endorsement.member", "pk": 4, "fields": {"name": "endorsee6"}}, {"model": "endorsement.member", "pk": 5, "fields": {"name": "jstaff"}}, {"model": "endorsement.member", "pk": 6, "fields": {"name": "boogaloo33@gmail.com"}}, {"model": "endorsement.member", "pk": 7, "fields": {"name": "endorsee5"}}, {"model": "endorsement.member", "pk": 8, "fields": {"name": "endorsee4"}}, {"model": "endorsement.member", "pk": 9, "fields": {"name": "endorsee3"}}, {"model": "endorsement.member", "pk": 10, "fields": {"name": "endorsee2"}}, {"model": "endorsement.member", "pk": 11, "fields": {"name": "endorsee1"}}] \ No newline at end of file diff --git a/endorsement/fixtures/test_data/role.json b/endorsement/fixtures/test_data/role.json new file mode 100644 index 00000000..f46271ee --- /dev/null +++ b/endorsement/fixtures/test_data/role.json @@ -0,0 +1 @@ +[{"model": "endorsement.role", "pk": 1, "fields": {"role": "organizer"}}] \ No newline at end of file diff --git a/endorsement/fixtures/test_data/shared_drive.json b/endorsement/fixtures/test_data/shared_drive.json new file mode 100644 index 00000000..f5934f98 --- /dev/null +++ b/endorsement/fixtures/test_data/shared_drive.json @@ -0,0 +1 @@ +[{"model": "endorsement.shareddrive", "pk": 1, "fields": {"drive_id": "ABC_0123-DE45FF5789", "drive_name": "My Drive One", "drive_quota": 1, "members": [1, 2, 3, 4]}}, {"model": "endorsement.shareddrive", "pk": 2, "fields": {"drive_id": "IRDXB54TWF3OY8MVC9J", "drive_name": "My Other Drive", "drive_quota": 2, "members": [3, 5]}}, {"model": "endorsement.shareddrive", "pk": 3, "fields": {"drive_id": "rBQ11YNswxAz2p2yGyE", "drive_name": "Single Member Drive", "drive_quota": 3, "members": [3]}}] \ No newline at end of file diff --git a/endorsement/fixtures/test_data/shared_drive_member.json b/endorsement/fixtures/test_data/shared_drive_member.json new file mode 100644 index 00000000..e351b730 --- /dev/null +++ b/endorsement/fixtures/test_data/shared_drive_member.json @@ -0,0 +1 @@ +[{"model": "endorsement.shareddrivemember", "pk": 1, "fields": {"member": 3, "role": 1}}, {"model": "endorsement.shareddrivemember", "pk": 2, "fields": {"member": 4, "role": 1}}, {"model": "endorsement.shareddrivemember", "pk": 3, "fields": {"member": 5, "role": 1}}, {"model": "endorsement.shareddrivemember", "pk": 4, "fields": {"member": 11, "role": 1}}, {"model": "endorsement.shareddrivemember", "pk": 5, "fields": {"member": 1, "role": 1}}] \ No newline at end of file diff --git a/endorsement/fixtures/test_data/shared_drive_quota.json b/endorsement/fixtures/test_data/shared_drive_quota.json new file mode 100644 index 00000000..7996d8f3 --- /dev/null +++ b/endorsement/fixtures/test_data/shared_drive_quota.json @@ -0,0 +1 @@ +[{"model": "endorsement.shareddrivequota", "pk": 1, "fields": {"org_unit": "T72MWH9HA4WUVIU", "quota_limit": null, "name": null}}, {"model": "endorsement.shareddrivequota", "pk": 2, "fields": {"org_unit": "WbliZ9HlrRXkszf", "quota_limit": null, "name": null}}, {"model": "endorsement.shareddrivequota", "pk": 3, "fields": {"org_unit": "HlhssOVDbv1dgHV", "quota_limit": null, "name": null}}] \ No newline at end of file diff --git a/endorsement/fixtures/test_data/shared_drive_record.json b/endorsement/fixtures/test_data/shared_drive_record.json new file mode 100644 index 00000000..964ac273 --- /dev/null +++ b/endorsement/fixtures/test_data/shared_drive_record.json @@ -0,0 +1 @@ +[{"model": "endorsement.shareddriverecord", "pk": 1, "fields": {"shared_drive": 1, "subscription_id": null, "state": null, "acted_as": null, "datetime_created": null, "datetime_emailed": null, "datetime_notice_1_emailed": null, "datetime_notice_2_emailed": null, "datetime_notice_3_emailed": null, "datetime_notice_4_emailed": null, "datetime_granted": null, "datetime_renewed": null, "datetime_expired": null, "is_deleted": null}}, {"model": "endorsement.shareddriverecord", "pk": 2, "fields": {"shared_drive": 2, "subscription_id": null, "state": null, "acted_as": null, "datetime_created": null, "datetime_emailed": null, "datetime_notice_1_emailed": null, "datetime_notice_2_emailed": null, "datetime_notice_3_emailed": null, "datetime_notice_4_emailed": null, "datetime_granted": null, "datetime_renewed": null, "datetime_expired": null, "is_deleted": null}}, {"model": "endorsement.shareddriverecord", "pk": 3, "fields": {"shared_drive": 3, "subscription_id": null, "state": null, "acted_as": null, "datetime_created": null, "datetime_emailed": null, "datetime_notice_1_emailed": null, "datetime_notice_2_emailed": null, "datetime_notice_3_emailed": null, "datetime_notice_4_emailed": null, "datetime_granted": null, "datetime_renewed": null, "datetime_expired": null, "is_deleted": null}}] \ No newline at end of file diff --git a/endorsement/management/commands/initialize_db.py b/endorsement/management/commands/initialize_db.py index 5106e3fd..68881748 100644 --- a/endorsement/management/commands/initialize_db.py +++ b/endorsement/management/commands/initialize_db.py @@ -13,3 +13,10 @@ def handle(self, *args, **options): call_command('loaddata', 'test_data/accessee.json') call_command('loaddata', 'test_data/accessor.json') call_command('loaddata', 'test_data/accessrecordconflict.json') + + call_command('loaddata', 'test_data/member.json') + call_command('loaddata', 'test_data/role.json') + call_command('loaddata', 'test_data/shared_drive_member.json') + call_command('loaddata', 'test_data/shared_drive_quota.json') + call_command('loaddata', 'test_data/shared_drive.json') + call_command('loaddata', 'test_data/shared_drive_record.json') diff --git a/endorsement/management/commands/load_shared_drives.py b/endorsement/management/commands/load_shared_drives.py new file mode 100644 index 00000000..80b1fc18 --- /dev/null +++ b/endorsement/management/commands/load_shared_drives.py @@ -0,0 +1,17 @@ +# Copyright 2024 UW-IT, University of Washington +# SPDX-License-Identifier: Apache-2.0 + + +from django.core.management.base import BaseCommand +from django.core.management import call_command +from endorsement.dao.shared_drive import load_shared_drives + + +class Command(BaseCommand): + help = "load shared drive data from csv file." + + def add_arguments(self, parser): + parser.add_argument('csv_file') + + def handle(self, *args, **options): + load_shared_drives(options['csv_file']) diff --git a/endorsement/migrations/0025_auto_20240228_1850.py b/endorsement/migrations/0025_auto_20240228_1850.py new file mode 100644 index 00000000..11d91f99 --- /dev/null +++ b/endorsement/migrations/0025_auto_20240228_1850.py @@ -0,0 +1,90 @@ +# Generated by Django 3.2.23 on 2024-02-29 02:50 + +from django.db import migrations, models +import django.db.models.deletion +import django_prometheus.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('endorsement', '0024_auto_20230913_1438'), + ] + + operations = [ + migrations.CreateModel( + name='Member', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=128)), + ], + bases=(django_prometheus.models.ExportModelOperationsMixin('member'), models.Model), + ), + migrations.CreateModel( + name='Role', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('role', models.CharField(max_length=32)), + ], + bases=(django_prometheus.models.ExportModelOperationsMixin('role'), models.Model), + ), + migrations.CreateModel( + name='SharedDrive', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('drive_id', models.SlugField(max_length=32)), + ('drive_name', models.CharField(max_length=128)), + ], + bases=(django_prometheus.models.ExportModelOperationsMixin('shared_drive'), models.Model), + ), + migrations.CreateModel( + name='SharedDriveQuota', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('org_unit', models.CharField(max_length=32)), + ('quota_limit', models.IntegerField(null=True)), + ('name', models.CharField(max_length=64, null=True)), + ], + bases=(django_prometheus.models.ExportModelOperationsMixin('shared_drive_tier'), models.Model), + ), + migrations.CreateModel( + name='SharedDriveRecord', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('subscription_id', models.SlugField(max_length=32, null=True)), + ('state', models.CharField(max_length=128, null=True)), + ('acted_as', models.SlugField(max_length=32, null=True)), + ('datetime_created', models.DateTimeField(null=True)), + ('datetime_emailed', models.DateTimeField(null=True)), + ('datetime_notice_1_emailed', models.DateTimeField(null=True)), + ('datetime_notice_2_emailed', models.DateTimeField(null=True)), + ('datetime_notice_3_emailed', models.DateTimeField(null=True)), + ('datetime_notice_4_emailed', models.DateTimeField(null=True)), + ('datetime_granted', models.DateTimeField(null=True)), + ('datetime_renewed', models.DateTimeField(null=True)), + ('datetime_expired', models.DateTimeField(null=True)), + ('is_deleted', models.BooleanField(null=True)), + ('shared_drive', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='endorsement.shareddrive')), + ], + bases=(django_prometheus.models.ExportModelOperationsMixin('shared_drive_record'), models.Model), + ), + migrations.CreateModel( + name='SharedDriveMember', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('member', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='endorsement.member')), + ('role', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='endorsement.role')), + ], + bases=(django_prometheus.models.ExportModelOperationsMixin('shared_drive_member'), models.Model), + ), + migrations.AddField( + model_name='shareddrive', + name='drive_quota', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='endorsement.shareddrivequota'), + ), + migrations.AddField( + model_name='shareddrive', + name='members', + field=models.ManyToManyField(to='endorsement.SharedDriveMember'), + ), + ] diff --git a/endorsement/models/shared_drive.py b/endorsement/models/shared_drive.py index 78192c20..01a8faff 100644 --- a/endorsement/models/shared_drive.py +++ b/endorsement/models/shared_drive.py @@ -8,52 +8,89 @@ import json -class Manager(ExportModelOperationsMixin('manager'), models.Model): +class Member(ExportModelOperationsMixin('member'), models.Model): + name = models.CharField(max_length=128) + + def json_data(self): + return self.name + + def __str__(self): + return json.dumps(self.json_data()) + + +class Role(ExportModelOperationsMixin('role'), models.Model): + role = models.CharField(max_length=32) + + def json_data(self): + return self.role + + def __str__(self): + return json.dumps(self.json_data()) + + +class SharedDriveMember( + ExportModelOperationsMixin('shared_drive_member'), models.Model): """ - Manager model represents user who is responsible the given + Member model represents users/groups responsible the given shared drive and corresponding subscription. """ - netid = models.SlugField(max_length=32, - db_index=True, - unique=True) - display_name = models.CharField(max_length=256, - null=True) - regid = models.CharField(max_length=32, - db_index=True, - unique=True) - is_valid = models.BooleanField(default=False) - - def __eq__(self, other): - return other is not None and self.regid == other.regid + member = models.ForeignKey(Member, on_delete=models.PROTECT) + role = models.ForeignKey(Role, on_delete=models.PROTECT) def json_data(self): return { - "netid": self.netid, - "display_name": self.display_name, - "regid": self.regid, - "is_valid": self.is_valid + "name": self.member.json_data(), + "role": self.role.json_data() } def __str__(self): return json.dumps(self.json_data()) -class SharedDriveTier( +class SharedDriveQuota( ExportModelOperationsMixin('shared_drive_tier'), models.Model): """ - SharedDriveTier model represents the shared drive tier. - It mainly serves to cache specific shared drive values - for quickly displaying details for the given manager's tier. + SharedDriveQuota model represents a quota (tier) """ - tier_id = models.SlugField(max_length=32, null=True) - quota = models.IntegerField(null=True) + org_unit = models.CharField(max_length=32) + quota_limit = models.IntegerField(null=True) name = models.CharField(max_length=64, null=True) + def json_data(self): return { - "tier_id": self.tier_id, - "quota": self.quota, + "org_unit": self.org_unit, + "quota": self.quota_limit, "name": self.name - } + } + + +class SharedDrive(ExportModelOperationsMixin('shared_drive'), models.Model): + """ + SharedDrive model represents a shared drive, its current quota + and its members + """ + drive_id = models.SlugField(max_length=32) + drive_name = models.CharField(max_length=128) + drive_quota = models.ForeignKey(SharedDriveQuota, on_delete=models.PROTECT) + members = models.ManyToManyField(SharedDriveMember) + + def json_data(self): + return { + "drive_id": self.drive_id, + "drive_name": self.drive_name, + "drive_quota": self.drive_quota.json_data(), + "members": [m.json_data() for m in self.members.all()] + } + + def __str__(self): + return json.dumps(self.json_data()) + + +class SharedDriveRecordManager(models.Manager): + def get_shared_drives_for_netid(self, netid): + return self.filter( + shared_drive__members__member__name=netid, + is_deleted__isnull=True) class SharedDriveRecord( @@ -63,12 +100,10 @@ class SharedDriveRecord( shared drive and its corresponding subscription, and preserves various states and timestamps to manage its lifecycle. """ - manager = models.ForeignKey( - Manager, on_delete=models.PROTECT) + MANAGER_ROLE = "organizer" + + shared_drive = models.ForeignKey(SharedDrive, on_delete=models.PROTECT) subscription_id = models.SlugField(max_length=32, null=True) - shared_drive_id = models.SlugField(max_length=32, null=True) - shared_drive_tier = models.ForeignKey( - SharedDriveTier, on_delete=models.PROTECT) state = models.CharField(max_length=128, null=True) acted_as = models.SlugField(max_length=32, null=True) datetime_created = models.DateTimeField(null=True) @@ -82,12 +117,12 @@ class SharedDriveRecord( datetime_expired = models.DateTimeField(null=True) is_deleted = models.BooleanField(null=True) + objects = SharedDriveRecordManager() + def json_data(self): return { - "manager": self.manager.json_data(), + "shared_drive": self.shared_drive.json_data(), "subscription_id": self.subscription_id, - "shared_drive_tier": self.shared_drive_tier.json_data(), - "shared_drive_id": self.shared_drive_id, "state": self.state, "acted_as": self.acted_as, "datetime_created": datetime_to_str(self.datetime_created), @@ -104,4 +139,7 @@ def json_data(self): "datetime_renewed": datetime_to_str(self.datetime_renewed), "datetime_expired": datetime_to_str(self.datetime_expired), "is_deleted": self.is_deleted - } + } + + def __str__(self): + return json.dumps(self.json_data()) diff --git a/endorsement/static/endorsement/css/critical.scss b/endorsement/static/endorsement/css/critical.scss index 69d5f6cb..565b45e2 100644 --- a/endorsement/static/endorsement/css/critical.scss +++ b/endorsement/static/endorsement/css/critical.scss @@ -281,7 +281,7 @@ a { } } -.endorsed-netids-table, .shared-netids-table, .office-access-table { border: 1px solid #ddd; +.endorsed-netids-table, .shared-netids-table, .office-access-table, .shared-drive-table { border: 1px solid #ddd; table { display: flex; flex-flow: column; width: 100%; margin-bottom: 0; } thead { flex: 0 0 auto; } @@ -358,6 +358,7 @@ label.accept_responsibility { .panel-toggle { color:#0077c2; display: inline-block; margin-bottom: 10px; } .netid-panel { margin-bottom: 40px; } +.shared-drive-panel { margin-bottom: 40px; } #provision { margin-bottom: 38px; } @@ -365,6 +366,10 @@ label.accept_responsibility { .access-header { font-size: 1.1em; font-weight: 800; } } +#shared_drives { padding-top: 2em; + .shared-drive-header { font-size: 1.1em; font-weight: 800; } +} + #provisioned, #shared, #office_access { .loading { min-height: 180px; diff --git a/endorsement/static/endorsement/js/handlebars-helpers.js b/endorsement/static/endorsement/js/handlebars-helpers.js index 9b84c038..0ed57f7f 100644 --- a/endorsement/static/endorsement/js/handlebars-helpers.js +++ b/endorsement/static/endorsement/js/handlebars-helpers.js @@ -8,7 +8,9 @@ $(window.document).ready(function() { 'email_editor_partial': $("#email_editor_partial").html(), 'office_access_row_partial': $("#office_access_row_partial").html(), 'office_conflict_row_partial': $('#office_conflict_row_partial').html(), - 'modal_action_partial': $("#modal_action_partial_template").html() + 'modal_action_partial': $("#modal_action_partial_template").html(), + 'shared_drives_row_partial': $("#shared_drives_row_partial").html(), + }); Handlebars.registerHelper({ diff --git a/endorsement/static/endorsement/js/main.js b/endorsement/static/endorsement/js/main.js index 0d51883b..30f8c84b 100644 --- a/endorsement/static/endorsement/js/main.js +++ b/endorsement/static/endorsement/js/main.js @@ -14,6 +14,7 @@ import { HandlebarsHelpers } from "./handlebars-helpers.js"; import { ManageProvisionedServices } from "./tab/endorsed.js"; import { ManageSharedNetids } from "./tab/shared.js"; import { ManageOfficeAccess } from "./tab/office.js"; +import { ManageSharedDrives } from "./tab/google.js"; $(window.document).ready(function() { var common_tools, @@ -36,7 +37,8 @@ $(window.document).ready(function() { panels = [MainTabs, ManageProvisionedServices, ManageSharedNetids, - ManageOfficeAccess]; + ManageOfficeAccess, + ManageSharedDrives]; loadTools(panels); } catch (err) { diff --git a/endorsement/static/endorsement/js/tab/google.js b/endorsement/static/endorsement/js/tab/google.js new file mode 100644 index 00000000..0345c143 --- /dev/null +++ b/endorsement/static/endorsement/js/tab/google.js @@ -0,0 +1,97 @@ +// common service endorse javascript +/* jshint esversion: 6 */ + +import { Scroll } from "../scroll.js"; +import { Button } from "../button.js"; +import { Notify } from "../notify.js"; +import { DateTime } from "../datetime.js"; +import { History } from "../history.js"; + +var ManageSharedDrives = (function () { + var content_id = 'shared_drives', + location_hash = '#' + content_id, + $panel = $(location_hash), + $content = $(location_hash), + + _registerEvents = function () { + var $tab = $('.tabs div#drives'); + + // delegated events within our content + $tab.on('endorse:drivesTabExposed', function (e) { + var $drives_table = $('.shared-drive-table', $panel); + + _getSharedDrives(); + }); + + $panel.on('endorse:SharedDrivesSuccess', function (e, data) { + _displaySharedDrives(data.drives); + }).on('endorse:SharedDrivesFailure', function (e, data) { + _displaySharedDrivesFailure(data); + }); + + $(document).on('endorse:TabChange', function (e, data) { + if (data == 'access') { + _adjustTabLocation(); + } + }).on('endorse:HistoryChange', function (e) { + _showTab(); + }); + }, + _showLoading = function () { + var source = $("#shared-drives-loading").html(), + template = Handlebars.compile(source); + + $content.html(template()); + }, + _displaySharedDrives = function (drives) { + var source = $("#shared_drives_panel").html(), + template = Handlebars.compile(source); + + $content.html(template({drives: drives})); + Scroll.init('.shared-drives-table'); + $('[data-toggle="popover"]').popover(); + }, + _getSharedDrives = function() { + var csrf_token = $("input[name=csrfmiddlewaretoken]")[0].value; + + _showLoading(); + + $.ajax({ + url: "/google/v1/shared_drives", + dataType: "JSON", + type: "GET", + accepts: {html: "application/json"}, + headers: { + "X-CSRFToken": csrf_token + }, + success: function(results) { + $panel.trigger('endorse:SharedDrivesSuccess', [results]); + }, + error: function(xhr, status, error) { + $panel.trigger('endorse:SharedDrivesFailure', [error]); + } + }); + }, + _displaySharedDrivesFailure = function (data) { + alert('Sorry, but we cannot retrieve shared drive information at the time: ' + data); + }, + _adjustTabLocation = function (tab) { + History.addPath('drives'); + }, + _showTab = function () { + if (window.location.pathname.match(/\/drives$/)) { + setTimeout(function(){ + $('.tabs .tabs-list li[data-tab="drives"] span').click(); + },100); + } + }; + + return { + load: function () { + _registerEvents(); + _showTab(); + } + }; +}()); + +export { ManageSharedDrives }; diff --git a/endorsement/templates/handlebars/tab/drives/google.html b/endorsement/templates/handlebars/tab/drives/google.html new file mode 100644 index 00000000..904a0313 --- /dev/null +++ b/endorsement/templates/handlebars/tab/drives/google.html @@ -0,0 +1,51 @@ +{% verbatim %} + + + + + + + + + +{% endverbatim %} diff --git a/endorsement/templates/index.html b/endorsement/templates/index.html index 23a5729c..63bed01c 100644 --- a/endorsement/templates/index.html +++ b/endorsement/templates/index.html @@ -14,6 +14,7 @@

Manage Provisions

  • Services
  • Elevated Access
  • +
  • Shared Drives
@@ -33,6 +34,9 @@

UW NetIDs owned by others

+
+
+
{% include "handlebars/reasons_partial.html" %} @@ -61,6 +65,8 @@

UW NetIDs owned by others

{% include "handlebars/tab/access/modal_renew.html" %} {% include "handlebars/tab/access/modal_update.html" %} +{% include "handlebars/tab/drives/google.html" %} + {% include "handlebars/expiring.html" %} {% include "handlebars/persistent_messages.html" %} diff --git a/endorsement/urls.py b/endorsement/urls.py index 33116be9..e7c49308 100644 --- a/endorsement/urls.py +++ b/endorsement/urls.py @@ -27,6 +27,7 @@ Access as OfficeAccess, AccessRights as OfficeAccessRights) from endorsement.views.api.office.resolve import ResolveRightsConflict from endorsement.views.api.office.validate import Validate as OfficeValidate +from endorsement.views.api.google.shared_drive import SharedDrive from endorsement.views.api.notification import Notification @@ -74,5 +75,7 @@ re_path(r'^office/v1/access', OfficeAccess.as_view(), name='access_api'), re_path(r'^office/v1/validate', OfficeValidate.as_view(), name='office_validate_api'), + re_path(r'^google/v1/shared_drives', SharedDrive.as_view(), + name='shared_drives_api'), re_path(r'.*', page.index, name='home'), ] diff --git a/endorsement/views/api/google/shared_drive.py b/endorsement/views/api/google/shared_drive.py new file mode 100644 index 00000000..8cdabc6b --- /dev/null +++ b/endorsement/views/api/google/shared_drive.py @@ -0,0 +1,46 @@ +# Copyright 2024 UW-IT, University of Washington +# SPDX-License-Identifier: Apache-2.0 + +from userservice.user import UserService +from endorsement.models import SharedDriveRecord +from endorsement.dao.persistent_messages import get_persistent_messages +from endorsement.views.rest_dispatch import ( + RESTDispatch, invalid_session, invalid_endorser) +from endorsement.exceptions import UnrecognizedUWNetid, InvalidNetID +import logging + + +logger = logging.getLogger(__name__) + + +class SharedDrive(RESTDispatch): + """ + Return SharedDriveRecords for provided netid + """ + def get(self, request, *args, **kwargs): + try: + netid, acted_as = self._validate_user(request) + except UnrecognizedUWNetid: + return invalid_session(logger) + except InvalidNetID: + return invalid_endorser(logger) + + drives = SharedDriveRecord.objects.get_shared_drives_for_netid(netid) + + return self.json_response({ + 'drives': [d.json_data() for d in drives], + 'messages': get_persistent_messages() + }) + + def _validate_user(self, request): + user_service = UserService() + netid = user_service.get_user() + if not netid: + raise UnrecognizedUWNetid() + + original_user = user_service.get_original_user() + acted_as = None if (netid == original_user) else original_user + if acted_as and is_only_support_user(request): + raise InvalidNetID() + + return netid, acted_as From fc173ffbae21ffb2b7de37553392a38affcbc19b Mon Sep 17 00:00:00 2001 From: mike seibel Date: Thu, 29 Feb 2024 08:01:55 -0800 Subject: [PATCH 03/26] shared drive api tests --- .../handlebars/tab/drives/google.html | 2 +- endorsement/test/api/test_shared_drives.py | 33 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 endorsement/test/api/test_shared_drives.py diff --git a/endorsement/templates/handlebars/tab/drives/google.html b/endorsement/templates/handlebars/tab/drives/google.html index 904a0313..c528f68a 100644 --- a/endorsement/templates/handlebars/tab/drives/google.html +++ b/endorsement/templates/handlebars/tab/drives/google.html @@ -30,7 +30,7 @@ {{ shared_drive.drive_name }} {{ shared_drive.drive_quota.org_unit }} - {{#each shared_drive.members}}{{name}}, {{/each}} +
    {{#each shared_drive.members}}
  • {{name}}
  • {{/each}}
diff --git a/endorsement/test/api/test_shared_drives.py b/endorsement/test/api/test_shared_drives.py new file mode 100644 index 00000000..04443dbe --- /dev/null +++ b/endorsement/test/api/test_shared_drives.py @@ -0,0 +1,33 @@ +# Copyright 2024 UW-IT, University of Washington +# SPDX-License-Identifier: Apache-2.0 + +import json +from django.urls import reverse +from endorsement.test.api import EndorsementApiTest + + +class TestSharedDrivesAPI(EndorsementApiTest): + fixtures = [ + 'test_data/member.json', + 'test_data/role.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 test_shared_drives(self): + self.set_user('jstaff') + url = reverse('shared_drives_api') + response = self.client.get(url) + self.assertEquals(response.status_code, 200) + data = json.loads(response.content) + self.assertEqual(len(data['drives']), 3) + + def test_no_shared_drives(self): + self.set_user('endorsee2') + url = reverse('shared_drives_api') + response = self.client.get(url) + self.assertEquals(response.status_code, 200) + data = json.loads(response.content) + self.assertEqual(len(data['drives']), 0) From d002f3ef19e73fdb5390c5573d0d3fd737706416 Mon Sep 17 00:00:00 2001 From: mike seibel Date: Thu, 29 Feb 2024 09:31:43 -0800 Subject: [PATCH 04/26] shared drive loader upsert --- endorsement/dao/shared_drive.py | 57 ++++++++++++------- .../management/commands/load_shared_drives.py | 4 +- 2 files changed, 37 insertions(+), 24 deletions(-) diff --git a/endorsement/dao/shared_drive.py b/endorsement/dao/shared_drive.py index a367196f..e5c385aa 100644 --- a/endorsement/dao/shared_drive.py +++ b/endorsement/dao/shared_drive.py @@ -11,49 +11,64 @@ logger = logging.getLogger(__name__) +netid_regex = re.compile( + r'^(?P[^@]+)@(uw|(u\.)?washington)\.edu$', re.I) -def load_shared_drives(file_path): +def load_shared_drives_from_csv(file_path): """ populate shared drive models """ + seen_shared_drives = set() + columns = None with open(file_path, 'r') as csvfile: - next(csvfile, None) for row in csv.reader(csvfile, delimiter=","): - a = SharedDriveData(row) try: - shared_drive_record = get_shared_drive_record(a) + if columns: + a = DataFromRow(row=row, columns=columns) + load_shared_drive_record( + a, a.DriveId in seen_shared_drives) + seen_shared_drives.add(a.DriveId) + else: + columns = list(filter(None, row)) except SharedDriveNonPrivilegedMember as ex: logger.info(f"{ex}") except Exception as ex: logger.error(f"shared drive record: {a}: {ex}") -def get_shared_drive_record(a): +def load_shared_drive_record(a, is_seen): """ ensure shared drive record is created """ - shared_drive = get_shared_drive(a) + shared_drive = upsert_shared_drive(a, is_seen) shared_drive_record, _ = SharedDriveRecord.objects.get_or_create( shared_drive=shared_drive) return shared_drive_record -def get_shared_drive(a): +def upsert_shared_drive(a, is_seen): """ - return a shared drive model + return a shared drive model for given DriveId, allowing for + name, quota and membership chantges """ drive_quota = get_drive_quota(a) shared_drive_member = get_shared_drive_member(a) - shared_drive, _ = SharedDrive.objects.get_or_create( - drive_id=a.DriveId, drive_name=a.DriveName, drive_quota=drive_quota) + shared_drive, created = SharedDrive.objects.update_or_create( + drive_id=a.DriveId, defaults={ + 'drive_name': a.DriveName, + 'drive_quota': drive_quota}) + + if not created and not is_seen: + shared_drive.members.clear() + shared_drive.members.add(shared_drive_member) return shared_drive def get_drive_quota(a): """ - return a shared drive quota model + return a shared drive quota model from OrgUnit """ drive_quota, _ = SharedDriveQuota.objects.get_or_create(org_unit=a.OrgUnit) return drive_quota @@ -61,7 +76,7 @@ def get_drive_quota(a): def get_shared_drive_member(a): """ - return a shared drive member model + return a shared drive member model from Member and Role """ member = get_member(a) role = get_role(a) @@ -72,12 +87,11 @@ def get_shared_drive_member(a): def get_member(a): """ - return a member model, netid-ing as necessary + return a member model, bare netid or non-uw email """ - is_netid = re.match( - r'^(?P[^@]+)@(uw|(u\.)?washington)\.edu$', a.Member, re.I) + netid = netid_regex.match(a.Member) member, _ = Member.objects.get_or_create( - name=is_netid.group('netid') if is_netid else a.Member) + name=netid.group('netid') if netid else a.Member) return member @@ -94,11 +108,10 @@ def get_role(a): return role -class SharedDriveData(object): - SHARED_DRIVE_COLUMNS = [ - "DriveId", "DriveName", "TotalMembers", "OrgUnit", "Member", - "Role", "QueryDate"] +class DataFromRow(object): + def __init__(self, *args, **kwargs): + columns = kwargs.get('columns', []) + row = kwargs.get('row', []) - def __init__(self, row): - for i, k in enumerate(self.SHARED_DRIVE_COLUMNS): + for i, k in enumerate(columns): setattr(self, k, row[i]) diff --git a/endorsement/management/commands/load_shared_drives.py b/endorsement/management/commands/load_shared_drives.py index 80b1fc18..e5e2bce6 100644 --- a/endorsement/management/commands/load_shared_drives.py +++ b/endorsement/management/commands/load_shared_drives.py @@ -4,7 +4,7 @@ from django.core.management.base import BaseCommand from django.core.management import call_command -from endorsement.dao.shared_drive import load_shared_drives +from endorsement.dao.shared_drive import load_shared_drives_from_csv class Command(BaseCommand): @@ -14,4 +14,4 @@ def add_arguments(self, parser): parser.add_argument('csv_file') def handle(self, *args, **options): - load_shared_drives(options['csv_file']) + load_shared_drives_from_csv(options['csv_file']) From 485b97d7e6b07094500d2382b7e7a07893bd51c7 Mon Sep 17 00:00:00 2001 From: mike seibel Date: Tue, 9 Apr 2024 12:49:26 -0700 Subject: [PATCH 05/26] itbill restclient --- docker/settings.py | 8 +++++ endorsement/dao/itbill.py | 56 ++++++++++++++++++++++++++++++ endorsement/exceptions.py | 8 +++++ endorsement/models/shared_drive.py | 27 ++++++++++++-- requirements.txt | 1 + 5 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 endorsement/dao/itbill.py diff --git a/docker/settings.py b/docker/settings.py index 82ed7363..155072c0 100644 --- a/docker/settings.py +++ b/docker/settings.py @@ -83,6 +83,14 @@ "MSCA_TIMEOUT", RESTCLIENTS_DEFAULT_TIMEOUT) RESTCLIENTS_MSCA_SUBSCRIPTION_KEY = os.getenv('MSCA_SUBSCRIPTION_KEY', '') + RESTCLIENTS_ITBILL_DAO_CLASS = Live + RESTCLIENTS_ITBILL_HOST=os.getenv('ITBILL_HOST') + RESTCLIENTS_ITBILL_BASIC_AUTH=os.getenv('ITBILL_BASIC_AUTH') + +ITBILL_SHARED_DRIVE_NAME_FORMAT="GSD_{}" +ITBILL_SHARED_DRIVE_PRODUCT_SYS_ID=os.getenv( + 'ITBILL_SHARED_DRIVE_PRODUCT_SYS_ID') + PROVISION_ADMIN_GROUP = 'u_acadev_provision_admin' PROVISION_SUPPORT_GROUP = 'u_acadev_provision_support' PROVISION_TEST_GROUP = 'u_acadev_provision_test' diff --git a/endorsement/dao/itbill.py b/endorsement/dao/itbill.py new file mode 100644 index 00000000..05d166df --- /dev/null +++ b/endorsement/dao/itbill.py @@ -0,0 +1,56 @@ +# Copyright 2024 UW-IT, University of Washington +# SPDX-License-Identifier: Apache-2.0 + +from django.conf import settings +from endorsement.models import SharedDrive, SharedDriveRecord +from endorsement.exceptions import ( + SharedDriveRecordExists, ITBillSubscriptionNotFound) +from userservice.user import UserService +from uw_itbill.subscription import Subscription +import logging + + +logger = logging.getLogger(__name__) + + +def initiate_subscription(shared_drive): + try: + user_service = UserService() + + shared_drive_record = SharedDriveRecord.objects.create( + shared_drive=shared_drive, state=SharedDriveRecord.STATE_DRAFT) + + new_subscription = { + "name": getattr( + settings, "ITBILL_SHARED_DRIVE_NAME_FORMAT", "{}").format( + shared_drive.drive_id), + "key_remote": shared_drive_record.subscription_key_remote, + "product": getattr(settings, "ITBILL_SHARED_DRIVE_PRODUCT_SYS_ID"), + "start_date": "", + "contact": user_service.get_user(), + "contacts_additional": ','.join([ + member.name for member in shared_drive.members.all()]), + "lifecycle_state": SharedDriveRecord.STATE_DRAFT, + "work_notes": "Subscription initiated by Provision Request Tool", + } + + return Subscription().create_subscription(new_subscription) + except SharedDriveRecord.ValidationError as ex: + raise SharedDriveRecordExists(shared_drive.drive_id) + except Exception as ex: + logger.exception("Subscription: for {}: {}".format(subscription, ex)) + + return None + + +def get_subscription_by_key_remote(key_remote): + try: + return Subscription().get_subscription_by_key_remote( + subscription_key_remote) + except DataFailureException as ex: + if ex.status == 404: + raise ITBillSubscriptionNotFound(key_remote) + + raise + + return None diff --git a/endorsement/exceptions.py b/endorsement/exceptions.py index ba1433d3..d0337b6e 100644 --- a/endorsement/exceptions.py +++ b/endorsement/exceptions.py @@ -45,3 +45,11 @@ class EmailFailureException(Exception): class SharedDriveNonPrivilegedMember(Exception): pass + + +class SharedDriveRecordExists(Exception): + pass + + +class ITBillSubscriptionNotFound(Exception): + pass diff --git a/endorsement/models/shared_drive.py b/endorsement/models/shared_drive.py index 01a8faff..ef8b8451 100644 --- a/endorsement/models/shared_drive.py +++ b/endorsement/models/shared_drive.py @@ -101,10 +101,25 @@ class SharedDriveRecord( various states and timestamps to manage its lifecycle. """ MANAGER_ROLE = "organizer" + SUBSCRIPTION_DRAFT = "draft" + SUBSCRIPTION_PROVISIONING = "provisioning" + SUBSCRIPTION_DEPLOYED = "deployed" + SUBSCRIPTION_DEPROVISION = "deprovision" + SUBSCRIPTION_CLOSED = "closed" + SUBSCRIPTION_CANCELLED = "cancelled" + SUBSCRIPTION_STATE_CHOICES = ( + (SUBSCRIPTION_DRAFT, "Draft"), + (SUBSCRIPTION_PROVISIONING, "Provisioning"), + (SUBSCRIPTION_DEPLOYED, "Deployed"), + (SUBSCRIPTION_DEPROVISION, "Deprovision"), + (SUBSCRIPTION_CLOSED, "Closed"), + (SUBSCRIPTION_CANCELLED, "Cancelled") + ) shared_drive = models.ForeignKey(SharedDrive, on_delete=models.PROTECT) - subscription_id = models.SlugField(max_length=32, null=True) - state = models.CharField(max_length=128, null=True) + subscription_key_remote = models.SlugField(max_length=32) + subscription_state = models.CharField( + max_length=16, choices=SUBSCRIPTION_STATE_CHOICES) acted_as = models.SlugField(max_length=32, null=True) datetime_created = models.DateTimeField(null=True) datetime_emailed = models.DateTimeField(null=True) @@ -119,6 +134,14 @@ class SharedDriveRecord( objects = SharedDriveRecordManager() + def save(self, *args, **kwargs): + if not self.subscription_key_remote: + self.subscription_key_remote = "".join( + ["0123456789abcdef"[ + random.randint(0, 0xF)] for _ in range(32)]) + + super(SharedDriveRecord, self).save(*args, **kwargs) + def json_data(self): return { "shared_drive": self.shared_drive.json_data(), diff --git a/requirements.txt b/requirements.txt index 274b021b..44cff4ee 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ -e . git+https://github.com/uw-it-aca/uw-restclients-msca@develop#egg=uw-restclients-msca +git+https://github.com/uw-it-aca/uw-restclients-itbill@develop#egg=uw-restclients-itbill From 72f3bfe1363e340222eb7f60847b0780b90d75bf Mon Sep 17 00:00:00 2001 From: mike seibel Date: Tue, 9 Apr 2024 12:55:04 -0700 Subject: [PATCH 06/26] shared drive record to unique drive --- endorsement/dao/itbill.py | 2 -- endorsement/models/shared_drive.py | 3 ++- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/endorsement/dao/itbill.py b/endorsement/dao/itbill.py index 05d166df..b3ef920a 100644 --- a/endorsement/dao/itbill.py +++ b/endorsement/dao/itbill.py @@ -52,5 +52,3 @@ def get_subscription_by_key_remote(key_remote): raise ITBillSubscriptionNotFound(key_remote) raise - - return None diff --git a/endorsement/models/shared_drive.py b/endorsement/models/shared_drive.py index ef8b8451..a44af2b1 100644 --- a/endorsement/models/shared_drive.py +++ b/endorsement/models/shared_drive.py @@ -116,7 +116,8 @@ class SharedDriveRecord( (SUBSCRIPTION_CANCELLED, "Cancelled") ) - shared_drive = models.ForeignKey(SharedDrive, on_delete=models.PROTECT) + shared_drive = models.ForeignKey( + SharedDrive, unique=True, on_delete=models.PROTECT) subscription_key_remote = models.SlugField(max_length=32) subscription_state = models.CharField( max_length=16, choices=SUBSCRIPTION_STATE_CHOICES) From cc60f543d0569c9343a70352bbbf27e6cea2204a Mon Sep 17 00:00:00 2001 From: mike seibel Date: Tue, 9 Apr 2024 14:29:07 -0700 Subject: [PATCH 07/26] random key_remote --- endorsement/models/shared_drive.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/endorsement/models/shared_drive.py b/endorsement/models/shared_drive.py index a44af2b1..0a75a1d0 100644 --- a/endorsement/models/shared_drive.py +++ b/endorsement/models/shared_drive.py @@ -5,6 +5,7 @@ from django.utils import timezone from django_prometheus.models import ExportModelOperationsMixin from endorsement.util.date import datetime_to_str +import secrets import json @@ -137,9 +138,7 @@ class SharedDriveRecord( def save(self, *args, **kwargs): if not self.subscription_key_remote: - self.subscription_key_remote = "".join( - ["0123456789abcdef"[ - random.randint(0, 0xF)] for _ in range(32)]) + self.subscription_key_remote = secrets.token_hex(16) super(SharedDriveRecord, self).save(*args, **kwargs) From 4e25613743b9151b3b287a4e4fc75cc080cedcd4 Mon Sep 17 00:00:00 2001 From: mike seibel Date: Fri, 26 Apr 2024 18:47:49 -0700 Subject: [PATCH 08/26] layout interactions align with wireframes --- docker/settings.py | 2 + endorsement/dao/itbill.py | 13 +- .../test_data/itbill_subscription.json | 26 +++ endorsement/fixtures/test_data/member.json | 56 ++++- .../fixtures/test_data/shared_drive.json | 58 ++++- .../test_data/shared_drive_member.json | 89 +++++++- .../test_data/shared_drive_quota.json | 37 +++- .../test_data/shared_drive_record.json | 98 ++++++++- .../management/commands/initialize_db.py | 1 + .../migrations/0026_auto_20240426_1758.py | 81 +++++++ endorsement/models/shared_drive.py | 202 ++++++++++++++---- .../static/endorsement/css/critical.scss | 31 ++- .../endorsement/js/handlebars-helpers.js | 19 ++ endorsement/static/endorsement/js/history.js | 16 +- .../static/endorsement/js/tab/endorsed.js | 2 +- .../static/endorsement/js/tab/google.js | 171 ++++++++++++++- .../templates/handlebars/reasons_partial.html | 2 +- .../handlebars/tab/drives/google.html | 83 +++++-- .../handlebars/tab/drives/modal_accept.html | 44 ++++ .../handlebars/tab/drives/modal_itbill.html | 27 +++ .../handlebars/tab/drives/modal_revoke.html | 36 ++++ endorsement/templates/index.html | 13 +- endorsement/test/api/test_shared_drives.py | 9 +- endorsement/urls.py | 7 +- endorsement/util/log.py | 4 + endorsement/views/api/google/itbill.py | 59 +++++ endorsement/views/api/google/shared_drive.py | 58 +++-- endorsement/views/api/office/access.py | 13 -- endorsement/views/rest_dispatch.py | 29 ++- 29 files changed, 1162 insertions(+), 124 deletions(-) create mode 100644 endorsement/fixtures/test_data/itbill_subscription.json create mode 100644 endorsement/migrations/0026_auto_20240426_1758.py create mode 100644 endorsement/templates/handlebars/tab/drives/modal_accept.html create mode 100644 endorsement/templates/handlebars/tab/drives/modal_itbill.html create mode 100644 endorsement/templates/handlebars/tab/drives/modal_revoke.html create mode 100644 endorsement/views/api/google/itbill.py diff --git a/docker/settings.py b/docker/settings.py index 155072c0..45283cce 100644 --- a/docker/settings.py +++ b/docker/settings.py @@ -90,6 +90,8 @@ ITBILL_SHARED_DRIVE_NAME_FORMAT="GSD_{}" ITBILL_SHARED_DRIVE_PRODUCT_SYS_ID=os.getenv( 'ITBILL_SHARED_DRIVE_PRODUCT_SYS_ID') +ITBILL_FORM_URL_BASE=os.getenv('ITBILL_FORM_URL_BASE') +ITBILL_FORM_SYS_ID=os.getenv('ITBILL_FORM_SYS_ID') PROVISION_ADMIN_GROUP = 'u_acadev_provision_admin' PROVISION_SUPPORT_GROUP = 'u_acadev_provision_support' diff --git a/endorsement/dao/itbill.py b/endorsement/dao/itbill.py index b3ef920a..f4e02876 100644 --- a/endorsement/dao/itbill.py +++ b/endorsement/dao/itbill.py @@ -13,18 +13,13 @@ logger = logging.getLogger(__name__) -def initiate_subscription(shared_drive): +def initiate_subscription(shared_drive_record): try: user_service = UserService() - shared_drive_record = SharedDriveRecord.objects.create( - shared_drive=shared_drive, state=SharedDriveRecord.STATE_DRAFT) - new_subscription = { - "name": getattr( - settings, "ITBILL_SHARED_DRIVE_NAME_FORMAT", "{}").format( - shared_drive.drive_id), - "key_remote": shared_drive_record.subscription_key_remote, + "name": shared_drive_record.subscription.name, + "key_remote": shared_drive_record.subscription.key_remote, "product": getattr(settings, "ITBILL_SHARED_DRIVE_PRODUCT_SYS_ID"), "start_date": "", "contact": user_service.get_user(), @@ -46,7 +41,7 @@ def initiate_subscription(shared_drive): def get_subscription_by_key_remote(key_remote): try: return Subscription().get_subscription_by_key_remote( - subscription_key_remote) + subscription.key_remote) except DataFailureException as ex: if ex.status == 404: raise ITBillSubscriptionNotFound(key_remote) diff --git a/endorsement/fixtures/test_data/itbill_subscription.json b/endorsement/fixtures/test_data/itbill_subscription.json new file mode 100644 index 00000000..a9dce7f0 --- /dev/null +++ b/endorsement/fixtures/test_data/itbill_subscription.json @@ -0,0 +1,26 @@ +[ + { + "model": "endorsement.itbillsubscription", + "pk": 1, + "fields": { + "key_remote": "C11F79E44DA766B7", + "name": "GSD_C11F79E44DA766B7", + "state": 1, + "url": "https://uwdev.service-now.com/sp?id=sc_cat_item&sys_id=dc46f1711b418290cc990dc0604bcbcc&remote_key=GSD_test_test_test_123_rk&shared_drive=my%20drive", + "query_priority": 0, + "query_datetime": null + } + }, + { + "model": "endorsement.itbillsubscription", + "pk": 2, + "fields": { + "key_remote": "GSD_test_test_test_123_rk", + "name": "my drive", + "state": 1, + "url": "https://uwdev.service-now.com/sp?id=sc_cat_item&sys_id=dc46f1711b418290cc990dc0604bcbcc&remote_key=GSD_test_test_test_123_rk&shared_drive=my%20drive", + "query_priority": 0, + "query_datetime": null + } + } +] diff --git a/endorsement/fixtures/test_data/member.json b/endorsement/fixtures/test_data/member.json index 83a566cb..08453e1c 100644 --- a/endorsement/fixtures/test_data/member.json +++ b/endorsement/fixtures/test_data/member.json @@ -1 +1,55 @@ -[{"model": "endorsement.member", "pk": 1, "fields": {"name": "javerage"}}, {"model": "endorsement.member", "pk": 2, "fields": {"name": "endorsee7"}}, {"model": "endorsement.member", "pk": 3, "fields": {"name": "yahoodood@yahoo.com"}}, {"model": "endorsement.member", "pk": 4, "fields": {"name": "endorsee6"}}, {"model": "endorsement.member", "pk": 5, "fields": {"name": "jstaff"}}, {"model": "endorsement.member", "pk": 6, "fields": {"name": "boogaloo33@gmail.com"}}, {"model": "endorsement.member", "pk": 7, "fields": {"name": "endorsee5"}}, {"model": "endorsement.member", "pk": 8, "fields": {"name": "endorsee4"}}, {"model": "endorsement.member", "pk": 9, "fields": {"name": "endorsee3"}}, {"model": "endorsement.member", "pk": 10, "fields": {"name": "endorsee2"}}, {"model": "endorsement.member", "pk": 11, "fields": {"name": "endorsee1"}}] \ No newline at end of file +[{ + "model": "endorsement.member", + "pk": 1, + "fields": { + "name": "javerage"}}, + { + "model": "endorsement.member", + "pk": 2, + "fields": { + "name": "endorsee7"}}, + { + "model": "endorsement.member", + "pk": 3, + "fields": { + "name": "yahoodood@yahoo.com"}}, + { + "model": "endorsement.member", + "pk": 4, + "fields": { + "name": "endorsee6"}}, + { + "model": "endorsement.member", + "pk": 5, + "fields": { + "name": "jstaff"}}, + { + "model": "endorsement.member", + "pk": 6, + "fields": { + "name": "boogaloo33@gmail.com"}}, + { + "model": "endorsement.member", + "pk": 7, + "fields": { + "name": "endorsee5"}}, + { + "model": "endorsement.member", + "pk": 8, + "fields": { + "name": "endorsee4"}}, + { + "model": "endorsement.member", + "pk": 9, + "fields": { + "name": "endorsee3"}}, + { + "model": "endorsement.member", + "pk": 10, + "fields": { + "name": "endorsee2"}}, + { + "model": "endorsement.member", + "pk": 11, + "fields": { + "name": "endorsee1"}}] diff --git a/endorsement/fixtures/test_data/shared_drive.json b/endorsement/fixtures/test_data/shared_drive.json index f5934f98..547918b0 100644 --- a/endorsement/fixtures/test_data/shared_drive.json +++ b/endorsement/fixtures/test_data/shared_drive.json @@ -1 +1,57 @@ -[{"model": "endorsement.shareddrive", "pk": 1, "fields": {"drive_id": "ABC_0123-DE45FF5789", "drive_name": "My Drive One", "drive_quota": 1, "members": [1, 2, 3, 4]}}, {"model": "endorsement.shareddrive", "pk": 2, "fields": {"drive_id": "IRDXB54TWF3OY8MVC9J", "drive_name": "My Other Drive", "drive_quota": 2, "members": [3, 5]}}, {"model": "endorsement.shareddrive", "pk": 3, "fields": {"drive_id": "rBQ11YNswxAz2p2yGyE", "drive_name": "Single Member Drive", "drive_quota": 3, "members": [3]}}] \ No newline at end of file +[ + { + "model": "endorsement.shareddrive", + "pk": 1, + "fields": { + "drive_id": "ABC_0123-DE45FF5789", + "drive_name": "Subsidized quota", + "drive_usage": 39, + "drive_quota": 1, + "members": [1, 2, 3, 4, 5] + } + }, + { + "model": "endorsement.shareddrive", + "pk": 2, + "fields": { + "drive_id": "1KsIl1coIylwR0FQ", + "drive_name": "Subsidized, expiring", + "drive_usage": 81, + "drive_quota": 1, + "members": [5, 8, 10] + } + }, + { + "model": "endorsement.shareddrive", + "pk": 3, + "fields": { + "drive_id": "IRDXB54TWF3OY8MVC9J", + "drive_name": "Lg quota, with subscription, expiring soon", + "drive_usage": 218, + "drive_quota": 3, + "members": [5] + } + }, + { + "model": "endorsement.shareddrive", + "pk": 4, + "fields": { + "drive_id": "rBQ11YNswxAz2p2yGyE", + "drive_name": "Lg quota, day zero", + "drive_usage": 99, + "drive_quota": 2, + "members": [1, 3, 5] + } + }, + { + "model": "endorsement.shareddrive", + "pk": 5, + "fields": { + "drive_id": "9F97529B38CF46F336C7408", + "drive_name": "Lg quota with subscription", + "drive_usage": 283, + "drive_quota": 4, + "members": [1, 2, 3, 4, 5, 7, 8, 10, 11] + } + } +] diff --git a/endorsement/fixtures/test_data/shared_drive_member.json b/endorsement/fixtures/test_data/shared_drive_member.json index e351b730..e3339714 100644 --- a/endorsement/fixtures/test_data/shared_drive_member.json +++ b/endorsement/fixtures/test_data/shared_drive_member.json @@ -1 +1,88 @@ -[{"model": "endorsement.shareddrivemember", "pk": 1, "fields": {"member": 3, "role": 1}}, {"model": "endorsement.shareddrivemember", "pk": 2, "fields": {"member": 4, "role": 1}}, {"model": "endorsement.shareddrivemember", "pk": 3, "fields": {"member": 5, "role": 1}}, {"model": "endorsement.shareddrivemember", "pk": 4, "fields": {"member": 11, "role": 1}}, {"model": "endorsement.shareddrivemember", "pk": 5, "fields": {"member": 1, "role": 1}}] \ No newline at end of file +[{ + "model": "endorsement.shareddrivemember", + "pk": 1, + "fields": { + "member": 1, + "role": 1 + } + }, + { + "model": "endorsement.shareddrivemember", + "pk": 2, + "fields": { + "member": 2, + "role": 1 + } + }, + { + "model": "endorsement.shareddrivemember", + "pk": 3, + "fields": { + "member": 3, + "role": 1 + } + }, + { + "model": "endorsement.shareddrivemember", + "pk": 4, + "fields": { + "member": 4, + "role": 1 + } + }, + { + "model": "endorsement.shareddrivemember", + "pk": 5, + "fields": { + "member": 5, + "role": 1 + } + }, + { + "model": "endorsement.shareddrivemember", + "pk": 6, + "fields": { + "member": 6, + "role": 1 + } + }, + { + "model": "endorsement.shareddrivemember", + "pk": 7, + "fields": { + "member": 7, + "role": 1 + } + }, + { + "model": "endorsement.shareddrivemember", + "pk": 8, + "fields": { + "member": 8, + "role": 1 + } + }, + { + "model": "endorsement.shareddrivemember", + "pk": 9, + "fields": { + "member": 9, + "role": 1 + } + }, + { + "model": "endorsement.shareddrivemember", + "pk": 10, + "fields": { + "member": 10, + "role": 1 + } + }, + { + "model": "endorsement.shareddrivemember", + "pk": 11, + "fields": { + "member": 11, + "role": 1 + } + }] diff --git a/endorsement/fixtures/test_data/shared_drive_quota.json b/endorsement/fixtures/test_data/shared_drive_quota.json index 7996d8f3..31a16739 100644 --- a/endorsement/fixtures/test_data/shared_drive_quota.json +++ b/endorsement/fixtures/test_data/shared_drive_quota.json @@ -1 +1,36 @@ -[{"model": "endorsement.shareddrivequota", "pk": 1, "fields": {"org_unit": "T72MWH9HA4WUVIU", "quota_limit": null, "name": null}}, {"model": "endorsement.shareddrivequota", "pk": 2, "fields": {"org_unit": "WbliZ9HlrRXkszf", "quota_limit": null, "name": null}}, {"model": "endorsement.shareddrivequota", "pk": 3, "fields": {"org_unit": "HlhssOVDbv1dgHV", "quota_limit": null, "name": null}}] \ No newline at end of file +[ + { + "model": "endorsement.shareddrivequota", + "pk": 1, + "fields": { + "org_unit_id": "T72MWH9HA4WUVIU", + "org_unit_name": "uw.edu", + "quota_limit": 100 + } + }, + { + "model": "endorsement.shareddrivequota", + "pk": 2, + "fields": { + "org_unit_id": "WbliZ9HlrRXkszf", + "org_unit_name": "uw.edu", + "quota_limit": 200 + } + }, + {"model": "endorsement.shareddrivequota", + "pk": 3, + "fields": { + "org_unit_id": "HlhssOVDbv1dgHV", + "org_unit_name": "uw.edu", + "quota_limit": 300 + } + }, + {"model": "endorsement.shareddrivequota", + "pk": 4, + "fields": { + "org_unit_id": "gS8XJwJMfxYDCkle", + "org_unit_name": "uw.edu", + "quota_limit": 400 + } + } +] diff --git a/endorsement/fixtures/test_data/shared_drive_record.json b/endorsement/fixtures/test_data/shared_drive_record.json index 964ac273..f357021b 100644 --- a/endorsement/fixtures/test_data/shared_drive_record.json +++ b/endorsement/fixtures/test_data/shared_drive_record.json @@ -1 +1,97 @@ -[{"model": "endorsement.shareddriverecord", "pk": 1, "fields": {"shared_drive": 1, "subscription_id": null, "state": null, "acted_as": null, "datetime_created": null, "datetime_emailed": null, "datetime_notice_1_emailed": null, "datetime_notice_2_emailed": null, "datetime_notice_3_emailed": null, "datetime_notice_4_emailed": null, "datetime_granted": null, "datetime_renewed": null, "datetime_expired": null, "is_deleted": null}}, {"model": "endorsement.shareddriverecord", "pk": 2, "fields": {"shared_drive": 2, "subscription_id": null, "state": null, "acted_as": null, "datetime_created": null, "datetime_emailed": null, "datetime_notice_1_emailed": null, "datetime_notice_2_emailed": null, "datetime_notice_3_emailed": null, "datetime_notice_4_emailed": null, "datetime_granted": null, "datetime_renewed": null, "datetime_expired": null, "is_deleted": null}}, {"model": "endorsement.shareddriverecord", "pk": 3, "fields": {"shared_drive": 3, "subscription_id": null, "state": null, "acted_as": null, "datetime_created": null, "datetime_emailed": null, "datetime_notice_1_emailed": null, "datetime_notice_2_emailed": null, "datetime_notice_3_emailed": null, "datetime_notice_4_emailed": null, "datetime_granted": null, "datetime_renewed": null, "datetime_expired": null, "is_deleted": null}}] \ No newline at end of file +[ + { + "model": "endorsement.shareddriverecord", + "pk": 1, + "fields": { + "shared_drive": 1, + "subscription": null, + "acted_as": null, + "datetime_created": "2024-04-22T17:41:28+00:00", + "datetime_emailed": null, + "datetime_notice_1_emailed": null, + "datetime_notice_2_emailed": null, + "datetime_notice_3_emailed": null, + "datetime_notice_4_emailed": null, + "datetime_accepted": "2024-04-22T17:41:28+00:00", + "datetime_renewed": null, + "datetime_expired": null, + "is_deleted": null + } + }, + { + "model": "endorsement.shareddriverecord", + "pk": 2, + "fields": { + "shared_drive": 2, + "subscription": null, + "acted_as": null, + "datetime_created": "2023-06-22T17:41:28+00:00", + "datetime_emailed": null, + "datetime_notice_1_emailed": null, + "datetime_notice_2_emailed": null, + "datetime_notice_3_emailed": null, + "datetime_notice_4_emailed": null, + "datetime_accepted": "2023-06-22T17:41:28+00:00", + "datetime_renewed": null, + "datetime_expired": null, + "is_deleted": null + } + }, + { + "model": "endorsement.shareddriverecord", + "pk": 3, + "fields": { + "shared_drive": 3, + "subscription": 2, + "acted_as": null, + "datetime_created": "2024-04-22T17:41:28+00:00", + "datetime_emailed": null, + "datetime_notice_1_emailed": null, + "datetime_notice_2_emailed": null, + "datetime_notice_3_emailed": null, + "datetime_notice_4_emailed": null, + "datetime_accepted": "2023-05-03T17:41:28+00:00", + "datetime_renewed": null, + "datetime_expired": null, + "is_deleted": null + } + }, + { + "model": "endorsement.shareddriverecord", + "pk": 4, + "fields": { + "shared_drive": 4, + "subscription": null, + "acted_as": null, + "datetime_created": "2024-04-01T17:41:28+00:00", + "datetime_emailed": null, + "datetime_notice_1_emailed": null, + "datetime_notice_2_emailed": null, + "datetime_notice_3_emailed": null, + "datetime_notice_4_emailed": null, + "datetime_accepted": null, + "datetime_renewed": null, + "datetime_expired": null, + "is_deleted": null + } + }, + { + "model": "endorsement.shareddriverecord", + "pk": 5, + "fields": { + "shared_drive": 5, + "subscription": 2, + "acted_as": null, + "datetime_created": "2024-04-22T17:41:28+00:00", + "datetime_emailed": null, + "datetime_notice_1_emailed": null, + "datetime_notice_2_emailed": null, + "datetime_notice_3_emailed": null, + "datetime_notice_4_emailed": null, + "datetime_accepted": "2024-04-22T17:41:28+00:00", + "datetime_renewed": null, + "datetime_expired": null, + "is_deleted": null + } + } +] diff --git a/endorsement/management/commands/initialize_db.py b/endorsement/management/commands/initialize_db.py index 68881748..cfdbe3b2 100644 --- a/endorsement/management/commands/initialize_db.py +++ b/endorsement/management/commands/initialize_db.py @@ -16,6 +16,7 @@ def handle(self, *args, **options): call_command('loaddata', 'test_data/member.json') call_command('loaddata', 'test_data/role.json') + call_command('loaddata', 'test_data/itbill_subscription.json') call_command('loaddata', 'test_data/shared_drive_member.json') call_command('loaddata', 'test_data/shared_drive_quota.json') call_command('loaddata', 'test_data/shared_drive.json') diff --git a/endorsement/migrations/0026_auto_20240426_1758.py b/endorsement/migrations/0026_auto_20240426_1758.py new file mode 100644 index 00000000..e71e1ef0 --- /dev/null +++ b/endorsement/migrations/0026_auto_20240426_1758.py @@ -0,0 +1,81 @@ +# Generated by Django 3.2.25 on 2024-04-27 00:58 + +from django.db import migrations, models +import django.db.models.deletion +import django_prometheus.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('endorsement', '0025_auto_20240228_1850'), + ] + + operations = [ + migrations.CreateModel( + name='ITBillSubscription', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('key_remote', models.SlugField(max_length=32, null=True, unique=True)), + ('name', models.CharField(max_length=128, null=True)), + ('url', models.CharField(max_length=256, null=True)), + ('state', models.SmallIntegerField(choices=[(0, 'Draft'), (1, 'Provisioning'), (2, 'Deployed'), (3, 'Deprovision'), (4, 'Closed'), (5, 'Cancelled')], default=0)), + ('query_priority', models.SmallIntegerField(choices=[(0, 'none'), (1, 'normal'), (2, 'high')], default=1)), + ('query_datetime', models.DateTimeField(null=True)), + ], + bases=(django_prometheus.models.ExportModelOperationsMixin('itbill_subscription'), models.Model), + ), + migrations.RenameField( + model_name='shareddrivequota', + old_name='org_unit', + new_name='org_unit_id', + ), + migrations.RenameField( + model_name='shareddrivequota', + old_name='name', + new_name='org_unit_name', + ), + migrations.RenameField( + model_name='shareddriverecord', + old_name='datetime_granted', + new_name='datetime_accepted', + ), + migrations.RemoveField( + model_name='shareddriverecord', + name='state', + ), + migrations.RemoveField( + model_name='shareddriverecord', + name='subscription_id', + ), + migrations.AddField( + model_name='shareddrive', + name='drive_usage', + field=models.IntegerField(null=True), + ), + migrations.AddField( + model_name='shareddrive', + name='query_date', + field=models.DateTimeField(null=True), + ), + migrations.CreateModel( + name='SharedDriveAcceptance', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('action', models.SmallIntegerField(choices=[(0, 'Accept'), (1, 'Revoke')], default=0)), + ('datetime_accepted', models.DateTimeField(auto_now_add=True)), + ('member', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='endorsement.member')), + ], + bases=(django_prometheus.models.ExportModelOperationsMixin('shared_drive_acceptance'), models.Model), + ), + migrations.AddField( + model_name='shareddriverecord', + name='acceptance', + field=models.ManyToManyField(blank=True, to='endorsement.SharedDriveAcceptance'), + ), + migrations.AddField( + model_name='shareddriverecord', + name='subscription', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='endorsement.itbillsubscription'), + ), + ] diff --git a/endorsement/models/shared_drive.py b/endorsement/models/shared_drive.py index 0a75a1d0..2b43ed05 100644 --- a/endorsement/models/shared_drive.py +++ b/endorsement/models/shared_drive.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 from django.db import models +from django.conf import settings from django.utils import timezone from django_prometheus.models import ExportModelOperationsMixin from endorsement.util.date import datetime_to_str @@ -9,12 +10,20 @@ import json +class MemberManager(models.Manager): + def get_member(self, name): + member, _ = self.get_or_create(name=name) + return member + + class Member(ExportModelOperationsMixin('member'), models.Model): name = models.CharField(max_length=128) def json_data(self): return self.name + objects = MemberManager() + def __str__(self): return json.dumps(self.json_data()) @@ -53,15 +62,22 @@ class SharedDriveQuota( """ SharedDriveQuota model represents a quota (tier) """ - org_unit = models.CharField(max_length=32) + SUBSIDIZED_QUOTA = 100 + + org_unit_id = models.CharField(max_length=32) + org_unit_name = models.CharField(max_length=64, null=True) quota_limit = models.IntegerField(null=True) - name = models.CharField(max_length=64, null=True) + + @property + def is_subsidized(self): + return self.drive_quota.quota_limit <= self.SUBSIDIZED_QUOTA def json_data(self): return { - "org_unit": self.org_unit, - "quota": self.quota_limit, - "name": self.name + "org_unit_id": self.org_unit_id, + "org_unit_name": self.org_unit_name, + "quota_limit": self.quota_limit, + "is_subsidized": self.quota_limit <= self.SUBSIDIZED_QUOTA } @@ -73,12 +89,15 @@ class SharedDrive(ExportModelOperationsMixin('shared_drive'), models.Model): drive_id = models.SlugField(max_length=32) 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) + query_date = models.DateTimeField(null=True) def json_data(self): return { "drive_id": self.drive_id, "drive_name": self.drive_name, + "drive_usage": self.drive_usage, "drive_quota": self.drive_quota.json_data(), "members": [m.json_data() for m in self.members.all()] } @@ -87,27 +106,45 @@ def __str__(self): return json.dumps(self.json_data()) -class SharedDriveRecordManager(models.Manager): - def get_shared_drives_for_netid(self, netid): - return self.filter( - shared_drive__members__member__name=netid, - is_deleted__isnull=True) - - -class SharedDriveRecord( - ExportModelOperationsMixin('shared_drive_record'), models.Model): +class SharedDriveAcceptance( + ExportModelOperationsMixin('shared_drive_acceptance'), models.Model): """ - SharedDriveRecord model represents the binding between a - shared drive and its corresponding subscription, and preserves - various states and timestamps to manage its lifecycle. + SharedDriveAcceptance model records each instance of a shared drive + record being accepted by a member. """ + ACCEPT = 0 + REVOKE = 1 + + ACCEPTANCE_ACTION_CHOICES = ( + (ACCEPT, "Accept"), + (REVOKE, "Revoke")) + + member = models.ForeignKey(Member, on_delete=models.PROTECT) + action = models.SmallIntegerField( + default=ACCEPT, choices=ACCEPTANCE_ACTION_CHOICES) + datetime_accepted = models.DateTimeField(auto_now_add=True) + + def json_data(self): + return { + "member": self.member.json_data(), + "action": self.ACCEPTANCE_ACTION_CHOICES[self.action][1], + "datetime_accepted": datetime_to_str(self.datetime_accepted) + } + + def __str__(self): + return json.dumps(self.json_data()) + + +class ITBillSubscription( + ExportModelOperationsMixin('itbill_subscription'), models.Model): MANAGER_ROLE = "organizer" - SUBSCRIPTION_DRAFT = "draft" - SUBSCRIPTION_PROVISIONING = "provisioning" - SUBSCRIPTION_DEPLOYED = "deployed" - SUBSCRIPTION_DEPROVISION = "deprovision" - SUBSCRIPTION_CLOSED = "closed" - SUBSCRIPTION_CANCELLED = "cancelled" + + SUBSCRIPTION_DRAFT = 0 + SUBSCRIPTION_PROVISIONING = 1 + SUBSCRIPTION_DEPLOYED = 2 + SUBSCRIPTION_DEPROVISION = 3 + SUBSCRIPTION_CLOSED = 4 + SUBSCRIPTION_CANCELLED = 5 SUBSCRIPTION_STATE_CHOICES = ( (SUBSCRIPTION_DRAFT, "Draft"), (SUBSCRIPTION_PROVISIONING, "Provisioning"), @@ -117,36 +154,128 @@ class SharedDriveRecord( (SUBSCRIPTION_CANCELLED, "Cancelled") ) + PRIORITY_NONE = 0 + PRIORITY_DEFAULT = 1 + PRIORITY_HIGH = 2 + PRIORITY_CHOICES = ( + (PRIORITY_NONE, 'none'), + (PRIORITY_DEFAULT, 'normal'), + (PRIORITY_HIGH, 'high') + ) + + key_remote = models.SlugField(max_length=32, unique=True, null=True) + name = models.CharField(max_length=128, null=True) + url = models.CharField(max_length=256, null=True) + state = models.SmallIntegerField( + default=SUBSCRIPTION_DRAFT, choices=SUBSCRIPTION_STATE_CHOICES) + query_priority = models.SmallIntegerField( + default=PRIORITY_DEFAULT, choices=PRIORITY_CHOICES) + query_datetime = models.DateTimeField(null=True) + + def save(self, *args, **kwargs): + if not self.key_remote: + self.key_remote = secrets.token_hex(16) + + if not self.name: + self.name = getattr( + settings, "ITBILL_SHARED_DRIVE_NAME_FORMAT", "{}").format( + self.shared_drive.drive_id), + + super(SharedDriveSubscription, self).save(*args, **kwargs) + + def json_data(self): + return { + "key_remote": self.key_remote, + "name": self.name, + "url": self.url, + "state": self.SUBSCRIPTION_STATE_CHOICES[self.state][1], + "query_priority": self.PRIORITY_CHOICES[ + self.query_priority][1], + "query_datetime": datetime_to_str( + self.query_datetime) + } + + def __str__(self): + return json.dumps(self.json_data()) + + +class SharedDriveRecordManager(models.Manager): + def get_shared_drives_for_netid(self, netid, drive_id=None): + parms = { + "shared_drive__members__member__name": netid, + "is_deleted__isnull": True} + + if drive_id: + parms["shared_drive__drive_id"] = drive_id + + return self.filter(**parms) + + def get_record_by_drive_id(self, drive_id): + return self.get( + shared_drive__drive_id=drive_id, is_deleted__isnull=True) + + +class SharedDriveRecord( + ExportModelOperationsMixin('shared_drive_record'), models.Model): + """ + SharedDriveRecord model represents the binding between a + shared drive and its corresponding subscription, and preserves + various states and timestamps to manage its lifecycle. + """ + shared_drive = models.ForeignKey( - SharedDrive, unique=True, on_delete=models.PROTECT) - subscription_key_remote = models.SlugField(max_length=32) - subscription_state = models.CharField( - max_length=16, choices=SUBSCRIPTION_STATE_CHOICES) + SharedDrive, on_delete=models.PROTECT) + subscription = models.ForeignKey( + ITBillSubscription, on_delete=models.PROTECT, null=True) acted_as = models.SlugField(max_length=32, null=True) datetime_created = models.DateTimeField(null=True) + datetime_accepted = models.DateTimeField(null=True) datetime_emailed = models.DateTimeField(null=True) datetime_notice_1_emailed = models.DateTimeField(null=True) datetime_notice_2_emailed = models.DateTimeField(null=True) datetime_notice_3_emailed = models.DateTimeField(null=True) datetime_notice_4_emailed = models.DateTimeField(null=True) - datetime_granted = models.DateTimeField(null=True) datetime_renewed = models.DateTimeField(null=True) datetime_expired = models.DateTimeField(null=True) + acceptance = models.ManyToManyField(SharedDriveAcceptance, blank=True) is_deleted = models.BooleanField(null=True) objects = SharedDriveRecordManager() - def save(self, *args, **kwargs): - if not self.subscription_key_remote: - self.subscription_key_remote = secrets.token_hex(16) + @property + def expiration_date(self): + lifespan = getattr(settings, 'SHARED_DRIVE_LIFESPAN_DAYS', 365) + claim_span = getattr(settings, 'SHARED_DRIVE_CLAIM_DAYS', 30) + initial_date = (self.datetime_renewed or self.datetime_accepted) + return (self.datetime_expired or + (initial_date + timezone.timedelta(days=lifespan)) if ( + initial_date) else ( + self.datetime_created + timezone.timedelta( + claim_span)) if ( + self.datetime_created) else None) + + def set_acceptance(self, member_netid, accept=True): + member = Member.objects.get_member(member_netid) + action = SharedDriveAcceptance.ACCEPT if ( + accept) else SharedDriveAcceptance.REVOKE + + acceptance = SharedDriveAcceptance.objects.create( + member=member, action=action) + self.acceptance.add(acceptance) + + if accept: + self.datetime_accepted = acceptance.datetime_accepted + else: + self.datetime_expired = acceptance.datetime_accepted + self.is_deleted = True - super(SharedDriveRecord, self).save(*args, **kwargs) + self.save() def json_data(self): return { - "shared_drive": self.shared_drive.json_data(), - "subscription_id": self.subscription_id, - "state": self.state, + "drive": self.shared_drive.json_data(), + "subscription": self.subscription.json_data() if ( + self.subscription) else None, "acted_as": self.acted_as, "datetime_created": datetime_to_str(self.datetime_created), "datetime_emailed": datetime_to_str(self.datetime_emailed), @@ -158,9 +287,10 @@ def json_data(self): self.datetime_notice_3_emailed), "datetime_notice_4_emailed": datetime_to_str( self.datetime_notice_4_emailed), - "datetime_granted": datetime_to_str(self.datetime_granted), + "datetime_accepted": datetime_to_str(self.datetime_accepted), "datetime_renewed": datetime_to_str(self.datetime_renewed), "datetime_expired": datetime_to_str(self.datetime_expired), + "datetime_expiration": datetime_to_str(self.expiration_date), "is_deleted": self.is_deleted } diff --git a/endorsement/static/endorsement/css/critical.scss b/endorsement/static/endorsement/css/critical.scss index 565b45e2..76ca3c8b 100644 --- a/endorsement/static/endorsement/css/critical.scss +++ b/endorsement/static/endorsement/css/critical.scss @@ -281,6 +281,16 @@ a { } } +.no-provisions-found { + height: 600px !important; + font-size: 1.4em; + font-weight: 600; +} + +.shared-drive-table-tall { + tbody { height: 600px !important; } +} + .endorsed-netids-table, .shared-netids-table, .office-access-table, .shared-drive-table { border: 1px solid #ddd; table { display: flex; flex-flow: column; width: 100%; margin-bottom: 0; } @@ -298,6 +308,9 @@ a { tr.new_access .access-status, tr.new_access .access-type, tr.new_access .access-action { background: #efffef !important; } + .shared-drive-action { width: 16em !important; } + .xshared-drive-quota { width: 5em; } + .shared-drive-est-usage { width: 7em; } .access-status p { padding-left: 1.2em; } .endorsed-netid, .access-mailbox { border-top: none; } tr.endorsee_row_odd { background: #f8f8f8; } @@ -309,18 +322,20 @@ a { } .endorsed-netid, .access-mailbox { width: 12rem; white-space: nowrap; } .endorsed-name, .access-mailbox-name { border-top: none; border-right: 1px solid #ddd; } - .endorsed-status-icon { width: 20px; } - .endorsed-status { + .endorsed-status-icon, { width: 20px; } + .shared-drive-status-icon { width: 24px; } + .endorsed-status, .shared-drive-status { width: 15rem; p { font-size: smaller; } } .endorsed-reason { width: 13rem; } - .endorsed-action { width: 12rem; white-space: nowrap; } + .endorsed-action, .shared-drive-action { width: 12rem; white-space: nowrap; } .aggregate_action { margin-right: 10px; } .endorsed-error { width: 51rem; span { display: block; color: red; } } + .subscription-status { font-size: smaller; color: green; } } .prt-data-popover { @@ -332,6 +347,16 @@ a { max-width: 380px; } +.popover-body:has(.manager-list) { + max-height: 160px; + overflow-y: scroll; +} + +div.toggle { + height: 100px; + overflow-y: auto; +} + span.mailbox, span.delegate { font-weight: 800; } diff --git a/endorsement/static/endorsement/js/handlebars-helpers.js b/endorsement/static/endorsement/js/handlebars-helpers.js index 0ed57f7f..a271e4b0 100644 --- a/endorsement/static/endorsement/js/handlebars-helpers.js +++ b/endorsement/static/endorsement/js/handlebars-helpers.js @@ -27,11 +27,30 @@ $(window.document).ready(function() { 'gt': function(a, b, options) { return (a > b) ? options.fn(this) : options.inverse(this); }, + 'lte': function(a, b, options) { + return (a <= b) ? options.fn(this) : options.inverse(this); + }, 'even': function(n, options) { return ((n % 2) === 0) ? options.fn(this) : options.inverse(this); }, + 'slice': function(a, start, end, options) { + if(!a || a.length == 0) + return options.inverse(this); + + var result = []; + for(var i = start; i < end && i < a.length; ++i) + result.push(options.fn(a[i])); + + return result.join(''); + }, + 'or': function(a, b, options) { + return (a || b) ? options.fn(this) : options.inverse(this); + }, 'ifAndNot': function(a, b, options) { return (a && !b) ? options.fn(this) : options.inverse(this); + }, + 'ifNotAndNot': function(a, b, options) { + return (!a && !b) ? options.fn(this) : options.inverse(this); } }); }); diff --git a/endorsement/static/endorsement/js/history.js b/endorsement/static/endorsement/js/history.js index d5cac90e..aa21e01b 100644 --- a/endorsement/static/endorsement/js/history.js +++ b/endorsement/static/endorsement/js/history.js @@ -22,6 +22,19 @@ var History = (function () { _clipPath = function (component) { var re = new RegExp('\/' + component + '$'); + if (window.location.pathname.match(re)) { + history.pushState({}, "", window.location.origin + '/' + + window.location.pathname.replace(re, '')); + } + }, + + _clearPath = function () { + var ids = [], + re; + + $('.tabs .tab').each(function() {ids.push($(this).attr('id'));}); + re = new RegExp('\/(' + ids.join('|') + ')$'); + if (window.location.pathname.match(re)) { history.pushState({}, "", window.location.origin + '/' + window.location.pathname.replace(re, '')); @@ -31,7 +44,8 @@ var History = (function () { return { replaceHash: _replaceHash, addPath: _addPath, - clipPath: _clipPath + clipPath: _clipPath, + clearPath: _clearPath }; }()); diff --git a/endorsement/static/endorsement/js/tab/endorsed.js b/endorsement/static/endorsement/js/tab/endorsed.js index 01a935df..7b9b4741 100644 --- a/endorsement/static/endorsement/js/tab/endorsed.js +++ b/endorsement/static/endorsement/js/tab/endorsed.js @@ -357,7 +357,7 @@ var ManageProvisionedServices = (function () { }, _adjustTabLocation = function (tab) { - History.clipPath('access'); + History.clearPath(); }, _showTab = function () { diff --git a/endorsement/static/endorsement/js/tab/google.js b/endorsement/static/endorsement/js/tab/google.js index 0345c143..c357ae0b 100644 --- a/endorsement/static/endorsement/js/tab/google.js +++ b/endorsement/static/endorsement/js/tab/google.js @@ -18,19 +18,77 @@ var ManageSharedDrives = (function () { // delegated events within our content $tab.on('endorse:drivesTabExposed', function (e) { - var $drives_table = $('.shared-drive-table', $panel); - _getSharedDrives(); }); - $panel.on('endorse:SharedDrivesSuccess', function (e, data) { + $panel.on('change', 'select#shared_drive_action', function (e) { + var $this = $(this), + action = $(this).val(), + drive_id = $this.attr('data-drive-id'), + itbill_url = $this.attr('data-itbill-url'); + + if (action === 'shared_drive_accept') { + _sharedDriveAcceptModal(drive_id, itbill_url); + } else if (action === 'shared_drive_change') { + _sharedDriveChangeModal(drive_id, itbill_url); + } else if (action === 'shared_drive_revoke') { + _sharedDriveRevokeModal(drive_id); + } + }).on('click', '#shared_drive_accept', function (e) { + _displayModal('#shared-drive-acceptance', { + drive_id: $(this).attr('data-drive-id')}); + }).on('click', '#confirm_itbill_visit', function (e) { + var $this = $(this), + itbill_url = $this.attr('data-itbill-url'), + drive_id = $this.attr('data-drive-id'); + + if (itbill_url) { + window.open(itbill_url, '_blank'); + } else { + _getITBill_URL(drive_id); + } + + _modalHide(); + }).on('click', '#confirm_shared_drive_acceptance', function (e) { + _setSharedDriveResponsibility($(this).attr('data-drive-id'), true); + }).on('click', '#confirm_shared_drive_revoke', function (e) { + _setSharedDriveResponsibility($(this).attr('data-drive-id'), false); + }).on('endorse:SharedDriveResponsibilityAccepted', function (e, data) { + _modalHide(); + _updateSharedDrivesDiplay(data.drives[0]); + }).on('endorse:SharedDriveResponsibilityAcceptedError', function (e, error) { + Notify.error('Sorry, but we cannot accept responsibility at this time: ' + error); + }).on('change', '#shared_drive_modal input', function () { + var $modal = $(this).closest('#shared_drive_modal'), + $accept_button = $('button.accept-button', $modal), + $checkboxes = $('input.accept-button-dependency', $modal), + checked = 0; + + $checkboxes.each(function () { + if (this.checked) + checked += 1; + }); + + if ($checkboxes.length === checked && $('.error').length === 0) { + $accept_button.removeAttr('disabled'); + } else { + $accept_button.attr('disabled', 'disabled'); + } + }).on('hidden.bs.modal', function () { + $('select[data-drive-id]', $content).val('select'); + }).on('endorse:SharedDrivesSuccess', function (e, data) { _displaySharedDrives(data.drives); }).on('endorse:SharedDrivesFailure', function (e, data) { _displaySharedDrivesFailure(data); + }).on('endorse:SharedDrivesITBIllURLSuccess', function (e, data) { + debugger + window.open(data.subscription.url, '_blank'); + }).on('endorse:SharedDrivesITBIllURLFailure', function (e, data) { + Notify.error('Sorry, but we cannot retrieve the ITBill URL at this time: ' + data); }); $(document).on('endorse:TabChange', function (e, data) { - if (data == 'access') { + if (data == 'drives') { _adjustTabLocation(); } }).on('endorse:HistoryChange', function (e) { @@ -43,21 +101,51 @@ var ManageSharedDrives = (function () { $content.html(template()); }, + _updateSharedDrivesDiplay = function (record) { + var source = $("#shared_drives_row_partial").html(), + template = Handlebars.compile(source), + $row = $('tr.shared-drive-row[data-drive-id="' + record.drive.drive_id + '"]'); + + _prepSharedDriveContext(record); + + $row.replaceWith(template(record)); + }, _displaySharedDrives = function (drives) { var source = $("#shared_drives_panel").html(), template = Handlebars.compile(source); + if (drives.length === 0) { + source = $("#no_shared_drives").html(); + template = Handlebars.compile(source), + $content.html(template()); + return; + } + + // convert drives data into context-appropriate values + $.each(drives, function (i, drive) { + _prepSharedDriveContext(drive); + }); + $content.html(template({drives: drives})); Scroll.init('.shared-drives-table'); $('[data-toggle="popover"]').popover(); }, + _prepSharedDriveContext = function (drive) { + var expiration = moment(drive.datetime_expiration), + days_remaining = expiration.diff(now, 'days'), + now = moment.utc(); + + drive.expiration_date = expiration.format('M/D/YYYY'); + drive.expiration_days = expiration.diff(now, 'days'); + drive.expiration_from_now = expiration.from(now); + }, _getSharedDrives = function() { var csrf_token = $("input[name=csrfmiddlewaretoken]")[0].value; _showLoading(); $.ajax({ - url: "/google/v1/shared_drives", + url: "/google/v1/shared_drive/", dataType: "JSON", type: "GET", accepts: {html: "application/json"}, @@ -72,8 +160,47 @@ var ManageSharedDrives = (function () { } }); }, + _setSharedDriveResponsibility = function (drive_id, accepted) { + var csrf_token = $("input[name=csrfmiddlewaretoken]")[0].value; + + $.ajax({ + url: "/google/v1/shared_drive/" + drive_id, + type: "PUT", + data: JSON.stringify({accept: accepted}), + contentType: "application/json", + accepts: {html: "application/json"}, + headers: { + "X-CSRFToken": csrf_token + }, + success: function(results) { + $panel.trigger('endorse:SharedDriveResponsibilityAccepted', [results]); + }, + error: function(xhr, status, error) { + $panel.trigger('endorse:SharedDriveResponsibilityAcceptedError', [error]); + } + }); + }, + _getITBill_URL = function (drive_id) { + var csrf_token = $("input[name=csrfmiddlewaretoken]")[0].value; + + $.ajax({ + url: "/google/v1/shared_drive/" + drive_id + "/itbill_url/", + dataType: "JSON", + type: "GET", + accepts: {html: "application/json"}, + headers: { + "X-CSRFToken": csrf_token + }, + success: function(results) { + $panel.trigger('endorse:SharedDrivesITBIllURLSuccess', [results]); + }, + error: function(xhr, status, error) { + $panel.trigger('endorse:SharedDrivesITBIllURLFailure', [error]); + } + }); + }, _displaySharedDrivesFailure = function (data) { - alert('Sorry, but we cannot retrieve shared drive information at the time: ' + data); + Notify.error('Sorry, but we cannot retrieve shared drive information at the time: ' + data); }, _adjustTabLocation = function (tab) { History.addPath('drives'); @@ -84,6 +211,38 @@ var ManageSharedDrives = (function () { $('.tabs .tabs-list li[data-tab="drives"] span').click(); },100); } + }, + _sharedDriveAcceptModal = function (drive_id, itbill_url) { + _displayModal('#shared-drive-acceptance', { + drive_id: drive_id, + itbill_url: itbill_url + }); + }, + _sharedDriveChangeModal = function (drive_id, itbill_url) { + _displayModal('#shared-drive-visit-itbill', { + drive_id: drive_id, + itbill_url: itbill_url + }); + }, + _sharedDriveRevokeModal = function (drive_id) { + _displayModal('#shared-drive-revoke', { + drive_id: drive_id, + }); + }, + _displayModal = function (template_id, context) { + var source = $(template_id).html(), + template = Handlebars.compile(source); + + $('#shared_drive_modal .modal-content', $content).html(template(context)); + _modalShow(); + }, + _modalShow = function () { + $('#shared_drive_modal', $content).modal('show'); + }, + _modalHide = function () { + $('#shared_drive_modal', $content).modal('hide'); + $('body').removeClass('modal-open'); + $('.modal-backdrop').remove(); }; return { diff --git a/endorsement/templates/handlebars/reasons_partial.html b/endorsement/templates/handlebars/reasons_partial.html index 8d01c9b6..33a4c032 100644 --- a/endorsement/templates/handlebars/reasons_partial.html +++ b/endorsement/templates/handlebars/reasons_partial.html @@ -6,7 +6,7 @@ {{else}}
- diff --git a/endorsement/templates/handlebars/tab/drives/google.html b/endorsement/templates/handlebars/tab/drives/google.html index c528f68a..77528e21 100644 --- a/endorsement/templates/handlebars/tab/drives/google.html +++ b/endorsement/templates/handlebars/tab/drives/google.html @@ -6,38 +6,92 @@

Manage the quota size of your shared drives.  Learn more.

-
+
- - - + + + + + + {{#each drives}} - {{> shared_drives_row_partial drive=drive }} + {{> shared_drives_row_partial . }} {{/each}}
MembersActionEst Usage QuotaManagers Action
+ - - {% endverbatim %} diff --git a/endorsement/templates/handlebars/tab/drives/modal_accept.html b/endorsement/templates/handlebars/tab/drives/modal_accept.html new file mode 100644 index 00000000..1c3912b3 --- /dev/null +++ b/endorsement/templates/handlebars/tab/drives/modal_accept.html @@ -0,0 +1,44 @@ +{% verbatim %} + +{% endverbatim %} diff --git a/endorsement/templates/handlebars/tab/drives/modal_itbill.html b/endorsement/templates/handlebars/tab/drives/modal_itbill.html new file mode 100644 index 00000000..10006309 --- /dev/null +++ b/endorsement/templates/handlebars/tab/drives/modal_itbill.html @@ -0,0 +1,27 @@ +{% verbatim %} + +{% endverbatim %} diff --git a/endorsement/templates/handlebars/tab/drives/modal_revoke.html b/endorsement/templates/handlebars/tab/drives/modal_revoke.html new file mode 100644 index 00000000..322bf761 --- /dev/null +++ b/endorsement/templates/handlebars/tab/drives/modal_revoke.html @@ -0,0 +1,36 @@ +{% verbatim %} + +{% endverbatim %} diff --git a/endorsement/templates/index.html b/endorsement/templates/index.html index 63bed01c..7f92b35e 100644 --- a/endorsement/templates/index.html +++ b/endorsement/templates/index.html @@ -12,9 +12,9 @@

Manage Provisions

    -
  • Services
  • +
  • Services for NetIds
  • +
  • Google Shared Drives
  • Elevated Access
  • -
  • Shared Drives
@@ -31,12 +31,12 @@

UW NetIDs owned by others

-
-
-
+
+
+
{% include "handlebars/reasons_partial.html" %} @@ -66,6 +66,9 @@

UW NetIDs owned by others

{% include "handlebars/tab/access/modal_update.html" %} {% include "handlebars/tab/drives/google.html" %} +{% include "handlebars/tab/drives/modal_accept.html" %} +{% include "handlebars/tab/drives/modal_revoke.html" %} +{% include "handlebars/tab/drives/modal_itbill.html" %} {% include "handlebars/expiring.html" %} {% include "handlebars/persistent_messages.html" %} diff --git a/endorsement/test/api/test_shared_drives.py b/endorsement/test/api/test_shared_drives.py index 04443dbe..fbcb2ea0 100644 --- a/endorsement/test/api/test_shared_drives.py +++ b/endorsement/test/api/test_shared_drives.py @@ -18,16 +18,17 @@ class TestSharedDrivesAPI(EndorsementApiTest): def test_shared_drives(self): self.set_user('jstaff') - url = reverse('shared_drives_api') + url = reverse('shared_drive_api') response = self.client.get(url) self.assertEquals(response.status_code, 200) data = json.loads(response.content) - self.assertEqual(len(data['drives']), 3) + self.assertEqual(len(data['drives']), 5) def test_no_shared_drives(self): - self.set_user('endorsee2') - url = reverse('shared_drives_api') + self.set_user('endorsee3') + url = reverse('shared_drive_api') response = self.client.get(url) self.assertEquals(response.status_code, 200) data = json.loads(response.content) self.assertEqual(len(data['drives']), 0) + diff --git a/endorsement/urls.py b/endorsement/urls.py index e7c49308..f976a871 100644 --- a/endorsement/urls.py +++ b/endorsement/urls.py @@ -28,6 +28,7 @@ from endorsement.views.api.office.resolve import ResolveRightsConflict from endorsement.views.api.office.validate import Validate as OfficeValidate from endorsement.views.api.google.shared_drive import SharedDrive +from endorsement.views.api.google.itbill import SharedDriveITBill from endorsement.views.api.notification import Notification @@ -75,7 +76,9 @@ re_path(r'^office/v1/access', OfficeAccess.as_view(), name='access_api'), re_path(r'^office/v1/validate', OfficeValidate.as_view(), name='office_validate_api'), - re_path(r'^google/v1/shared_drives', SharedDrive.as_view(), - name='shared_drives_api'), + re_path(r'^google/v1/shared_drive/(?P\S+)/itbill_url', + SharedDriveITBill.as_view(), name='shared_drive_itbill_api'), + re_path(r'^google/v1/shared_drive/(?P\S+)?', + SharedDrive.as_view(), name='shared_drive_api'), re_path(r'.*', page.index, name='home'), ] diff --git a/endorsement/util/log.py b/endorsement/util/log.py index f7a282c8..c01ab78b 100644 --- a/endorsement/util/log.py +++ b/endorsement/util/log.py @@ -55,3 +55,7 @@ def log_data_error_response(logger, ex): def log_data_not_found_response(logger): log_err_with_netid(logger, 'Data not found') + + +def log_bad_request_response(logger): + log_err_with_netid(logger, 'Data not found') diff --git a/endorsement/views/api/google/itbill.py b/endorsement/views/api/google/itbill.py new file mode 100644 index 00000000..23b80736 --- /dev/null +++ b/endorsement/views/api/google/itbill.py @@ -0,0 +1,59 @@ +# Copyright 2024 UW-IT, University of Washington +# SPDX-License-Identifier: Apache-2.0 + +from endorsement.models import SharedDriveRecord +from endorsement.dao.persistent_messages import get_persistent_messages +from endorsement.dao.itbill import initiate_subscription +from endorsement.views.rest_dispatch import ( + RESTDispatch, invalid_session, data_not_found, + invalid_endorser, bad_request, data_error) +from endorsement.exceptions import UnrecognizedUWNetid, InvalidNetID +import logging + + +logger = logging.getLogger(__name__) + + +class SharedDriveITBill(RESTDispatch): + """ + Manipulate ITBill resource SharedDriveRecords for provided netid + """ + def get(self, request, *args, **kwargs): + """ + Return SharedDriveRecord with suitable ITBill resource values + """ + try: + netid, acted_as = self._validate_user(request) + except UnrecognizedUWNetid: + return invalid_session(logger) + except InvalidNetID: + return invalid_endorser(logger) + + try: + drive_id = self.kwargs.get('drive_id') + drive = SharedDriveRecord.objects.get_shared_drives_for_netid( + netid, drive_id)[0] + + try: + + subscription = initiate_subscription(drive) + drive.subscription.url = subscription.url + drive.save() + except Exception as ex: + return data_error( + logger, "Error initiating subscription: {}".format(ex)) + + except SharedDriveRecord.DoesNotExist: + return data_not_found(logger) + + return self.json_response(self._drive_list(netid, drive_id)) + + def _drive_list(self, netid, drive_id=None): + drives = SharedDriveRecord.objects.get_shared_drives_for_netid( + netid, drive_id) + + return { + 'drives': [d.json_data() for d in drives], + 'messages': get_persistent_messages() + } + diff --git a/endorsement/views/api/google/shared_drive.py b/endorsement/views/api/google/shared_drive.py index 8cdabc6b..2e98fb7e 100644 --- a/endorsement/views/api/google/shared_drive.py +++ b/endorsement/views/api/google/shared_drive.py @@ -1,11 +1,11 @@ # Copyright 2024 UW-IT, University of Washington # SPDX-License-Identifier: Apache-2.0 -from userservice.user import UserService from endorsement.models import SharedDriveRecord from endorsement.dao.persistent_messages import get_persistent_messages from endorsement.views.rest_dispatch import ( - RESTDispatch, invalid_session, invalid_endorser) + RESTDispatch, invalid_session, data_not_found, + invalid_endorser, bad_request) from endorsement.exceptions import UnrecognizedUWNetid, InvalidNetID import logging @@ -15,9 +15,12 @@ class SharedDrive(RESTDispatch): """ - Return SharedDriveRecords for provided netid + Manipulate SharedDriveRecords for provided netid """ def get(self, request, *args, **kwargs): + """ + Return SharedDriveRecords for provided netid, all or by drive_id + """ try: netid, acted_as = self._validate_user(request) except UnrecognizedUWNetid: @@ -25,22 +28,41 @@ def get(self, request, *args, **kwargs): except InvalidNetID: return invalid_endorser(logger) - drives = SharedDriveRecord.objects.get_shared_drives_for_netid(netid) + drive_id = self.kwargs.get('drive_id') + return self.json_response(self._drive_list(netid, drive_id)) - return self.json_response({ - 'drives': [d.json_data() for d in drives], - 'messages': get_persistent_messages() - }) + def put(self, request, *args, **kwargs): + try: + netid, acted_as = self._validate_user(request) + except UnrecognizedUWNetid: + return invalid_session(logger) + except InvalidNetID: + return invalid_endorser(logger) - def _validate_user(self, request): - user_service = UserService() - netid = user_service.get_user() - if not netid: - raise UnrecognizedUWNetid() + try: + drive_id = self.kwargs['drive_id'] + drive = SharedDriveRecord.objects.get_shared_drives_for_netid( + netid, drive_id).get() + + accept = request.data.get('accept') + if isinstance(accept, bool): + drive.set_acceptance(netid, accept) + else: + return bad_request(logger) + + except SharedDriveRecord.DoesNotExist: + return data_not_found(logger) + except KeyError: + return bad_request(logger, "Missing accept parameter") + + return self.json_response(self._drive_list(netid, drive_id)) - original_user = user_service.get_original_user() - acted_as = None if (netid == original_user) else original_user - if acted_as and is_only_support_user(request): - raise InvalidNetID() + def _drive_list(self, netid, drive_id=None): + drives = SharedDriveRecord.objects.get_shared_drives_for_netid( + netid, drive_id) + + return { + 'drives': [d.json_data() for d in drives], + 'messages': get_persistent_messages() + } - return netid, acted_as diff --git a/endorsement/views/api/office/access.py b/endorsement/views/api/office/access.py index 5a2ca9e4..85789ae8 100644 --- a/endorsement/views/api/office/access.py +++ b/endorsement/views/api/office/access.py @@ -148,19 +148,6 @@ def _load_access_conflict_for_accessee(self, accessee): conflict = AccessRecordConflict.objects.filter(accessee=accessee) return [arc.json_data() for arc in conflict] - def _validate_user(self, request): - user_service = UserService() - netid = user_service.get_user() - if not netid: - raise UnrecognizedUWNetid() - - original_user = user_service.get_original_user() - acted_as = None if (netid == original_user) else original_user - if acted_as and is_only_support_user(request): - raise InvalidNetID() - - return netid, acted_as - def _is_valid_accessor(self, supported): return (supported.is_owner() and ( supported.is_shared_netid() diff --git a/endorsement/views/rest_dispatch.py b/endorsement/views/rest_dispatch.py index 09964513..fb67f210 100644 --- a/endorsement/views/rest_dispatch.py +++ b/endorsement/views/rest_dispatch.py @@ -3,15 +3,16 @@ from rest_framework.views import APIView from django.http import HttpResponse +from userservice.user import UserService +from endorsement.exceptions import UnrecognizedUWNetid, InvalidNetID import json import sys from restclients_core.exceptions import DataFailureException,\ InvalidNetID, InvalidRegID - - -from endorsement.util.log import log_exception,\ - log_data_not_found_response, log_data_error_response,\ - log_invalid_netid_response, log_invalid_endorser_response +from endorsement.util.log import ( + log_exception, log_data_not_found_response, log_data_error_response, + log_invalid_netid_response, log_invalid_endorser_response, + log_bad_request_response) class RESTDispatch(APIView): @@ -26,6 +27,19 @@ def json_response(self, content='', status=200): status=status, content_type='application/json') + def _validate_user(self, request): + user_service = UserService() + netid = user_service.get_user() + if not netid: + raise UnrecognizedUWNetid() + + original_user = user_service.get_original_user() + acted_as = None if (netid == original_user) else original_user + if acted_as and is_only_support_user(request): + raise InvalidNetID() + + return netid, acted_as + def handle_exception(logger, message, stack_trace): log_exception(logger, message, stack_trace.format_exc()) @@ -58,6 +72,11 @@ def data_not_found(logger): return RESTDispatch().error_response(404, "Data not found") +def bad_request(logger, msg="Bad request"): + log_bad_request_response(logger) + return RESTDispatch().error_response(400, msg) + + def data_error(logger, msg): log_data_error_response(logger, msg) return RESTDispatch().error_response( From c0fdf0900376dfdb3c774b9762b1078458191a99 Mon Sep 17 00:00:00 2001 From: mike seibel Date: Tue, 30 Apr 2024 09:03:14 -0700 Subject: [PATCH 09/26] mock dates --- .../management/commands/initialize_db.py | 20 +++++++++++++++++++ endorsement/models/shared_drive.py | 7 ++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/endorsement/management/commands/initialize_db.py b/endorsement/management/commands/initialize_db.py index cfdbe3b2..ae7a5061 100644 --- a/endorsement/management/commands/initialize_db.py +++ b/endorsement/management/commands/initialize_db.py @@ -4,6 +4,8 @@ from django.core.management.base import BaseCommand from django.core.management import call_command +from endorsement.models import SharedDriveRecord +from datetime import datetime, timezone, timedelta class Command(BaseCommand): @@ -21,3 +23,21 @@ def handle(self, *args, **options): call_command('loaddata', 'test_data/shared_drive_quota.json') call_command('loaddata', 'test_data/shared_drive.json') call_command('loaddata', 'test_data/shared_drive_record.json') + + # adjust dates relative to today + now = datetime.now(timezone.utc) + sdr = SharedDriveRecord.objects.get(pk=1) + sdr.datetime_accepted = now - timedelta(days=60) + sdr.save() + + sdr = SharedDriveRecord.objects.get(pk=2) + sdr.datetime_accepted = now - timedelta(days=330) + sdr.save() + + sdr = SharedDriveRecord.objects.get(pk=3) + sdr.datetime_accepted = now - timedelta(days=360) + sdr.save() + + sdr = SharedDriveRecord.objects.get(pk=5) + sdr.datetime_accepted = now - timedelta(days=88) + sdr.save() diff --git a/endorsement/models/shared_drive.py b/endorsement/models/shared_drive.py index 2b43ed05..d23655ff 100644 --- a/endorsement/models/shared_drive.py +++ b/endorsement/models/shared_drive.py @@ -17,6 +17,9 @@ def get_member(self, name): class Member(ExportModelOperationsMixin('member'), models.Model): + """ + Member represents user associated with a shared drive + """ name = models.CharField(max_length=128) def json_data(self): @@ -61,6 +64,8 @@ class SharedDriveQuota( ExportModelOperationsMixin('shared_drive_tier'), models.Model): """ SharedDriveQuota model represents a quota (tier) + + Quota limit is represnted as an integer number of Gigabytes """ SUBSIDIZED_QUOTA = 100 @@ -110,7 +115,7 @@ class SharedDriveAcceptance( ExportModelOperationsMixin('shared_drive_acceptance'), models.Model): """ SharedDriveAcceptance model records each instance of a shared drive - record being accepted by a member. + record being accepted or revoked by a shared drive manager. """ ACCEPT = 0 REVOKE = 1 From 23d6bdb1505792767f723e1b57d0e4ceaaeb21cb Mon Sep 17 00:00:00 2001 From: mike seibel Date: Fri, 3 May 2024 17:34:20 -0700 Subject: [PATCH 10/26] itbill payload integration --- docker-compose.yml | 3 + docker/settings.py | 4 +- endorsement/dao/itbill.py | 53 ++- endorsement/dao/notification.py | 434 +----------------- .../fixtures/test_data/itbill_provision.json | 18 + .../fixtures/test_data/itbill_quantity.json | 24 + .../test_data/itbill_subscription.json | 4 - endorsement/fixtures/test_data/member.json | 2 +- .../fixtures/test_data/shared_drive.json | 8 +- .../test_data/shared_drive_member.json | 2 +- .../test_data/shared_drive_record.json | 2 +- .../management/commands/initialize_db.py | 9 + .../management/commands/notify_endorsers.py | 2 +- .../commands/notify_provisionees.py | 3 +- .../commands/notify_provisioners.py | 17 + .../commands/reconcile_shared_drives.py | 28 ++ .../migrations/0027_auto_20240503_1436.py | 59 +++ endorsement/models/__init__.py | 1 + endorsement/models/itbill.py | 168 +++++++ endorsement/models/shared_drive.py | 94 +--- endorsement/notifications/access.py | 57 +++ endorsement/notifications/endorsement.py | 395 ++++++++++++++++ endorsement/provisioner_validation.py | 2 +- .../itbill/file/api/x_unowr_subscriptn/POST | 0 .../api/x_unowr_subscriptn/subscription/.POST | 1 + .../subscription/.http-headers | 4 + .../key_remote/GSD_test_test_test_123_rk | 106 +++++ endorsement/shared_validation.py | 2 +- .../static/endorsement/css/critical.scss | 5 +- .../static/endorsement/js/tab/google.js | 93 +++- .../static/endorsement/js/tab/office.js | 5 +- .../email/{ => access}/accessor.html | 0 .../templates/email/{ => access}/accessor.txt | 0 .../email/{ => endorsement}/endorsee.html | 0 .../email/{ => endorsement}/endorsee.txt | 0 .../email/{ => endorsement}/endorser.html | 0 .../email/{ => endorsement}/endorser.txt | 0 .../{ => endorsement}/invalid_endorser.html | 0 .../{ => endorsement}/invalid_endorser.txt | 0 .../notice_new_shared_warning.html | 0 .../notice_new_shared_warning.txt | 0 .../{ => endorsement}/notice_warning.html | 0 .../{ => endorsement}/notice_warning.txt | 0 .../notice_warning_final.html | 0 .../notice_warning_final.txt | 0 .../handlebars/tab/access/office.html | 4 +- .../handlebars/tab/drives/google.html | 14 +- endorsement/test/api/test_shared_drives.py | 5 +- endorsement/test/dao/test_itbill.py | 50 ++ endorsement/test/dao/test_notification.py | 4 +- endorsement/test/notifications/test_access.py | 51 ++ .../test/notifications/test_endorsement.py | 133 ++++++ endorsement/test/test_expiration_warnings.py | 2 +- endorsement/urls.py | 8 +- endorsement/util/date.py | 5 + endorsement/util/itbill/__init__.py | 0 endorsement/util/itbill/shared_drive.py | 14 + endorsement/util/key_remote.py | 8 + endorsement/views/api/google/itbill.py | 57 ++- endorsement/views/api/google/shared_drive.py | 17 +- endorsement/views/api/notification.py | 5 +- 61 files changed, 1372 insertions(+), 610 deletions(-) create mode 100644 endorsement/fixtures/test_data/itbill_provision.json create mode 100644 endorsement/fixtures/test_data/itbill_quantity.json create mode 100644 endorsement/management/commands/notify_provisioners.py create mode 100644 endorsement/management/commands/reconcile_shared_drives.py create mode 100644 endorsement/migrations/0027_auto_20240503_1436.py create mode 100644 endorsement/models/itbill.py create mode 100644 endorsement/notifications/access.py create mode 100644 endorsement/notifications/endorsement.py create mode 100644 endorsement/resources/itbill/file/api/x_unowr_subscriptn/POST create mode 100644 endorsement/resources/itbill/file/api/x_unowr_subscriptn/subscription/.POST create mode 100644 endorsement/resources/itbill/file/api/x_unowr_subscriptn/subscription/.http-headers create mode 100644 endorsement/resources/itbill/file/api/x_unowr_subscriptn/subscription/key_remote/GSD_test_test_test_123_rk rename endorsement/templates/email/{ => access}/accessor.html (100%) rename endorsement/templates/email/{ => access}/accessor.txt (100%) rename endorsement/templates/email/{ => endorsement}/endorsee.html (100%) rename endorsement/templates/email/{ => endorsement}/endorsee.txt (100%) rename endorsement/templates/email/{ => endorsement}/endorser.html (100%) rename endorsement/templates/email/{ => endorsement}/endorser.txt (100%) rename endorsement/templates/email/{ => endorsement}/invalid_endorser.html (100%) rename endorsement/templates/email/{ => endorsement}/invalid_endorser.txt (100%) rename endorsement/templates/email/{ => endorsement}/notice_new_shared_warning.html (100%) rename endorsement/templates/email/{ => endorsement}/notice_new_shared_warning.txt (100%) rename endorsement/templates/email/{ => endorsement}/notice_warning.html (100%) rename endorsement/templates/email/{ => endorsement}/notice_warning.txt (100%) rename endorsement/templates/email/{ => endorsement}/notice_warning_final.html (100%) rename endorsement/templates/email/{ => endorsement}/notice_warning_final.txt (100%) create mode 100644 endorsement/test/dao/test_itbill.py create mode 100644 endorsement/test/notifications/test_access.py create mode 100644 endorsement/test/notifications/test_endorsement.py create mode 100644 endorsement/util/itbill/__init__.py create mode 100644 endorsement/util/itbill/shared_drive.py create mode 100644 endorsement/util/key_remote.py diff --git a/docker-compose.yml b/docker-compose.yml index 045fc6a4..38ada88d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,9 @@ services: PORT: 8000 AUTH: SAML_MOCK # ENDORSEMENT_SERVICES: "google, office365" + ITBILL_FORM_URL_BASE: https://uwdev.service-now.com/sp + ITBILL_FORM_URL_BASE_ID: sc_cat_item + ITBILL_SHARED_DRIVE_PRODUCT_SYS_ID: 7078586b2f6cb076cad75ae9aab3ea05 restart: always container_name: app-provision build: diff --git a/docker/settings.py b/docker/settings.py index 45283cce..e4b94c31 100644 --- a/docker/settings.py +++ b/docker/settings.py @@ -87,11 +87,13 @@ RESTCLIENTS_ITBILL_HOST=os.getenv('ITBILL_HOST') RESTCLIENTS_ITBILL_BASIC_AUTH=os.getenv('ITBILL_BASIC_AUTH') +ITBILL_SHARED_DRIVE_SUBSIDIZED_QUOTA=100 ITBILL_SHARED_DRIVE_NAME_FORMAT="GSD_{}" ITBILL_SHARED_DRIVE_PRODUCT_SYS_ID=os.getenv( 'ITBILL_SHARED_DRIVE_PRODUCT_SYS_ID') ITBILL_FORM_URL_BASE=os.getenv('ITBILL_FORM_URL_BASE') -ITBILL_FORM_SYS_ID=os.getenv('ITBILL_FORM_SYS_ID') +ITBILL_FORM_URL_BASE_ID=os.getenv('ITBILL_FORM_URL_BASE_ID') +ITBILL_FORM_URL_SYS_ID=os.getenv('ITBILL_FORM_URL_SYS_ID') PROVISION_ADMIN_GROUP = 'u_acadev_provision_admin' PROVISION_SUPPORT_GROUP = 'u_acadev_provision_support' diff --git a/endorsement/dao/itbill.py b/endorsement/dao/itbill.py index f4e02876..b6abfddc 100644 --- a/endorsement/dao/itbill.py +++ b/endorsement/dao/itbill.py @@ -2,10 +2,12 @@ # SPDX-License-Identifier: Apache-2.0 from django.conf import settings -from endorsement.models import SharedDrive, SharedDriveRecord -from endorsement.exceptions import ( - SharedDriveRecordExists, ITBillSubscriptionNotFound) from userservice.user import UserService +from endorsement.models import SharedDriveRecord, ITBillSubscription +from endorsement.util.itbill.shared_drive import ( + subscription_name, product_sys_id) +from endorsement.exceptions import ITBillSubscriptionNotFound +from restclients_core.exceptions import DataFailureException from uw_itbill.subscription import Subscription import logging @@ -14,36 +16,57 @@ def initiate_subscription(shared_drive_record): + if shared_drive_record.subscription: + return + try: user_service = UserService() + itbill_subscription = ITBillSubscription() + membership = shared_drive_record.shared_drive.members.all() - new_subscription = { - "name": shared_drive_record.subscription.name, - "key_remote": shared_drive_record.subscription.key_remote, - "product": getattr(settings, "ITBILL_SHARED_DRIVE_PRODUCT_SYS_ID"), + data = { + "name": subscription_name(shared_drive_record), + "key_remote": itbill_subscription.key_remote, + "product": product_sys_id(), "start_date": "", "contact": user_service.get_user(), "contacts_additional": ','.join([ - member.name for member in shared_drive.members.all()]), - "lifecycle_state": SharedDriveRecord.STATE_DRAFT, + member.member.name for member in membership]), + "lifecycle_state": ITBillSubscription.SUBSCRIPTION_STATE_CHOICES[ + ITBillSubscription.SUBSCRIPTION_DRAFT][1], "work_notes": "Subscription initiated by Provision Request Tool", } - return Subscription().create_subscription(new_subscription) - except SharedDriveRecord.ValidationError as ex: - raise SharedDriveRecordExists(shared_drive.drive_id) + Subscription().create_subscription(data) + itbill_subscription.save() + shared_drive_record.subscription = itbill_subscription + shared_drive_record.save() except Exception as ex: - logger.exception("Subscription: for {}: {}".format(subscription, ex)) + raise ex return None +def refresh_subscription(member_netid, drive_id): + record = SharedDriveRecord.objects.get_member_drives( + member_netid, drive_id).get() + + load_itbill_subscription(record) + + def get_subscription_by_key_remote(key_remote): try: - return Subscription().get_subscription_by_key_remote( - subscription.key_remote) + return Subscription().get_subscription_by_key_remote(key_remote) except DataFailureException as ex: if ex.status == 404: raise ITBillSubscriptionNotFound(key_remote) raise + + +def load_itbill_subscription(record): + """ + Update the subscription record with the latest ITBill data + """ + record.update_subscription( + get_subscription_by_key_remote(record.subscription.key_remote)) diff --git a/endorsement/dao/notification.py b/endorsement/dao/notification.py index 2d8c2b11..e1e10702 100644 --- a/endorsement/dao/notification.py +++ b/endorsement/dao/notification.py @@ -3,20 +3,7 @@ from django.conf import settings from django.core.mail import EmailMultiAlternatives -from django.template import loader, Template, Context -from django.utils import timezone -from endorsement.models import EndorsementRecord, AccessRecord -from endorsement.services import ( - endorsement_services, get_endorsement_service, service_names) -from endorsement.dao.user import get_endorsee_email_model -from endorsement.dao import display_datetime -from endorsement.dao.endorse import clear_endorsement -from endorsement.dao.accessors import get_accessor_email from endorsement.exceptions import EmailFailureException -from endorsement.policy import endorsements_to_warn -from endorsement.util.email import uw_email_address -from endorsement.util.string import listed_list -import re import logging @@ -26,427 +13,8 @@ "provision-noreply@uw.edu") -# This function is a monument to technical debt and intended -# to blow up tests as soon as the first endorsemnnt service lifecycle -# definition strays from current common set of values -def confirm_common_lifecyle_values(): - lifetime = None - warnings = None - - for service in endorsement_services(): - if lifetime is None: - lifetime = service.endorsement_lifetime - else: - if lifetime != service.endorsement_lifetime: - raise Exception( - "Messaging does not support mixed service lifetimes") - - if warnings is None: - warnings = [ - service.endorsement_expiration_warning(1), - service.endorsement_expiration_warning(2), - service.endorsement_expiration_warning(3), - service.endorsement_expiration_warning(4), - ] - elif (warnings[0] != service.endorsement_expiration_warning(1) or - warnings[1] != service.endorsement_expiration_warning(2) or - warnings[2] != service.endorsement_expiration_warning(3) or - warnings[3] != service.endorsement_expiration_warning(4)): - raise Exception( - "Messaging does not support mismatched service warning spans") - - -def _create_endorsee_message(endorser): - sent_date = timezone.now() - params = { - "endorser_netid": endorser['netid'], - "endorser_name": endorser['display_name'], - "endorsed_date": display_datetime(sent_date), - "services": endorser['services'] - } - - names = [] - for k, v in endorser['services'].items(): - names.append(v['name']) - for service in endorsement_services(): - if k == service.service_name: - v['service_link'] = service.service_link - - subject = "Action Required: Your new access to {0}".format( - listed_list(names)) - - text_template = "email/endorsee.txt" - html_template = "email/endorsee.html" - - return (subject, - loader.render_to_string(text_template, params), - loader.render_to_string(html_template, params)) - - -def get_unendorsed_unnotified(): - endorsements = {} - for er in EndorsementRecord.objects.get_unendorsed_unnotified(): - try: - email = get_endorsee_email_model(er.endorsee, er.endorser).email - except Exception as ex: - logger.error("Notify get email failed: {0}, netid: {1}" - .format(ex, er.endorsee)) - continue - - if email not in endorsements: - endorsements[email] = { - 'endorsers': {} - } - - if (er.endorser.netid not in endorsements[email]['endorsers']): - endorsements[email]['endorsers'][er.endorser.netid] = { - 'netid': er.endorser.netid, - 'display_name': er.endorser.display_name, - 'services': {} - } - - for service in endorsement_services(): - if er.category_code == service.category_code: - endorsements[email]['endorsers'][ - er.endorser.netid]['services'][service.service_name] = { - 'code': service.category_code, - 'name': service.category_name, - 'id': er.id, - 'accept_url': er.accept_url() - } - break - - return endorsements - - -def notify_endorsees(): - sender = EMAIL_REPLY_ADDRESS - endorsements = get_unendorsed_unnotified() - - for email, endorsers in endorsements.items(): - for endorser_netid, endorsers in endorsers['endorsers'].items(): - (subject, text_body, html_body) = _create_endorsee_message( - endorsers) - try: - send_email( - sender, [email], subject, text_body, html_body, "Endorsee") - for service, data in endorsers['services'].items(): - EndorsementRecord.objects.emailed(data['id']) - except EmailFailureException as ex: - pass - - -def _create_endorser_message(endorsed): - sent_date = timezone.now() - params = { - "endorsed_date": display_datetime(sent_date), - "endorsed": {} - } - - unique = {} - for svc, endorsee_list in endorsed.items(): - for e in endorsee_list: - service_name = e["name"] - netid = e["netid"] - unique[netid] = 1 - if service_name in params["endorsed"]: - params["endorsed"][service_name]['netids'].append(netid) - else: - params["endorsed"][service_name] = { - 'svc': svc, - 'netids': [netid] - } - - params["endorsees"] = list(unique.keys()) - - services = [] - for s, v in params["endorsed"].items(): - services.append(s) - for service in endorsement_services(): - if v['svc'] == service.service_name: - v['service_link'] = service.service_link - - subject = "Shared NetID access to {}".format(listed_list(services)) - text_template = "email/endorser.txt" - html_template = "email/endorser.html" - - return (subject, - loader.render_to_string(text_template, params), - loader.render_to_string(html_template, params)) - - -def get_endorsed_unnotified(): - return _get_endorsed_unnotified( - EndorsementRecord.objects.get_endorsed_unnotified()) - - -def _get_endorsed_unnotified(endorsed_unnotified): - endorsements = {} - for er in endorsed_unnotified: - # rely on @u forwarding for valid address - email = uw_email_address(er.endorser.netid) - if email not in endorsements: - endorsements[email] = {} - - data = { - 'netid': er.endorsee.netid, - 'name': '', - 'id': er.id - } - - for service in endorsement_services(): - if er.category_code == service.category_code: - data['name'] = service.category_name - if service.service_name in endorsements[email]: - endorsements[email][service.service_name].append(data) - else: - endorsements[email][service.service_name] = [data] - - break - - return endorsements - - -def notify_endorsers(): - sender = EMAIL_REPLY_ADDRESS - endorsements = get_endorsed_unnotified() - for email, endorsed in endorsements.items(): - (subject, text_body, html_body) = _create_endorser_message(endorsed) - try: - send_email( - sender, [email], subject, text_body, html_body, "Endorser") - for svc in [s.service_name for s in endorsement_services()]: - if svc in endorsed: - for id in [x['id'] for x in endorsed[svc]]: - EndorsementRecord.objects.emailed(id) - except EmailFailureException as ex: - pass - - -def _create_invalid_endorser_message(endorsements): - params = { - "endorsed": {} - } - - services = {} - for e in endorsements: - params['endorser_netid'] = e.endorser.netid - for service in endorsement_services(): - if e.category_code == service.category_code: - services[service.category_name] = 1 - try: - params['endorsed'][e.endorsee.netid].append( - service.category_name) - except KeyError: - params['endorsed'][e.endorsee.netid] = [ - service.category_name] - - params['services'] = list(services.keys()) - - subject = "Action Required: Provisioned UW-IT services will expire soon" - - text_template = "email/invalid_endorser.txt" - html_template = "email/invalid_endorser.html" - - return (subject, - loader.render_to_string(text_template, params), - loader.render_to_string(html_template, params)) - - -def notify_invalid_endorser(invalid_endorsements): - if not (invalid_endorsements and len(invalid_endorsements) > 0): - return - - sent_date = timezone.now() - email = uw_email_address(invalid_endorsements[0].endorser.netid) +def send_notification(recipients, subject, text_body, html_body, kind): sender = EMAIL_REPLY_ADDRESS - (subject, text_body, html_body) = _create_invalid_endorser_message( - invalid_endorsements) - - try: - send_email( - sender, [email], subject, text_body, html_body, "Invalid endorser") - invalid_endorsements[0].endorser.datetime_emailed = sent_date - invalid_endorsements[0].endorser.save() - for endorsement in invalid_endorsements: - clear_endorsement(endorsement) - except EmailFailureException as ex: - pass - - -def _create_expire_notice_message(notice_level, lifetime, endorsed): - category_codes = list(set([e.category_code for e in endorsed])) - services = [get_endorsement_service(c) for c in category_codes] - context = { - 'endorser': endorsed[0].endorser, - 'lifetime': lifetime, - 'notice_time': services[0].endorsement_expiration_warning( - notice_level), - 'expiring': endorsed, - 'expiring_count': len(set(e.endorsee.netid for e in endorsed)), - 'impacts': [] - } - - for impact in list(set([s.service_renewal_statement for s in services])): - m = re.match( - r'.*{{[\s]*(service_names((_([0-9a-z]+))+))[\s]*}}.*', impact) - if m: - names = [] - for n in re.findall(r'_([^_]*)', m.group(2)): - impact_service = get_endorsement_service(n) - if impact_service.category_code in category_codes: - names.append(impact_service.category_name) - - impact_context = { - m.group(1): service_names(service_list=names), - 'service_names_count': len(names) - } - - template = Template(impact) - impact_statement = template.render(Context(impact_context)) - else: - impact_statement = impact - - context['impacts'].append(impact_statement) - - if notice_level < 4: - subject = ("Action Required: Provisioned UW-IT " - "services will expire soon") - text_template = "email/notice_warning.txt" - html_template = "email/notice_warning.html" - else: - subject = "Action Required: Provisioned UW-IT services have expired" - text_template = "email/notice_warning_final.txt" - html_template = "email/notice_warning_final.html" - - return (subject, - loader.render_to_string(text_template, context), - loader.render_to_string(html_template, context)) - - -def warn_endorsers(notice_level): - confirm_common_lifecyle_values() - - endorsements = endorsements_to_warn(notice_level) - - lifetime = endorsement_services()[0].endorsement_lifetime - - if len(endorsements): - endorsers = {} - for e in endorsements: - endorsers[e.endorser.id] = 1 - - for endorser in endorsers.keys(): - endorsed = endorsements.filter(endorser=endorser) - - sent_date = timezone.now() - email = uw_email_address(endorsed[0].endorser.netid) - sender = EMAIL_REPLY_ADDRESS - - try: - (subject, - text_body, - html_body) = _create_expire_notice_message( - notice_level, lifetime, endorsed) - send_email( - sender, [email], subject, text_body, html_body, - "Invalid endorser") - - sent_date = { - 'datetime_notice_{}_emailed'.format( - notice_level): timezone.now() - } - endorsed.update(**sent_date) - except EmailFailureException as ex: - pass - - -def _create_warn_shared_owner_message(owner_netid, endorsements): - service = get_endorsement_service(endorsements[0].category_code) - context = { - 'endorser': owner_netid, - 'lifetime': service.endorsement_lifetime, - 'notice_time': service.endorsement_expiration_warning(1), - 'expiring': endorsements, - 'expiring_count': len(endorsements) - } - - subject = "{0}{1}".format( - "Action Required: UW-IT services provisioned for Shared ", - "UW NetIDs you own have expired") - text_template = "email/notice_new_shared_warning.txt" - html_template = "email/notice_new_shared_warning.html" - - return (subject, - loader.render_to_string(text_template, context), - loader.render_to_string(html_template, context)) - - -def warn_new_shared_netid_owner(new_owner, endorsements): - if not (endorsements and len(endorsements) > 0): - return - - confirm_common_lifecyle_values() - - sent_date = timezone.now() - email = uw_email_address(new_owner.netid) - sender = EMAIL_REPLY_ADDRESS - (subject, text_body, html_body) = _create_warn_shared_owner_message( - new_owner, endorsements) - - send_email( - sender, [email], subject, text_body, html_body, - "Shared Netid Owner") - - for endorsement in endorsements: - endorsement.datetime_notice_1_emailed = sent_date - endorsement.save() - - -def notify_accessors(): - sender = EMAIL_REPLY_ADDRESS - - for ar in AccessRecord.objects.get_unnotified_accessors(): - try: - emails = get_accessor_email(ar) - - (subject, text_body, html_body) = _create_accessor_message( - ar, emails) - - recipients = [] - for addr in emails: - recipients.append(addr['email']) - - send_email( - sender, recipients, subject, - text_body, html_body, "Accessor") - - ar.emailed() - except EmailFailureException as ex: - logger.error("Accessor notification failed: {}".format(ex)) - except Exception as ex: - logger.error("Notify get email failed: {0}, netid: {1}" - .format(ex, ar.accessor)) - - -def _create_accessor_message(access_record, emails): - subject = "Delegated Mailbox Access to {}".format( - access_record.accessee.netid) - - params = { - 'record': access_record, - 'emails': emails - } - - text_template = "email/accessor.txt" - html_template = "email/accessor.html" - - return (subject, - loader.render_to_string(text_template, params), - loader.render_to_string(html_template, params)) - - -def send_email(sender, recipients, subject, text_body, html_body, kind): message = EmailMultiAlternatives( subject, text_body, sender, recipients, headers={'Precedence': 'bulk'}) message.attach_alternative(html_body, "text/html") diff --git a/endorsement/fixtures/test_data/itbill_provision.json b/endorsement/fixtures/test_data/itbill_provision.json new file mode 100644 index 00000000..85fddc7e --- /dev/null +++ b/endorsement/fixtures/test_data/itbill_provision.json @@ -0,0 +1,18 @@ +[ + { + "model": "endorsement.itbillprovision", + "pk": 1, + "fields": { + "subscription": 1, + "current_quantity": 2 + } + }, + { + "model": "endorsement.itbillprovision", + "pk": 2, + "fields": { + "subscription": 2, + "current_quantity": 5 + } + } +] diff --git a/endorsement/fixtures/test_data/itbill_quantity.json b/endorsement/fixtures/test_data/itbill_quantity.json new file mode 100644 index 00000000..038b71c4 --- /dev/null +++ b/endorsement/fixtures/test_data/itbill_quantity.json @@ -0,0 +1,24 @@ +[ + { + "model": "endorsement.itbillquantity", + "pk": 1, + "fields": { + "provision": 1, + "quantity": 2, + "start_date": "2024-01-01", + "end_date": "2024-12-31", + "stage": null + } + }, + { + "model": "endorsement.itbillquantity", + "pk": 2, + "fields": { + "provision": 2, + "quantity": 5, + "start_date": null, + "end_date": null, + "stage": null + } + } +] diff --git a/endorsement/fixtures/test_data/itbill_subscription.json b/endorsement/fixtures/test_data/itbill_subscription.json index a9dce7f0..42d918d6 100644 --- a/endorsement/fixtures/test_data/itbill_subscription.json +++ b/endorsement/fixtures/test_data/itbill_subscription.json @@ -4,9 +4,7 @@ "pk": 1, "fields": { "key_remote": "C11F79E44DA766B7", - "name": "GSD_C11F79E44DA766B7", "state": 1, - "url": "https://uwdev.service-now.com/sp?id=sc_cat_item&sys_id=dc46f1711b418290cc990dc0604bcbcc&remote_key=GSD_test_test_test_123_rk&shared_drive=my%20drive", "query_priority": 0, "query_datetime": null } @@ -16,9 +14,7 @@ "pk": 2, "fields": { "key_remote": "GSD_test_test_test_123_rk", - "name": "my drive", "state": 1, - "url": "https://uwdev.service-now.com/sp?id=sc_cat_item&sys_id=dc46f1711b418290cc990dc0604bcbcc&remote_key=GSD_test_test_test_123_rk&shared_drive=my%20drive", "query_priority": 0, "query_datetime": null } diff --git a/endorsement/fixtures/test_data/member.json b/endorsement/fixtures/test_data/member.json index 08453e1c..ab79fdba 100644 --- a/endorsement/fixtures/test_data/member.json +++ b/endorsement/fixtures/test_data/member.json @@ -27,7 +27,7 @@ "model": "endorsement.member", "pk": 6, "fields": { - "name": "boogaloo33@gmail.com"}}, + "name": "jinter"}}, { "model": "endorsement.member", "pk": 7, diff --git a/endorsement/fixtures/test_data/shared_drive.json b/endorsement/fixtures/test_data/shared_drive.json index 547918b0..d653186a 100644 --- a/endorsement/fixtures/test_data/shared_drive.json +++ b/endorsement/fixtures/test_data/shared_drive.json @@ -7,7 +7,7 @@ "drive_name": "Subsidized quota", "drive_usage": 39, "drive_quota": 1, - "members": [1, 2, 3, 4, 5] + "members": [1, 2, 4, 5, 10] } }, { @@ -26,7 +26,7 @@ "pk": 3, "fields": { "drive_id": "IRDXB54TWF3OY8MVC9J", - "drive_name": "Lg quota, with subscription, expiring soon", + "drive_name": "Lg quota, expiring soon", "drive_usage": 218, "drive_quota": 3, "members": [5] @@ -48,10 +48,10 @@ "pk": 5, "fields": { "drive_id": "9F97529B38CF46F336C7408", - "drive_name": "Lg quota with subscription", + "drive_name": "Lg quota with subscription (mock)", "drive_usage": 283, "drive_quota": 4, - "members": [1, 2, 3, 4, 5, 7, 8, 10, 11] + "members": [1, 2, 4, 5, 7, 8, 9, 10, 11] } } ] diff --git a/endorsement/fixtures/test_data/shared_drive_member.json b/endorsement/fixtures/test_data/shared_drive_member.json index e3339714..86f894b7 100644 --- a/endorsement/fixtures/test_data/shared_drive_member.json +++ b/endorsement/fixtures/test_data/shared_drive_member.json @@ -18,7 +18,7 @@ "model": "endorsement.shareddrivemember", "pk": 3, "fields": { - "member": 3, + "member": 8, "role": 1 } }, diff --git a/endorsement/fixtures/test_data/shared_drive_record.json b/endorsement/fixtures/test_data/shared_drive_record.json index f357021b..cef683ab 100644 --- a/endorsement/fixtures/test_data/shared_drive_record.json +++ b/endorsement/fixtures/test_data/shared_drive_record.json @@ -42,7 +42,7 @@ "pk": 3, "fields": { "shared_drive": 3, - "subscription": 2, + "subscription": 1, "acted_as": null, "datetime_created": "2024-04-22T17:41:28+00:00", "datetime_emailed": null, diff --git a/endorsement/management/commands/initialize_db.py b/endorsement/management/commands/initialize_db.py index ae7a5061..634b28a8 100644 --- a/endorsement/management/commands/initialize_db.py +++ b/endorsement/management/commands/initialize_db.py @@ -19,6 +19,8 @@ def handle(self, *args, **options): call_command('loaddata', 'test_data/member.json') call_command('loaddata', 'test_data/role.json') call_command('loaddata', 'test_data/itbill_subscription.json') + call_command('loaddata', 'test_data/itbill_provision.json') + call_command('loaddata', 'test_data/itbill_quantity.json') call_command('loaddata', 'test_data/shared_drive_member.json') call_command('loaddata', 'test_data/shared_drive_quota.json') call_command('loaddata', 'test_data/shared_drive.json') @@ -27,10 +29,12 @@ def handle(self, *args, **options): # adjust dates relative to today now = datetime.now(timezone.utc) sdr = SharedDriveRecord.objects.get(pk=1) + sdr.datetime_created = now - timedelta(days=80) sdr.datetime_accepted = now - timedelta(days=60) sdr.save() sdr = SharedDriveRecord.objects.get(pk=2) + sdr.datetime_created = now - timedelta(days=365) sdr.datetime_accepted = now - timedelta(days=330) sdr.save() @@ -38,6 +42,11 @@ def handle(self, *args, **options): sdr.datetime_accepted = now - timedelta(days=360) sdr.save() + sdr = SharedDriveRecord.objects.get(pk=4) + sdr.datetime_created = now - timedelta(days=27) + sdr.datetime_accepted = None + sdr.save() + sdr = SharedDriveRecord.objects.get(pk=5) sdr.datetime_accepted = now - timedelta(days=88) sdr.save() diff --git a/endorsement/management/commands/notify_endorsers.py b/endorsement/management/commands/notify_endorsers.py index 63be52b1..c01a073b 100644 --- a/endorsement/management/commands/notify_endorsers.py +++ b/endorsement/management/commands/notify_endorsers.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 from django.core.management.base import BaseCommand, CommandError -from endorsement.dao.notification import notify_endorsers +from endorsement.notifications.endorsement import notify_endorsers class Command(BaseCommand): diff --git a/endorsement/management/commands/notify_provisionees.py b/endorsement/management/commands/notify_provisionees.py index e47db474..902a3fb1 100644 --- a/endorsement/management/commands/notify_provisionees.py +++ b/endorsement/management/commands/notify_provisionees.py @@ -2,7 +2,8 @@ # SPDX-License-Identifier: Apache-2.0 from django.core.management.base import BaseCommand, CommandError -from endorsement.dao.notification import notify_endorsees, notify_accessors +from endorsement.notifications.endorsement import notify_endorsees +from endorsement.notifications.access import notify_accessors class Command(BaseCommand): diff --git a/endorsement/management/commands/notify_provisioners.py b/endorsement/management/commands/notify_provisioners.py new file mode 100644 index 00000000..aec2c2d9 --- /dev/null +++ b/endorsement/management/commands/notify_provisioners.py @@ -0,0 +1,17 @@ +# Copyright 2024 UW-IT, University of Washington +# SPDX-License-Identifier: Apache-2.0 + +from django.core.management.base import BaseCommand, CommandError +from endorsement.notifications.endorsement import notify_endorsers +from endorsement.notifications.access import notify_accessees + + +class Command(BaseCommand): + help = 'Send and/or retry failed email notification to endorsers' + + def handle(self, *args, **options): + try: + notify_endorsers() + notify_accessees() + except Exception as ex: + raise CommandError('notify endorser: {0}'.format(ex)) diff --git a/endorsement/management/commands/reconcile_shared_drives.py b/endorsement/management/commands/reconcile_shared_drives.py new file mode 100644 index 00000000..5d72632b --- /dev/null +++ b/endorsement/management/commands/reconcile_shared_drives.py @@ -0,0 +1,28 @@ +# Copyright 2024 UW-IT, University of Washington +# SPDX-License-Identifier: Apache-2.0 + +from django.core.management.base import BaseCommand, CommandError + + +class Command(BaseCommand): + help = 'Loop over all shared drives verifying lifecycle and subscriptions' + + def handle(self, *args, **options): +# for shared drives, only include members/managers that are in +# the uw_employee group since we'll be showing managers that aren't +# able to do anything about the subscription since their access to +# prt will be denied. + + + +# query itbill status for partiular key_memote +# only add provisions and quanties to itbill models +# for the given product sys_id +# (could it be the case that the product sys_id is not unique?) + + raise Exception("Not implemented yet") + + + + + diff --git a/endorsement/migrations/0027_auto_20240503_1436.py b/endorsement/migrations/0027_auto_20240503_1436.py new file mode 100644 index 00000000..8b55acae --- /dev/null +++ b/endorsement/migrations/0027_auto_20240503_1436.py @@ -0,0 +1,59 @@ +# Generated by Django 3.2.25 on 2024-05-03 21:36 + +from django.db import migrations, models +import django.db.models.deletion +import django_prometheus.models +import endorsement.util.key_remote + + +class Migration(migrations.Migration): + + dependencies = [ + ('endorsement', '0026_auto_20240426_1758'), + ] + + operations = [ + migrations.CreateModel( + name='ITBillProvision', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('current_quantity', models.IntegerField()), + ], + bases=(django_prometheus.models.ExportModelOperationsMixin('itbill_provision'), models.Model), + ), + migrations.RemoveField( + model_name='itbillsubscription', + name='name', + ), + migrations.RemoveField( + model_name='itbillsubscription', + name='url', + ), + migrations.AlterField( + model_name='itbillsubscription', + name='key_remote', + field=models.SlugField(default=endorsement.util.key_remote.key_remote, max_length=32), + ), + migrations.AlterField( + model_name='itbillsubscription', + name='state', + field=models.SmallIntegerField(choices=[(0, 'draft'), (1, 'provisioning'), (2, 'deployed'), (3, 'deprovision'), (4, 'closed'), (5, 'cancelled')], default=0), + ), + migrations.CreateModel( + name='ITBillQuantity', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity', models.IntegerField(null=True)), + ('start_date', models.DateField(null=True)), + ('end_date', models.DateField(null=True)), + ('stage', models.CharField(max_length=32, null=True)), + ('provision', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='endorsement.itbillprovision')), + ], + bases=(django_prometheus.models.ExportModelOperationsMixin('itbill_quantity'), models.Model), + ), + migrations.AddField( + model_name='itbillprovision', + name='subscription', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='endorsement.itbillsubscription'), + ), + ] diff --git a/endorsement/models/__init__.py b/endorsement/models/__init__.py index 29ec7b52..13b23d40 100644 --- a/endorsement/models/__init__.py +++ b/endorsement/models/__init__.py @@ -3,4 +3,5 @@ from endorsement.models.core import * from endorsement.models.access import * +from endorsement.models.itbill import * from endorsement.models.shared_drive import * diff --git a/endorsement/models/itbill.py b/endorsement/models/itbill.py new file mode 100644 index 00000000..5f1511e8 --- /dev/null +++ b/endorsement/models/itbill.py @@ -0,0 +1,168 @@ +# Copyright 2024 UW-IT, University of Washington +# SPDX-License-Identifier: Apache-2.0 + +from django.db import models +from django.conf import settings +from django_prometheus.models import ExportModelOperationsMixin +from endorsement.util.date import datetime_to_str, date_to_str +from endorsement.util.key_remote import key_remote +import json + + +class ITBillSubscription( + ExportModelOperationsMixin('itbill_subscription'), models.Model): + MANAGER_ROLE = "organizer" + + SUBSCRIPTION_DRAFT = 0 + SUBSCRIPTION_PROVISIONING = 1 + SUBSCRIPTION_DEPLOYED = 2 + SUBSCRIPTION_DEPROVISION = 3 + SUBSCRIPTION_CLOSED = 4 + SUBSCRIPTION_CANCELLED = 5 + SUBSCRIPTION_STATE_CHOICES = ( + (SUBSCRIPTION_DRAFT, "draft"), + (SUBSCRIPTION_PROVISIONING, "provisioning"), + (SUBSCRIPTION_DEPLOYED, "deployed"), + (SUBSCRIPTION_DEPROVISION, "deprovision"), + (SUBSCRIPTION_CLOSED, "closed"), + (SUBSCRIPTION_CANCELLED, "cancelled") + ) + + PRIORITY_NONE = 0 + PRIORITY_DEFAULT = 1 + PRIORITY_HIGH = 2 + PRIORITY_CHOICES = ( + (PRIORITY_NONE, 'none'), + (PRIORITY_DEFAULT, 'normal'), + (PRIORITY_HIGH, 'high') + ) + + key_remote = models.SlugField(max_length=32, default=key_remote) + state = models.SmallIntegerField( + default=SUBSCRIPTION_DRAFT, choices=SUBSCRIPTION_STATE_CHOICES) + query_priority = models.SmallIntegerField( + default=PRIORITY_DEFAULT, choices=PRIORITY_CHOICES) + query_datetime = models.DateTimeField(null=True) + + def from_json(self, itbill): + """ + (re)load subscription from itbill data + """ + self.state = next( + filter(lambda x: x[1] == itbill.lifecycle_state, + ITBillSubscription.SUBSCRIPTION_STATE_CHOICES))[0] + self.query_priority = ITBillSubscription.PRIORITY_DEFAULT + self.update_provisions(itbill.provisions) + + def get_provisions(self): + return ITBillProvision.objects.filter(subscription=self) + + def create_provision(self, provision): + provision_obj = ITBillProvision.objects.create( + subscription=self, current_quantity=provision.current_quantity) + + provision_obj.from_json(provision) + print(f"saving provision {provision_obj}") + provision_obj.save() + + def update_provisions(self, provisions): + self.clear_provisions() + + product_sys_id = getattr( + settings, 'ITBILL_SHARED_DRIVE_PRODUCT_SYS_ID') + for provision in provisions: + if provision.product.sys_id == product_sys_id: + self.create_provision(provision) + + def clear_provisions(self): + provisions = self.get_provisions() + for provision in provisions: + provision.clear_quantities() + + provisions.delete() + + def json_data(self): + return { + "key_remote": self.key_remote, + "provisions": [p.json_data() for p in self.get_provisions()], + "state": self.SUBSCRIPTION_STATE_CHOICES[self.state][1], + "query_priority": self.PRIORITY_CHOICES[ + self.query_priority][1], + "query_datetime": datetime_to_str( + self.query_datetime) + } + + def __str__(self): + return json.dumps(self.json_data()) + + +class ITBillProvision( + ExportModelOperationsMixin('itbill_provision'), models.Model): + """ + ITBillProvision model represents the provisioning of a shared drive + """ + subscription = models.ForeignKey( + ITBillSubscription, on_delete=models.PROTECT) + current_quantity = models.IntegerField() + + def from_json(self, provision): + for quantity in provision.quantities: + self.set_quantity(quantity) + + def get_quantities(self): + return ITBillQuantity.objects.filter(provision=self) + + def set_quantity(self, quantity): + quantity_obj = ITBillQuantity.objects.create(provision=self) + quantity_obj.from_json(quantity) + print(f"saving quantity {quantity_obj}") + quantity_obj.save() + + def clear_quantities(self): + quantities = ITBillQuantity.objects.filter(provision=self) + quantities.delete() + + def json_data(self): + return { + "quantities": [q.json_data() for q in self.get_quantities()], + "current_quantity": self.current_quantity + } + + def __str__(self): + return json.dumps(self.json_data()) + + +class ITBillQuantity( + ExportModelOperationsMixin('itbill_quantity'), models.Model): + """ + ITBillQuantity quanity is billable units of 100GB each + """ + provision = models.ForeignKey( + ITBillProvision, on_delete=models.PROTECT) + quantity = models.IntegerField(null=True) + start_date = models.DateField(null=True) + end_date = models.DateField(null=True) + stage = models.CharField(max_length=32, null=True) + + @property + def quantity_gigabytes(self): + return (self.quantity * 100) + getattr( + settings, 'ITBILL_SHARED_DRIVE_SUBSIDIZED_QUOTA') + + def from_json(self, quantity): + self.quantity = int(quantity.quantity) + self.start_date = quantity.start_date + self.end_date = quantity.end_date + self.stage = quantity.stage + + def json_data(self): + return { + "quantity": self.quantity, + "quota_limit": self.quantity_gigabytes, + "start_date": date_to_str(self.start_date), + "end_date": date_to_str(self.end_date), + "stage": self.stage + } + + def __str__(self): + return json.dumps(self.json_data()) diff --git a/endorsement/models/shared_drive.py b/endorsement/models/shared_drive.py index d23655ff..0c3174f5 100644 --- a/endorsement/models/shared_drive.py +++ b/endorsement/models/shared_drive.py @@ -5,8 +5,8 @@ from django.conf import settings from django.utils import timezone from django_prometheus.models import ExportModelOperationsMixin +from endorsement.models.itbill import ITBillSubscription from endorsement.util.date import datetime_to_str -import secrets import json @@ -67,22 +67,21 @@ class SharedDriveQuota( Quota limit is represnted as an integer number of Gigabytes """ - SUBSIDIZED_QUOTA = 100 - org_unit_id = models.CharField(max_length=32) org_unit_name = models.CharField(max_length=64, null=True) quota_limit = models.IntegerField(null=True) @property def is_subsidized(self): - return self.drive_quota.quota_limit <= self.SUBSIDIZED_QUOTA + return self.quota_limit <= getattr( + settings, 'ITBILL_SHARED_DRIVE_SUBSIDIZED_QUOTA') def json_data(self): return { "org_unit_id": self.org_unit_id, "org_unit_name": self.org_unit_name, "quota_limit": self.quota_limit, - "is_subsidized": self.quota_limit <= self.SUBSIDIZED_QUOTA + "is_subsidized": self.is_subsidized } @@ -140,74 +139,10 @@ def __str__(self): return json.dumps(self.json_data()) -class ITBillSubscription( - ExportModelOperationsMixin('itbill_subscription'), models.Model): - MANAGER_ROLE = "organizer" - - SUBSCRIPTION_DRAFT = 0 - SUBSCRIPTION_PROVISIONING = 1 - SUBSCRIPTION_DEPLOYED = 2 - SUBSCRIPTION_DEPROVISION = 3 - SUBSCRIPTION_CLOSED = 4 - SUBSCRIPTION_CANCELLED = 5 - SUBSCRIPTION_STATE_CHOICES = ( - (SUBSCRIPTION_DRAFT, "Draft"), - (SUBSCRIPTION_PROVISIONING, "Provisioning"), - (SUBSCRIPTION_DEPLOYED, "Deployed"), - (SUBSCRIPTION_DEPROVISION, "Deprovision"), - (SUBSCRIPTION_CLOSED, "Closed"), - (SUBSCRIPTION_CANCELLED, "Cancelled") - ) - - PRIORITY_NONE = 0 - PRIORITY_DEFAULT = 1 - PRIORITY_HIGH = 2 - PRIORITY_CHOICES = ( - (PRIORITY_NONE, 'none'), - (PRIORITY_DEFAULT, 'normal'), - (PRIORITY_HIGH, 'high') - ) - - key_remote = models.SlugField(max_length=32, unique=True, null=True) - name = models.CharField(max_length=128, null=True) - url = models.CharField(max_length=256, null=True) - state = models.SmallIntegerField( - default=SUBSCRIPTION_DRAFT, choices=SUBSCRIPTION_STATE_CHOICES) - query_priority = models.SmallIntegerField( - default=PRIORITY_DEFAULT, choices=PRIORITY_CHOICES) - query_datetime = models.DateTimeField(null=True) - - def save(self, *args, **kwargs): - if not self.key_remote: - self.key_remote = secrets.token_hex(16) - - if not self.name: - self.name = getattr( - settings, "ITBILL_SHARED_DRIVE_NAME_FORMAT", "{}").format( - self.shared_drive.drive_id), - - super(SharedDriveSubscription, self).save(*args, **kwargs) - - def json_data(self): - return { - "key_remote": self.key_remote, - "name": self.name, - "url": self.url, - "state": self.SUBSCRIPTION_STATE_CHOICES[self.state][1], - "query_priority": self.PRIORITY_CHOICES[ - self.query_priority][1], - "query_datetime": datetime_to_str( - self.query_datetime) - } - - def __str__(self): - return json.dumps(self.json_data()) - - class SharedDriveRecordManager(models.Manager): - def get_shared_drives_for_netid(self, netid, drive_id=None): + def get_member_drives(self, member_netid, drive_id=None): parms = { - "shared_drive__members__member__name": netid, + "shared_drive__members__member__name": member_netid, "is_deleted__isnull": True} if drive_id: @@ -259,6 +194,16 @@ def expiration_date(self): claim_span)) if ( self.datetime_created) else None) + @property + def itbill_form_url(self): + url_base = getattr(settings, 'ITBILL_FORM_URL_BASE') + url_base_id = getattr(settings, 'ITBILL_FORM_URL_BASE_ID') + sys_id = getattr(settings, 'ITBILL_SHARED_DRIVE_PRODUCT_SYS_ID') + + return (f"{url_base}?id={url_base_id}&sys_id={sys_id}" + f"&remote_key={self.subscription.key_remote}" + f"&shared_drive={self.shared_drive.drive_name}") + def set_acceptance(self, member_netid, accept=True): member = Member.objects.get_member(member_netid) action = SharedDriveAcceptance.ACCEPT if ( @@ -276,11 +221,18 @@ def set_acceptance(self, member_netid, accept=True): self.save() + def update_subscription(self, itbill): + self.subscription.from_json(itbill) + self.subscription.save() + self.save() + def json_data(self): return { "drive": self.shared_drive.json_data(), "subscription": self.subscription.json_data() if ( self.subscription) else None, + "itbill_form_url": self.itbill_form_url if ( + self.subscription) else None, "acted_as": self.acted_as, "datetime_created": datetime_to_str(self.datetime_created), "datetime_emailed": datetime_to_str(self.datetime_emailed), diff --git a/endorsement/notifications/access.py b/endorsement/notifications/access.py new file mode 100644 index 00000000..9d8645cb --- /dev/null +++ b/endorsement/notifications/access.py @@ -0,0 +1,57 @@ +# Copyright 2024 UW-IT, University of Washington +# SPDX-License-Identifier: Apache-2.0 + +from endorsement.models import AccessRecord +from endorsement.dao.notification import send_notification +from endorsement.dao.accessors import get_accessor_email +from django.template import loader, Template, Context +import logging + + +logger = logging.getLogger(__name__) + + +def _email_template(template_name): + return "email/access/{}".format(template_name) + + +def notify_accessors(): + for ar in AccessRecord.objects.get_unnotified_accessors(): + try: + emails = get_accessor_email(ar) + + (subject, text_body, html_body) = _create_accessor_message( + ar, emails) + + recipients = [] + for addr in emails: + recipients.append(addr['email']) + + send_notification( + recipients, subject, text_body, html_body, "Accessor") + + ar.emailed() + except EmailFailureException as ex: + logger.error("Accessor notification failed: {}".format(ex)) + except Exception as ex: + logger.error("Notify get email failed: {0}, netid: {1}" + .format(ex, ar.accessor)) + + +def _create_accessor_message(access_record, emails): + subject = "Delegated Mailbox Access to {}".format( + access_record.accessee.netid) + + params = { + 'record': access_record, + 'emails': emails + } + + text_template = _email_template("accessor.txt") + html_template = _email_template("accessor.html") + + return (subject, + loader.render_to_string(text_template, params), + loader.render_to_string(html_template, params)) + + diff --git a/endorsement/notifications/endorsement.py b/endorsement/notifications/endorsement.py new file mode 100644 index 00000000..8c3e8c25 --- /dev/null +++ b/endorsement/notifications/endorsement.py @@ -0,0 +1,395 @@ +# Copyright 2024 UW-IT, University of Washington +# SPDX-License-Identifier: Apache-2.0 + +from endorsement.dao.notification import send_notification +from endorsement.models import EndorsementRecord +from endorsement.services import ( + endorsement_services, get_endorsement_service, service_names) +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.util.email import uw_email_address +from endorsement.util.string import listed_list +from django.template import loader, Template, Context +from django.utils import timezone +import re +import logging + + +logger = logging.getLogger(__name__) + + +# This function is a monument to technical debt and intended +# to blow up tests as soon as the first endorsemnnt service lifecycle +# definition strays from current common set of values +def confirm_common_lifecyle_values(): + lifetime = None + warnings = None + + for service in endorsement_services(): + if lifetime is None: + lifetime = service.endorsement_lifetime + else: + if lifetime != service.endorsement_lifetime: + raise Exception( + "Messaging does not support mixed service lifetimes") + + if warnings is None: + warnings = [ + service.endorsement_expiration_warning(1), + service.endorsement_expiration_warning(2), + service.endorsement_expiration_warning(3), + service.endorsement_expiration_warning(4), + ] + elif (warnings[0] != service.endorsement_expiration_warning(1) or + warnings[1] != service.endorsement_expiration_warning(2) or + warnings[2] != service.endorsement_expiration_warning(3) or + warnings[3] != service.endorsement_expiration_warning(4)): + raise Exception( + "Messaging does not support mismatched service warning spans") + + +def _email_template(template_name): + return "email/endorsement/{}".format(template_name) + + +def _create_endorsee_message(endorser): + sent_date = timezone.now() + params = { + "endorser_netid": endorser['netid'], + "endorser_name": endorser['display_name'], + "endorsed_date": display_datetime(sent_date), + "services": endorser['services'] + } + + names = [] + for k, v in endorser['services'].items(): + names.append(v['name']) + for service in endorsement_services(): + if k == service.service_name: + v['service_link'] = service.service_link + + subject = "Action Required: Your new access to {0}".format( + listed_list(names)) + + text_template = _email_template("endorsee.txt") + html_template = _email_template("endorsee.html") + + return (subject, + loader.render_to_string(text_template, params), + loader.render_to_string(html_template, params)) + + +def get_unendorsed_unnotified(): + endorsements = {} + for er in EndorsementRecord.objects.get_unendorsed_unnotified(): + try: + email = get_endorsee_email_model(er.endorsee, er.endorser).email + except Exception as ex: + logger.error("Notify get email failed: {0}, netid: {1}" + .format(ex, er.endorsee)) + continue + + if email not in endorsements: + endorsements[email] = { + 'endorsers': {} + } + + if (er.endorser.netid not in endorsements[email]['endorsers']): + endorsements[email]['endorsers'][er.endorser.netid] = { + 'netid': er.endorser.netid, + 'display_name': er.endorser.display_name, + 'services': {} + } + + for service in endorsement_services(): + if er.category_code == service.category_code: + endorsements[email]['endorsers'][ + er.endorser.netid]['services'][service.service_name] = { + 'code': service.category_code, + 'name': service.category_name, + 'id': er.id, + 'accept_url': er.accept_url() + } + break + + return endorsements + + +def notify_endorsees(): + endorsements = get_unendorsed_unnotified() + + for email, endorsers in endorsements.items(): + for endorser_netid, endorsers in endorsers['endorsers'].items(): + (subject, text_body, html_body) = _create_endorsee_message( + endorsers) + try: + send_notification([email], subject, text_body, html_body, "Endorsee") + for service, data in endorsers['services'].items(): + EndorsementRecord.objects.emailed(data['id']) + except EmailFailureException as ex: + pass + + +def _create_endorser_message(endorsed): + sent_date = timezone.now() + params = { + "endorsed_date": display_datetime(sent_date), + "endorsed": {} + } + + unique = {} + for svc, endorsee_list in endorsed.items(): + for e in endorsee_list: + service_name = e["name"] + netid = e["netid"] + unique[netid] = 1 + if service_name in params["endorsed"]: + params["endorsed"][service_name]['netids'].append(netid) + else: + params["endorsed"][service_name] = { + 'svc': svc, + 'netids': [netid] + } + + params["endorsees"] = list(unique.keys()) + + services = [] + for s, v in params["endorsed"].items(): + services.append(s) + for service in endorsement_services(): + if v['svc'] == service.service_name: + v['service_link'] = service.service_link + + subject = "Shared NetID access to {}".format(listed_list(services)) + text_template = _email_template("endorser.txt") + html_template = _email_template("endorser.html") + + return (subject, + loader.render_to_string(text_template, params), + loader.render_to_string(html_template, params)) + + +def get_endorsed_unnotified(): + return _get_endorsed_unnotified( + EndorsementRecord.objects.get_endorsed_unnotified()) + + +def _get_endorsed_unnotified(endorsed_unnotified): + endorsements = {} + for er in endorsed_unnotified: + # rely on @u forwarding for valid address + email = uw_email_address(er.endorser.netid) + if email not in endorsements: + endorsements[email] = {} + + data = { + 'netid': er.endorsee.netid, + 'name': '', + 'id': er.id + } + + for service in endorsement_services(): + if er.category_code == service.category_code: + data['name'] = service.category_name + if service.service_name in endorsements[email]: + endorsements[email][service.service_name].append(data) + else: + endorsements[email][service.service_name] = [data] + + break + + return endorsements + + +def notify_endorsers(): + endorsements = get_endorsed_unnotified() + for email, endorsed in endorsements.items(): + (subject, text_body, html_body) = _create_endorser_message(endorsed) + try: + send_notification( + [email], subject, text_body, html_body, "Endorser") + for svc in [s.service_name for s in endorsement_services()]: + if svc in endorsed: + for id in [x['id'] for x in endorsed[svc]]: + EndorsementRecord.objects.emailed(id) + except EmailFailureException as ex: + pass + + +def _create_invalid_endorser_message(endorsements): + params = { + "endorsed": {} + } + + services = {} + for e in endorsements: + params['endorser_netid'] = e.endorser.netid + for service in endorsement_services(): + if e.category_code == service.category_code: + services[service.category_name] = 1 + try: + params['endorsed'][e.endorsee.netid].append( + service.category_name) + except KeyError: + params['endorsed'][e.endorsee.netid] = [ + service.category_name] + + params['services'] = list(services.keys()) + + subject = "Action Required: Provisioned UW-IT services will expire soon" + + text_template = _email_template("invalid_endorser.txt") + html_template = _email_template("invalid_endorser.html") + + return (subject, + loader.render_to_string(text_template, params), + loader.render_to_string(html_template, params)) + + +def notify_invalid_endorser(invalid_endorsements): + if not (invalid_endorsements and len(invalid_endorsements) > 0): + return + + sent_date = timezone.now() + email = uw_email_address(invalid_endorsements[0].endorser.netid) + (subject, text_body, html_body) = _create_invalid_endorser_message( + invalid_endorsements) + + try: + send_notification( + [email], subject, text_body, html_body, "Invalid endorser") + invalid_endorsements[0].endorser.datetime_emailed = sent_date + invalid_endorsements[0].endorser.save() + for endorsement in invalid_endorsements: + clear_endorsement(endorsement) + except EmailFailureException as ex: + pass + + +def _create_expire_notice_message(notice_level, lifetime, endorsed): + category_codes = list(set([e.category_code for e in endorsed])) + services = [get_endorsement_service(c) for c in category_codes] + context = { + 'endorser': endorsed[0].endorser, + 'lifetime': lifetime, + 'notice_time': services[0].endorsement_expiration_warning( + notice_level), + 'expiring': endorsed, + 'expiring_count': len(set(e.endorsee.netid for e in endorsed)), + 'impacts': [] + } + + for impact in list(set([s.service_renewal_statement for s in services])): + m = re.match( + r'.*{{[\s]*(service_names((_([0-9a-z]+))+))[\s]*}}.*', impact) + if m: + names = [] + for n in re.findall(r'_([^_]*)', m.group(2)): + impact_service = get_endorsement_service(n) + if impact_service.category_code in category_codes: + names.append(impact_service.category_name) + + impact_context = { + m.group(1): service_names(service_list=names), + 'service_names_count': len(names) + } + + template = Template(impact) + impact_statement = template.render(Context(impact_context)) + else: + impact_statement = impact + + context['impacts'].append(impact_statement) + + if notice_level < 4: + subject = ("Action Required: Provisioned UW-IT " + "services will expire soon") + text_template = _email_template("notice_warning.txt") + html_template = _email_template("notice_warning.html") + else: + subject = "Action Required: Provisioned UW-IT 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_endorsers(notice_level): + confirm_common_lifecyle_values() + + endorsements = endorsements_to_warn(notice_level) + + lifetime = endorsement_services()[0].endorsement_lifetime + + if len(endorsements): + endorsers = {} + for e in endorsements: + endorsers[e.endorser.id] = 1 + + for endorser in endorsers.keys(): + endorsed = endorsements.filter(endorser=endorser) + + sent_date = timezone.now() + email = uw_email_address(endorsed[0].endorser.netid) + + try: + (subject, + text_body, + html_body) = _create_expire_notice_message( + notice_level, lifetime, endorsed) + send_notification( + [email], subject, text_body, html_body, "Invalid endorser") + + sent_date = { + 'datetime_notice_{}_emailed'.format( + notice_level): timezone.now() + } + endorsed.update(**sent_date) + except EmailFailureException as ex: + pass + + +def warn_new_shared_netid_owner(new_owner, endorsements): + if not (endorsements and len(endorsements) > 0): + return + + confirm_common_lifecyle_values() + + sent_date = timezone.now() + email = uw_email_address(new_owner.netid) + (subject, text_body, html_body) = _create_warn_shared_owner_message( + new_owner, endorsements) + + send_notification( + [email], subject, text_body, html_body, + "Shared Netid Owner") + + for endorsement in endorsements: + endorsement.datetime_notice_1_emailed = sent_date + endorsement.save() + + +def _create_warn_shared_owner_message(owner_netid, endorsements): + service = get_endorsement_service(endorsements[0].category_code) + context = { + 'endorser': owner_netid, + 'lifetime': service.endorsement_lifetime, + 'notice_time': service.endorsement_expiration_warning(1), + 'expiring': endorsements, + 'expiring_count': len(endorsements) + } + + subject = "{0}{1}".format( + "Action Required: UW-IT services provisioned for Shared ", + "UW NetIDs you own have expired") + text_template = _email_template("notice_new_shared_warning.txt") + html_template = _email_template("notice_new_shared_warning.html") + + return (subject, + loader.render_to_string(text_template, context), + loader.render_to_string(html_template, context)) + diff --git a/endorsement/provisioner_validation.py b/endorsement/provisioner_validation.py index 414da6ce..79905e63 100644 --- a/endorsement/provisioner_validation.py +++ b/endorsement/provisioner_validation.py @@ -4,7 +4,7 @@ from django.conf import settings from endorsement.models import EndorsementRecord as ER from endorsement.services import get_endorsement_service -from endorsement.dao.notification import notify_invalid_endorser +from endorsement.notifications.endorsement import notify_invalid_endorser from uw_saml.utils import is_member_of_group from importlib import import_module import logging diff --git a/endorsement/resources/itbill/file/api/x_unowr_subscriptn/POST b/endorsement/resources/itbill/file/api/x_unowr_subscriptn/POST new file mode 100644 index 00000000..e69de29b diff --git a/endorsement/resources/itbill/file/api/x_unowr_subscriptn/subscription/.POST b/endorsement/resources/itbill/file/api/x_unowr_subscriptn/subscription/.POST new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/endorsement/resources/itbill/file/api/x_unowr_subscriptn/subscription/.POST @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/endorsement/resources/itbill/file/api/x_unowr_subscriptn/subscription/.http-headers b/endorsement/resources/itbill/file/api/x_unowr_subscriptn/subscription/.http-headers new file mode 100644 index 00000000..e7b7bf65 --- /dev/null +++ b/endorsement/resources/itbill/file/api/x_unowr_subscriptn/subscription/.http-headers @@ -0,0 +1,4 @@ +{ + "headers": {}, + "status": 200 +} \ No newline at end of file diff --git a/endorsement/resources/itbill/file/api/x_unowr_subscriptn/subscription/key_remote/GSD_test_test_test_123_rk b/endorsement/resources/itbill/file/api/x_unowr_subscriptn/subscription/key_remote/GSD_test_test_test_123_rk new file mode 100644 index 00000000..6243b332 --- /dev/null +++ b/endorsement/resources/itbill/file/api/x_unowr_subscriptn/subscription/key_remote/GSD_test_test_test_123_rk @@ -0,0 +1,106 @@ +{ + "subscription": { + "sys_id": "0123456789abcdef0123456789abcdef", + "key_remote": "GSD_test_test_test_123_rk", + "name": "Google Shared Drive", + "lifecycle_state": "deployed", + "product": { + "name": "Google Shared Drive", + "sys_id": "7078586b2f6cb076cad75ae9aab3ea05", + "url": "/api/now/table/x_unowr_products_product/abc012def3456789abc012def3456789" + }, + "contact_number": {}, + "contacts_additional": [], + "configuration_item": { + "name": "", + "sys_id": null, + "url": "/api/now/table/cmdb_ci/null" + }, + "start_date": "2023-12-01", + "end_date": "", + "note": "Google Shared Drive. Licensee: javerge. Requester: jfaculty", + "work_notes": "", + "budgets": [ + { + "budget": "", + "budget_number": "", + "organization": "", + "pca_task": "", + "pca_option": "", + "pca_project": "", + "driver_worktag_type": "gift", + "cost_center_driver": "", + "resource_driver": "", + "fund_driver": "", + "grant_adhoc": "", + "program_adhoc": "", + "gift_driver": "", + "grant_driver": "", + "cost_center_toggle": "", + "resource_toggle": "", + "program_driver": "", + "project_driver": "", + "activity": "", + "assignee": "", + "other_worktags": "", + "percent": "100", + "start_month": "2023-07", + "end_month": "", + "last_bill_end_period": "", + "stage": "Future", + "url": "/api/now/table/x_unowr_subscriptn_subscription_budget/abcd0123ef456789abcd0123ef456789", + "sys_id": "abcd0123ef456789abcd0123ef456789" + } + ], + "provisions": [ + { + "provision": { + "sys_id": "473ae1d097afd914e88df7300153af97", + "name": "Google Shared Drive", + "product": { + "url": "/api/now/table/x_unowr_products_product/abc012def3456789abc012def3456789", + "sys_id": "7078586b2f6cb076cad75ae9aab3ea05" + }, + "subscription": { + "url": "/api/now/table/x_unowr_subscriptn_subscription/0123456789abcdef0123456789abcdef", + "sys_id": "0123456789abcdef0123456789abcdef" + }, + "bill_schedule": "annual-fixed", + "start_date": "2023-07-01", + "end_date": "", + "last_bill_end_period": "", + "next_bill_start": "2023-07-01", + "stage": "Future", + "note": "", + "key_remote": "", + "current_quantity": "5", + "quantities": [ + { + "url": "/api/now/table/x_unowr_subscriptn_provision_quantity/00112233445566778899aabbccddee00", + "sys_id": "00112233445566778899aabbccddee00", + "quantity": "5", + "start_date": "2024-01-01", + "end_date": "", + "stage": "Curent", + "bill_comment": "", + "last_bill_end_period": "", + "next_bill_start": "2024-01-01" + }, + { + "url": "/api/now/table/x_unowr_subscriptn_provision_quantity/00112233445566778899aabbccddeeff", + "sys_id": "00112233445566778899aabbccddeeff", + "quantity": "7", + "start_date": "2024-12-01", + "end_date": "", + "stage": "Future", + "bill_comment": "", + "last_bill_end_period": "", + "next_bill_start": "2024-12-01" + } + ], + "budgets": [] + } + } + ] + } +} diff --git a/endorsement/shared_validation.py b/endorsement/shared_validation.py index de885bac..2dfee57e 100644 --- a/endorsement/shared_validation.py +++ b/endorsement/shared_validation.py @@ -6,7 +6,7 @@ from endorsement.dao.uwnetid_supported import get_supported_resources_for_netid from endorsement.dao.user import get_endorser_model from endorsement.dao.uwnetid_admin import get_owner_for_shared_netid -from endorsement.dao.notification import warn_new_shared_netid_owner +from endorsement.notifications.endorsement import warn_new_shared_netid_owner import logging diff --git a/endorsement/static/endorsement/css/critical.scss b/endorsement/static/endorsement/css/critical.scss index 76ca3c8b..b0cecbbe 100644 --- a/endorsement/static/endorsement/css/critical.scss +++ b/endorsement/static/endorsement/css/critical.scss @@ -309,7 +309,6 @@ a { tr.new_access .access-type, tr.new_access .access-action { background: #efffef !important; } .shared-drive-action { width: 16em !important; } - .xshared-drive-quota { width: 5em; } .shared-drive-est-usage { width: 7em; } .access-status p { padding-left: 1.2em; } .endorsed-netid, .access-mailbox { border-top: none; } @@ -324,9 +323,9 @@ a { .endorsed-name, .access-mailbox-name { border-top: none; border-right: 1px solid #ddd; } .endorsed-status-icon, { width: 20px; } .shared-drive-status-icon { width: 24px; } - .endorsed-status, .shared-drive-status { + .endorsed-status, .shared-drive-status, .shared-drive-quota { width: 15rem; - p { font-size: smaller; } + p { font-size: smaller; width: 10rem; } } .endorsed-reason { width: 13rem; } .endorsed-action, .shared-drive-action { width: 12rem; white-space: nowrap; } diff --git a/endorsement/static/endorsement/js/tab/google.js b/endorsement/static/endorsement/js/tab/google.js index c357ae0b..42678830 100644 --- a/endorsement/static/endorsement/js/tab/google.js +++ b/endorsement/static/endorsement/js/tab/google.js @@ -16,9 +16,10 @@ var ManageSharedDrives = (function () { _registerEvents = function () { var $tab = $('.tabs div#drives'); - // delegated events within our content $tab.on('endorse:drivesTabExposed', function (e) { - _getSharedDrives(); + if ($content.is(':empty')) { + _getSharedDrives(); + } }); $panel.on('change', 'select#shared_drive_action', function (e) { @@ -34,25 +35,21 @@ var ManageSharedDrives = (function () { } else if (action === 'shared_drive_revoke') { _sharedDriveRevokeModal(drive_id); } - }).on('click', '#shared_drive_accept', function (e) { + }).delegate('#shared_drive_accept', 'click', function (e) { _displayModal('#shared-drive-acceptance', { drive_id: $(this).attr('data-drive-id')}); - }).on('click', '#confirm_itbill_visit', function (e) { - var $this = $(this), - itbill_url = $this.attr('data-itbill-url'), - drive_id = $this.attr('data-drive-id'); - - if (itbill_url) { - window.open(itbill_url, '_blank'); - } else { - _getITBill_URL(drive_id); - } - - _modalHide(); - }).on('click', '#confirm_shared_drive_acceptance', function (e) { + }).delegate('#confirm_itbill_visit', 'click', function (e) { + _getITBill_URL($(this).attr('data-drive-id')); + }).delegate('#confirm_shared_drive_acceptance', 'click', function (e) { _setSharedDriveResponsibility($(this).attr('data-drive-id'), true); - }).on('click', '#confirm_shared_drive_revoke', function (e) { + }).delegate('#confirm_shared_drive_revoke', 'click', function (e) { _setSharedDriveResponsibility($(this).attr('data-drive-id'), false); + }).delegate('#refresh_drive', 'click', function (e) { + _refreshSharedDrive($(this).attr('data-drive-id')); + }).on('endorse:SharedDriveRefresh', function (e, data) { + _updateSharedDrivesDiplay(data.drives[0]); + }).on('endorse:SharedDriveRefreshError', function (e, error) { + Notify.error('Sorry, but subscription information unavailable at this time: ' + error); }).on('endorse:SharedDriveResponsibilityAccepted', function (e, data) { _modalHide(); _updateSharedDrivesDiplay(data.drives[0]); @@ -81,10 +78,22 @@ var ManageSharedDrives = (function () { }).on('endorse:SharedDrivesFailure', function (e, data) { _displaySharedDrivesFailure(data); }).on('endorse:SharedDrivesITBIllURLSuccess', function (e, data) { - debugger - window.open(data.subscription.url, '_blank'); + var url = (data.hasOwnProperty('drives') && data.drives.length == 1) ? data.drives[0].itbill_form_url : null; + + _modalHide(); + _updateSharedDrivesDiplay(data.drives[0]); + if (url) { + window.open(url, '_blank'); + } else { + Notify.error('Sorry, but we cannot retrieve the ITBill Form URL at this time.'); + } }).on('endorse:SharedDrivesITBIllURLFailure', function (e, data) { + _modalHide(); Notify.error('Sorry, but we cannot retrieve the ITBill URL at this time: ' + data); + }).popover({ + selector: 'span.prt-data-popover', + trigger: 'focus', + html: true }); $(document).on('endorse:TabChange', function (e, data) { @@ -128,7 +137,6 @@ var ManageSharedDrives = (function () { $content.html(template({drives: drives})); Scroll.init('.shared-drives-table'); - $('[data-toggle="popover"]').popover(); }, _prepSharedDriveContext = function (drive) { var expiration = moment(drive.datetime_expiration), @@ -138,6 +146,32 @@ var ManageSharedDrives = (function () { drive.expiration_date = expiration.format('M/D/YYYY'); drive.expiration_days = expiration.diff(now, 'days'); drive.expiration_from_now = expiration.from(now); + drive.in_flight = (drive.subscription && drive.subscription.query_priority === 'high'); + drive.future_quotas = []; + if (drive.subscription) { + $.each(drive.subscription.provisions, function () { + $.each(this.quantities, function () { + var starting = moment(this.start_date), + ending = moment(this.end_date), + is_future = starting.diff(now) > 0, + is_ending = starting.diff(now) < 0 && ending.diff(now) > 0, + is_increasing = this.quota_limit > drive.drive.drive_quota.quota_limit, + is_decreasing = this.quota_limit < drive.drive.drive_quota.quota_limit, + is_changing = (is_future || is_ending); + + drive.future_quotas.push({ + is_future: is_future, + is_ending: is_ending, + quota_limit: this.quota_limit, + is_increasing: is_increasing, + is_decreasing: is_decreasing, + is_changing: is_changing, + start_date: moment(this.start_date).format('M/D/YYYY'), + end_date: moment(this.end_date).format('M/D/YYYY') + }); + }); + }); + } }, _getSharedDrives = function() { var csrf_token = $("input[name=csrfmiddlewaretoken]")[0].value; @@ -160,6 +194,25 @@ var ManageSharedDrives = (function () { } }); }, + _refreshSharedDrive = function (drive_id) { + var csrf_token = $("input[name=csrfmiddlewaretoken]")[0].value; + + $.ajax({ + url: "/google/v1/shared_drive/" + drive_id + "/?refresh=1", + type: "GET", + contentType: "application/json", + accepts: {html: "application/json"}, + headers: { + "X-CSRFToken": csrf_token + }, + success: function(results) { + $panel.trigger('endorse:SharedDriveRefresh', [results]); + }, + error: function(xhr, status, error) { + $panel.trigger('endorse:SharedDriveRefreshError', [error]); + } + }); + }, _setSharedDriveResponsibility = function (drive_id, accepted) { var csrf_token = $("input[name=csrfmiddlewaretoken]")[0].value; diff --git a/endorsement/static/endorsement/js/tab/office.js b/endorsement/static/endorsement/js/tab/office.js index 2647f688..72508efb 100644 --- a/endorsement/static/endorsement/js/tab/office.js +++ b/endorsement/static/endorsement/js/tab/office.js @@ -152,6 +152,10 @@ var ManageOfficeAccess = (function () { _displayOfficeAccessTypes(); }).on('endorse:OfficeAccessTypesFailure', function (e, data) { alert('Cannot determine Access Types: ' + data); + }).popover({ + selector: 'span.prt-data-popover', + trigger: 'focus', + html: true }); $(document).on('endorse:TabChange', function (e, data) { @@ -249,7 +253,6 @@ var ManageOfficeAccess = (function () { $content.html(template(context)); Scroll.init('.office-access-table'); - $('[data-toggle="popover"]').popover(); }, _getOfficeAccessUWNetIDs = function() { var csrf_token = $("input[name=csrfmiddlewaretoken]")[0].value; diff --git a/endorsement/templates/email/accessor.html b/endorsement/templates/email/access/accessor.html similarity index 100% rename from endorsement/templates/email/accessor.html rename to endorsement/templates/email/access/accessor.html diff --git a/endorsement/templates/email/accessor.txt b/endorsement/templates/email/access/accessor.txt similarity index 100% rename from endorsement/templates/email/accessor.txt rename to endorsement/templates/email/access/accessor.txt diff --git a/endorsement/templates/email/endorsee.html b/endorsement/templates/email/endorsement/endorsee.html similarity index 100% rename from endorsement/templates/email/endorsee.html rename to endorsement/templates/email/endorsement/endorsee.html diff --git a/endorsement/templates/email/endorsee.txt b/endorsement/templates/email/endorsement/endorsee.txt similarity index 100% rename from endorsement/templates/email/endorsee.txt rename to endorsement/templates/email/endorsement/endorsee.txt diff --git a/endorsement/templates/email/endorser.html b/endorsement/templates/email/endorsement/endorser.html similarity index 100% rename from endorsement/templates/email/endorser.html rename to endorsement/templates/email/endorsement/endorser.html diff --git a/endorsement/templates/email/endorser.txt b/endorsement/templates/email/endorsement/endorser.txt similarity index 100% rename from endorsement/templates/email/endorser.txt rename to endorsement/templates/email/endorsement/endorser.txt diff --git a/endorsement/templates/email/invalid_endorser.html b/endorsement/templates/email/endorsement/invalid_endorser.html similarity index 100% rename from endorsement/templates/email/invalid_endorser.html rename to endorsement/templates/email/endorsement/invalid_endorser.html diff --git a/endorsement/templates/email/invalid_endorser.txt b/endorsement/templates/email/endorsement/invalid_endorser.txt similarity index 100% rename from endorsement/templates/email/invalid_endorser.txt rename to endorsement/templates/email/endorsement/invalid_endorser.txt diff --git a/endorsement/templates/email/notice_new_shared_warning.html b/endorsement/templates/email/endorsement/notice_new_shared_warning.html similarity index 100% rename from endorsement/templates/email/notice_new_shared_warning.html rename to endorsement/templates/email/endorsement/notice_new_shared_warning.html diff --git a/endorsement/templates/email/notice_new_shared_warning.txt b/endorsement/templates/email/endorsement/notice_new_shared_warning.txt similarity index 100% rename from endorsement/templates/email/notice_new_shared_warning.txt rename to endorsement/templates/email/endorsement/notice_new_shared_warning.txt diff --git a/endorsement/templates/email/notice_warning.html b/endorsement/templates/email/endorsement/notice_warning.html similarity index 100% rename from endorsement/templates/email/notice_warning.html rename to endorsement/templates/email/endorsement/notice_warning.html diff --git a/endorsement/templates/email/notice_warning.txt b/endorsement/templates/email/endorsement/notice_warning.txt similarity index 100% rename from endorsement/templates/email/notice_warning.txt rename to endorsement/templates/email/endorsement/notice_warning.txt diff --git a/endorsement/templates/email/notice_warning_final.html b/endorsement/templates/email/endorsement/notice_warning_final.html similarity index 100% rename from endorsement/templates/email/notice_warning_final.html rename to endorsement/templates/email/endorsement/notice_warning_final.html diff --git a/endorsement/templates/email/notice_warning_final.txt b/endorsement/templates/email/endorsement/notice_warning_final.txt similarity index 100% rename from endorsement/templates/email/notice_warning_final.txt rename to endorsement/templates/email/endorsement/notice_warning_final.txt diff --git a/endorsement/templates/handlebars/tab/access/office.html b/endorsement/templates/handlebars/tab/access/office.html index e8ab20cb..bedafc75 100644 --- a/endorsement/templates/handlebars/tab/access/office.html +++ b/endorsement/templates/handlebars/tab/access/office.html @@ -26,11 +26,11 @@ - + - + diff --git a/endorsement/templates/handlebars/tab/drives/google.html b/endorsement/templates/handlebars/tab/drives/google.html index 77528e21..dbba61b7 100644 --- a/endorsement/templates/handlebars/tab/drives/google.html +++ b/endorsement/templates/handlebars/tab/drives/google.html @@ -13,9 +13,9 @@ - + - + @@ -56,14 +56,14 @@

{{#or drive.drive_quota.is_subsidized subscription}}Renew{{else}}Select quota{{/or}} by {{expiration_date}}{{#lte expiration_days 90}}
({{expiration_from_now}}){{/lte}}

- + diff --git a/endorsement/test/api/test_shared_drives.py b/endorsement/test/api/test_shared_drives.py index fbcb2ea0..c1f5c7b9 100644 --- a/endorsement/test/api/test_shared_drives.py +++ b/endorsement/test/api/test_shared_drives.py @@ -10,6 +10,9 @@ class TestSharedDrivesAPI(EndorsementApiTest): fixtures = [ 'test_data/member.json', 'test_data/role.json', + 'test_data/itbill_quantity.json', + 'test_data/itbill_provision.json', + 'test_data/itbill_subscription.json', 'test_data/shared_drive_member.json', 'test_data/shared_drive_quota.json', 'test_data/shared_drive.json', @@ -25,7 +28,7 @@ def test_shared_drives(self): self.assertEqual(len(data['drives']), 5) def test_no_shared_drives(self): - self.set_user('endorsee3') + self.set_user('jinter') url = reverse('shared_drive_api') response = self.client.get(url) self.assertEquals(response.status_code, 200) diff --git a/endorsement/test/dao/test_itbill.py b/endorsement/test/dao/test_itbill.py new file mode 100644 index 00000000..5f05f963 --- /dev/null +++ b/endorsement/test/dao/test_itbill.py @@ -0,0 +1,50 @@ +# Copyright 2024 UW-IT, University of Washington +# SPDX-License-Identifier: Apache-2.0 + +import json +from django.urls import reverse +from django.test import override_settings +from django.test.utils import override_settings +from uw_itbill.subscription import Subscription +from endorsement.dao.itbill import ( + initiate_subscription, load_itbill_subscription) +from endorsement.models import SharedDriveRecord, ITBillSubscription +from endorsement.test.api import EndorsementApiTest + +@override_settings( + ITBILL_SHARED_DRIVE_PRODUCT_SYS_ID='7078586b2f6cb076cad75ae9aab3ea05') +class TestITBill(EndorsementApiTest): + 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 test_itbill_data_load(self): + key_remote = 'GSD_test_test_test_123_rk' + drive_id = '9F97529B38CF46F336C7408' + + record = SharedDriveRecord.objects.get_record_by_drive_id(drive_id) + + json_data = record.json_data() + self.assertEqual(json_data['subscription']['key_remote'], key_remote) + self.assertEqual( + len(json_data['subscription']['provisions']), 1) + self.assertEqual( + len(json_data['subscription']['provisions'][0]['quantities']), 1) + + load_itbill_subscription(record) + + record = SharedDriveRecord.objects.get_record_by_drive_id(drive_id) + json_data = record.json_data() + self.assertEqual(json_data['subscription']['key_remote'], key_remote) + self.assertEqual( + len(json_data['subscription']['provisions']), 1) + self.assertEqual( + len(json_data['subscription']['provisions'][0]['quantities']), 2) diff --git a/endorsement/test/dao/test_notification.py b/endorsement/test/dao/test_notification.py index f285a46d..2e5a2b95 100644 --- a/endorsement/test/dao/test_notification.py +++ b/endorsement/test/dao/test_notification.py @@ -6,9 +6,9 @@ from django.core import mail from endorsement.models import Accessor, Accessee, AccessRight, AccessRecord from endorsement.util.string import listed_list -from endorsement.dao.notification import notify_accessors +from endorsement.notifications.access import notify_accessors from endorsement.services import endorsement_services -from endorsement.dao.notification import ( +from endorsement.notifications.endorsement import ( notify_endorsees, notify_endorsers, get_unendorsed_unnotified, get_endorsed_unnotified, notify_invalid_endorser) diff --git a/endorsement/test/notifications/test_access.py b/endorsement/test/notifications/test_access.py new file mode 100644 index 00000000..0374bce0 --- /dev/null +++ b/endorsement/test/notifications/test_access.py @@ -0,0 +1,51 @@ +# Copyright 2024 UW-IT, University of Washington +# SPDX-License-Identifier: Apache-2.0 + +from django.test import TransactionTestCase +from django.utils import timezone +from django.core import mail +from endorsement.models import Accessor, Accessee, AccessRight, AccessRecord +from endorsement.util.string import listed_list +from endorsement.notifications.access import notify_accessors +import random + + +class TestAccessNotifications(TransactionTestCase): + def setUp(self): + now = timezone.now() + self.accessee = Accessee.objects.create( + netid='accessee', display_name="Netid Accessee", + regid='AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', is_valid=True) + self.accessor = Accessor.objects.create( + name='accessor', display_name='Netid Accessor', + is_valid=True, is_shared_netid=False, is_group=False) + self.accessor2 = Accessor.objects.create( + name='accessor2', display_name='Netid Accessor Two', + is_valid=True, is_shared_netid=False, is_group=False) + self.group_accessor = Accessor.objects.create( + name='endorsement_group', display_name='Group Accessor', + is_valid=True, is_shared_netid=False, is_group=True) + + aaaott = AccessRight.objects.create( + name='1', display_name='AllAccessAllOfTheTime') + sasott = AccessRight.objects.create( + name='2', display_name='SomeAccessSomeOfTheTime') + + AccessRecord.objects.create( + accessee=self.accessee, accessor=self.accessor, + access_right=aaaott, datetime_granted=now) + AccessRecord.objects.create( + accessee=self.accessee, accessor=self.group_accessor, + access_right=sasott, datetime_granted=now) + AccessRecord.objects.create( + accessee=self.accessee, accessor=self.accessor2, + access_right=sasott, datetime_granted=now, is_reconcile=True) + + def test_access_notifications(self): + notify_accessors() + self.assertEqual(len(mail.outbox), 2) + + self.assertEqual(len(mail.outbox[0].to), 1) + self.assertEqual(len(mail.outbox[1].to), 2) + self.assertTrue('AllAccessAllOfTheTime' in mail.outbox[0].body) + self.assertTrue('SomeAccessSomeOfTheTime' in mail.outbox[1].body) diff --git a/endorsement/test/notifications/test_endorsement.py b/endorsement/test/notifications/test_endorsement.py new file mode 100644 index 00000000..3a172f0c --- /dev/null +++ b/endorsement/test/notifications/test_endorsement.py @@ -0,0 +1,133 @@ +# Copyright 2024 UW-IT, University of Washington +# SPDX-License-Identifier: Apache-2.0 + +from django.test import TransactionTestCase +from django.utils import timezone +from django.core import mail +from endorsement.util.string import listed_list +from endorsement.services import endorsement_services +from endorsement.dao.endorse import get_endorsements_by_endorser +from endorsement.dao.user import get_endorser_model, get_endorsee_model +from endorsement.notifications.endorsement import ( + notify_endorsees, notify_endorsers, + get_unendorsed_unnotified, get_endorsed_unnotified, + notify_invalid_endorser) +import random + + +class TestNotificationDao(TransactionTestCase): + def test_endorsee_notification_message_single(self): + endorser = get_endorser_model('jstaff') + endorsee = get_endorsee_model('endorsee7') + + service = random.choice(endorsement_services()) + service.initiate_endorsement(endorser, endorsee, 'because') + service_name = service.category_name + service_link = service.service_link + + endorsements = get_unendorsed_unnotified() + self.assertEqual(len(endorsements), 1) + + notify_endorsees() + self.assertEqual(len(mail.outbox), 1) + self.assertEqual( + mail.outbox[0].subject, + 'Action Required: Your new access to {}'.format(service_name)) + self.assertTrue(service_name in mail.outbox[0].body) + self.assertTrue(service_link in mail.outbox[0].body) + self.assertTrue('Appropriate Use' in mail.outbox[0].alternatives[0][0]) + + def test_endorsee_notification_message_all(self): + endorser = get_endorser_model('jstaff') + endorsee = get_endorsee_model('endorsee7') + + service_names = [] + for service in endorsement_services(): + if service.valid_person_endorsee(endorsee): + service.initiate_endorsement(endorser, endorsee, 'because') + service_names.append(service.category_name) + + service_list = listed_list(service_names) + + endorsements = get_unendorsed_unnotified() + self.assertEqual(len(endorsements), 1) + + notify_endorsees() + self.assertEqual(len(mail.outbox), 1) + self.assertEqual( + mail.outbox[0].subject, + 'Action Required: Your new access to {}'.format(service_list)) + self.assertTrue(service_list in mail.outbox[0].body) + + for service in endorsement_services(): + self.assertTrue(service.service_link in mail.outbox[0].body) + + self.assertTrue('Appropriate Use' in mail.outbox[0].alternatives[0][0]) + + def test_endorser_notification_message_single(self): + endorser = get_endorser_model('jstaff') + endorsee = get_endorsee_model('endorsee7') + + service = random.choice(endorsement_services()) + service.store_endorsement(endorser, endorsee, None, 'because') + service_name = service.category_name + service_link = service.service_link + + endorsements = get_endorsed_unnotified() + self.assertEqual(len(endorsements), 1) + + notify_endorsers() + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].subject, + 'Shared NetID access to {}'.format(service_name)) + self.assertTrue(service_name in mail.outbox[0].body) + self.assertTrue(service_link in mail.outbox[0].body) + self.assertTrue('Shared UW NetID use of these services is bound' + in mail.outbox[0].alternatives[0][0]) + + def test_endorser_notification_message_all(self): + endorser = get_endorser_model('jstaff') + endorsee = get_endorsee_model('endorsee7') + + service_names = [] + for service in endorsement_services(): + if service.valid_person_endorsee(endorsee): + service.store_endorsement(endorser, endorsee, None, 'because') + service_names.append(service.category_name) + + service_list = listed_list(service_names) + + endorsements = get_endorsed_unnotified() + self.assertEqual(len(endorsements), 1) + + notify_endorsers() + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].subject, + 'Shared NetID access to {}'.format(service_list)) + self.assertTrue(service_list in mail.outbox[0].body) + for service in endorsement_services(): + self.assertTrue(service.service_link in mail.outbox[0].body) + + self.assertTrue('Shared UW NetID use of these services is bound' + in mail.outbox[0].alternatives[0][0]) + + def test_invalid_endorser_notification_message_single(self): + endorser = get_endorser_model('jstaff') + endorsee = get_endorsee_model('endorsee7') + + service = random.choice(endorsement_services()) + service.store_endorsement(endorser, endorsee, None, 'because') + + endorsements = get_endorsements_by_endorser(endorser) + + self.assertEqual(len(endorsements), 1) + + notify_invalid_endorser(endorsements) + self.assertEqual(len(mail.outbox), 1) + self.assertEqual( + mail.outbox[0].subject, + 'Action Required: Provisioned UW-IT services will expire soon') + self.assertTrue( + service.category_name in mail.outbox[0].body) + self.assertTrue( + service.category_name in mail.outbox[0].alternatives[0][0]) diff --git a/endorsement/test/test_expiration_warnings.py b/endorsement/test/test_expiration_warnings.py index e8c81b93..eb760ff7 100644 --- a/endorsement/test/test_expiration_warnings.py +++ b/endorsement/test/test_expiration_warnings.py @@ -8,7 +8,7 @@ from endorsement.models import Endorser, Endorsee, EndorsementRecord from endorsement.policy import (_endorsements_to_warn, _endorsements_to_expire) from endorsement.services import get_endorsement_service -from endorsement.dao.notification import warn_endorsers +from endorsement.notifications.endorsement import warn_endorsers from datetime import timedelta diff --git a/endorsement/urls.py b/endorsement/urls.py index f976a871..345ff261 100644 --- a/endorsement/urls.py +++ b/endorsement/urls.py @@ -28,7 +28,7 @@ from endorsement.views.api.office.resolve import ResolveRightsConflict from endorsement.views.api.office.validate import Validate as OfficeValidate from endorsement.views.api.google.shared_drive import SharedDrive -from endorsement.views.api.google.itbill import SharedDriveITBill +from endorsement.views.api.google.itbill import SharedDriveITBillURL from endorsement.views.api.notification import Notification @@ -76,9 +76,9 @@ re_path(r'^office/v1/access', OfficeAccess.as_view(), name='access_api'), re_path(r'^office/v1/validate', OfficeValidate.as_view(), name='office_validate_api'), - re_path(r'^google/v1/shared_drive/(?P\S+)/itbill_url', - SharedDriveITBill.as_view(), name='shared_drive_itbill_api'), - re_path(r'^google/v1/shared_drive/(?P\S+)?', + re_path(r'^google/v1/shared_drive/(?P[\S^/]+)/itbill_url', + SharedDriveITBillURL.as_view(), name='shared_drive_itbill_url'), + re_path(r'^google/v1/shared_drive/(?P[^\s\/]+)?', SharedDrive.as_view(), name='shared_drive_api'), re_path(r'.*', page.index, name='home'), ] diff --git a/endorsement/util/date.py b/endorsement/util/date.py index 8d11e8a3..25bcf5db 100644 --- a/endorsement/util/date.py +++ b/endorsement/util/date.py @@ -4,3 +4,8 @@ def datetime_to_str(d_obj): return d_obj.strftime("%Y-%m-%d %H:%M:%S") if d_obj else None + + +def date_to_str(dt_obj): + # datetime.datetime.isoformat + return dt_obj.isoformat() if dt_obj is not None else None diff --git a/endorsement/util/itbill/__init__.py b/endorsement/util/itbill/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/endorsement/util/itbill/shared_drive.py b/endorsement/util/itbill/shared_drive.py new file mode 100644 index 00000000..b57c9433 --- /dev/null +++ b/endorsement/util/itbill/shared_drive.py @@ -0,0 +1,14 @@ +# Copyright 2024 UW-IT, University of Washington +# SPDX-License-Identifier: Apache-2.0 + + +from django.conf import settings + + +def subscription_name(shared_drive_record): + return getattr(settings, "ITBILL_SHARED_DRIVE_NAME_FORMAT", "{}").format( + shared_drive_record.shared_drive.drive_name) + + +def product_sys_id(): + return getattr(settings, "ITBILL_SHARED_DRIVE_PRODUCT_SYS_ID") diff --git a/endorsement/util/key_remote.py b/endorsement/util/key_remote.py new file mode 100644 index 00000000..826c3f20 --- /dev/null +++ b/endorsement/util/key_remote.py @@ -0,0 +1,8 @@ +# Copyright 2024 UW-IT, University of Washington +# SPDX-License-Identifier: Apache-2.0 + +import secrets + + +def key_remote(): + return secrets.token_hex(16) diff --git a/endorsement/views/api/google/itbill.py b/endorsement/views/api/google/itbill.py index 23b80736..8cb38b00 100644 --- a/endorsement/views/api/google/itbill.py +++ b/endorsement/views/api/google/itbill.py @@ -1,59 +1,56 @@ # Copyright 2024 UW-IT, University of Washington # SPDX-License-Identifier: Apache-2.0 -from endorsement.models import SharedDriveRecord +from endorsement.models import SharedDriveRecord, ITBillSubscription from endorsement.dao.persistent_messages import get_persistent_messages from endorsement.dao.itbill import initiate_subscription from endorsement.views.rest_dispatch import ( RESTDispatch, invalid_session, data_not_found, invalid_endorser, bad_request, data_error) -from endorsement.exceptions import UnrecognizedUWNetid, InvalidNetID +from endorsement.exceptions import ( + UnrecognizedUWNetid, InvalidNetID, ITBillSubscriptionNotFound) import logging logger = logging.getLogger(__name__) -class SharedDriveITBill(RESTDispatch): +class SharedDriveITBillURL(RESTDispatch): """ - Manipulate ITBill resource SharedDriveRecords for provided netid + Retrieve ITBill Form URL settings subsciption state """ def get(self, request, *args, **kwargs): """ - Return SharedDriveRecord with suitable ITBill resource values + Instantiate a subscription for the provided netid """ try: netid, acted_as = self._validate_user(request) - except UnrecognizedUWNetid: - return invalid_session(logger) - except InvalidNetID: - return invalid_endorser(logger) - - try: drive_id = self.kwargs.get('drive_id') - drive = SharedDriveRecord.objects.get_shared_drives_for_netid( - netid, drive_id)[0] + drive = self._get_drive(netid, drive_id) - try: + if not drive.subscription: + initiate_subscription(drive) - subscription = initiate_subscription(drive) - drive.subscription.url = subscription.url - drive.save() - except Exception as ex: - return data_error( - logger, "Error initiating subscription: {}".format(ex)) + drive.subscription.query_priority = \ + ITBillSubscription.PRIORITY_HIGH + drive.subscription.save() - except SharedDriveRecord.DoesNotExist: - return data_not_found(logger) + return self.json_response({ + 'drives': [self._get_drive(netid, drive_id).json_data()], + 'messages': get_persistent_messages() + }) - return self.json_response(self._drive_list(netid, drive_id)) + except UnrecognizedUWNetid: + return invalid_session(logger) + except InvalidNetID: + return invalid_endorser(logger) + except (TypeError, ITBillSubscriptionNotFound): + return data_not_found(logger) + except Exception as ex: + return data_error(logger, ex) - def _drive_list(self, netid, drive_id=None): - drives = SharedDriveRecord.objects.get_shared_drives_for_netid( - netid, drive_id) - return { - 'drives': [d.json_data() for d in drives], - 'messages': get_persistent_messages() - } + def _get_drive(self, netid, drive_id): + drives = SharedDriveRecord.objects.get_member_drives(netid, drive_id) + return drives.get() if drives.count() == 1 else None diff --git a/endorsement/views/api/google/shared_drive.py b/endorsement/views/api/google/shared_drive.py index 2e98fb7e..5cb21bb9 100644 --- a/endorsement/views/api/google/shared_drive.py +++ b/endorsement/views/api/google/shared_drive.py @@ -3,9 +3,10 @@ from endorsement.models import SharedDriveRecord from endorsement.dao.persistent_messages import get_persistent_messages +from endorsement.dao.itbill import refresh_subscription from endorsement.views.rest_dispatch import ( RESTDispatch, invalid_session, data_not_found, - invalid_endorser, bad_request) + invalid_endorser, bad_request, data_error) from endorsement.exceptions import UnrecognizedUWNetid, InvalidNetID import logging @@ -29,6 +30,15 @@ def get(self, request, *args, **kwargs): return invalid_endorser(logger) drive_id = self.kwargs.get('drive_id') + refresh = request.GET.get('refresh') + + try: + if drive_id and refresh: + refresh_subscription(netid, drive_id) + except Exception as ex: + logger.exception("refresh_subscription: {}".format(ex)) + return data_error(logger, ex) + return self.json_response(self._drive_list(netid, drive_id)) def put(self, request, *args, **kwargs): @@ -41,7 +51,7 @@ def put(self, request, *args, **kwargs): try: drive_id = self.kwargs['drive_id'] - drive = SharedDriveRecord.objects.get_shared_drives_for_netid( + drive = SharedDriveRecord.objects.get_member_drives( netid, drive_id).get() accept = request.data.get('accept') @@ -58,8 +68,7 @@ def put(self, request, *args, **kwargs): return self.json_response(self._drive_list(netid, drive_id)) def _drive_list(self, netid, drive_id=None): - drives = SharedDriveRecord.objects.get_shared_drives_for_netid( - netid, drive_id) + drives = SharedDriveRecord.objects.get_member_drives(netid, drive_id) return { 'drives': [d.json_data() for d in drives], diff --git a/endorsement/views/api/notification.py b/endorsement/views/api/notification.py index 85a70a1b..7b847db4 100644 --- a/endorsement/views/api/notification.py +++ b/endorsement/views/api/notification.py @@ -8,11 +8,12 @@ Accessor, Accessee, AccessRight, AccessRecord) from endorsement.services import endorsement_services, get_endorsement_service from endorsement.util.auth import SupportGroupAuthentication -from endorsement.dao.notification import ( +from endorsement.notifications.endorsement import ( _get_endorsed_unnotified, _create_expire_notice_message, _create_endorsee_message, _create_endorser_message, - _create_warn_shared_owner_message, + _create_warn_shared_owner_message) +from endorsement.notifications.access import ( _create_accessor_message) from endorsement.dao.accessors import get_accessor_email from datetime import datetime, timedelta From ce4d1b88b76dcb9d62e59829297aa0b45d40426c Mon Sep 17 00:00:00 2001 From: mike seibel Date: Sat, 4 May 2024 12:44:41 -0700 Subject: [PATCH 11/26] subscription relationships --- .../fixtures/test_data/itbill_provision.json | 8 ++++ .../fixtures/test_data/itbill_quantity.json | 13 ++++++- .../test_data/itbill_subscription.json | 10 +++++ .../fixtures/test_data/shared_drive.json | 11 ++++++ .../test_data/shared_drive_record.json | 19 +++++++++ .../management/commands/initialize_db.py | 5 ++- .../static/endorsement/css/critical.scss | 14 +++++-- .../static/endorsement/js/tab/google.js | 39 ++++++++++--------- .../handlebars/tab/drives/google.html | 17 +++++++- endorsement/test/api/test_shared_drives.py | 2 +- 10 files changed, 112 insertions(+), 26 deletions(-) diff --git a/endorsement/fixtures/test_data/itbill_provision.json b/endorsement/fixtures/test_data/itbill_provision.json index 85fddc7e..328e6225 100644 --- a/endorsement/fixtures/test_data/itbill_provision.json +++ b/endorsement/fixtures/test_data/itbill_provision.json @@ -14,5 +14,13 @@ "subscription": 2, "current_quantity": 5 } + }, + { + "model": "endorsement.itbillprovision", + "pk": 3, + "fields": { + "subscription": 3, + "current_quantity": 1 + } } ] diff --git a/endorsement/fixtures/test_data/itbill_quantity.json b/endorsement/fixtures/test_data/itbill_quantity.json index 038b71c4..e09d35ac 100644 --- a/endorsement/fixtures/test_data/itbill_quantity.json +++ b/endorsement/fixtures/test_data/itbill_quantity.json @@ -6,7 +6,7 @@ "provision": 1, "quantity": 2, "start_date": "2024-01-01", - "end_date": "2024-12-31", + "end_date": "2024-12-29", "stage": null } }, @@ -20,5 +20,16 @@ "end_date": null, "stage": null } + }, + { + "model": "endorsement.itbillquantity", + "pk": 3, + "fields": { + "provision": 3, + "quantity": 1, + "start_date": null, + "end_date": null, + "stage": null + } } ] diff --git a/endorsement/fixtures/test_data/itbill_subscription.json b/endorsement/fixtures/test_data/itbill_subscription.json index 42d918d6..d27a0a70 100644 --- a/endorsement/fixtures/test_data/itbill_subscription.json +++ b/endorsement/fixtures/test_data/itbill_subscription.json @@ -18,5 +18,15 @@ "query_priority": 0, "query_datetime": null } + }, + { + "model": "endorsement.itbillsubscription", + "pk": 3, + "fields": { + "key_remote": "spw48mh5yutht3431v4d8olsx04yi8dz", + "state": 1, + "query_priority": 0, + "query_datetime": null + } } ] diff --git a/endorsement/fixtures/test_data/shared_drive.json b/endorsement/fixtures/test_data/shared_drive.json index d653186a..056f6efc 100644 --- a/endorsement/fixtures/test_data/shared_drive.json +++ b/endorsement/fixtures/test_data/shared_drive.json @@ -53,5 +53,16 @@ "drive_quota": 4, "members": [1, 2, 4, 5, 7, 8, 9, 10, 11] } + }, + { + "model": "endorsement.shareddrive", + "pk": 6, + "fields": { + "drive_id": "g2ns3fxmo2x8wo6j7e5gloet", + "drive_name": "UX Design Team Drive", + "drive_usage": 359, + "drive_quota": 3, + "members": [1, 4, 5, 7, 10, 11] + } } ] diff --git a/endorsement/fixtures/test_data/shared_drive_record.json b/endorsement/fixtures/test_data/shared_drive_record.json index cef683ab..c266c096 100644 --- a/endorsement/fixtures/test_data/shared_drive_record.json +++ b/endorsement/fixtures/test_data/shared_drive_record.json @@ -93,5 +93,24 @@ "datetime_expired": null, "is_deleted": null } + }, + { + "model": "endorsement.shareddriverecord", + "pk": 6, + "fields": { + "shared_drive": 6, + "subscription": 3, + "acted_as": null, + "datetime_created": "2024-02-12T17:41:28+00:00", + "datetime_emailed": null, + "datetime_notice_1_emailed": null, + "datetime_notice_2_emailed": null, + "datetime_notice_3_emailed": null, + "datetime_notice_4_emailed": null, + "datetime_accepted": "2024-02-12T17:41:28+00:00", + "datetime_renewed": null, + "datetime_expired": null, + "is_deleted": null + } } ] diff --git a/endorsement/management/commands/initialize_db.py b/endorsement/management/commands/initialize_db.py index 634b28a8..63f3f89b 100644 --- a/endorsement/management/commands/initialize_db.py +++ b/endorsement/management/commands/initialize_db.py @@ -4,13 +4,16 @@ from django.core.management.base import BaseCommand from django.core.management import call_command -from endorsement.models import SharedDriveRecord +from endorsement.models import SharedDriveRecord, ITBillQuantity from datetime import datetime, timezone, timedelta class Command(BaseCommand): def handle(self, *args, **options): + # reset quantities + ITBillQuantity.objects.all().delete() + call_command('loaddata', 'test_data/accessright.json') call_command('loaddata', 'test_data/accessee.json') call_command('loaddata', 'test_data/accessor.json') diff --git a/endorsement/static/endorsement/css/critical.scss b/endorsement/static/endorsement/css/critical.scss index b0cecbbe..1eb40ce8 100644 --- a/endorsement/static/endorsement/css/critical.scss +++ b/endorsement/static/endorsement/css/critical.scss @@ -321,11 +321,17 @@ a { } .endorsed-netid, .access-mailbox { width: 12rem; white-space: nowrap; } .endorsed-name, .access-mailbox-name { border-top: none; border-right: 1px solid #ddd; } - .endorsed-status-icon, { width: 20px; } - .shared-drive-status-icon { width: 24px; } - .endorsed-status, .shared-drive-status, .shared-drive-quota { + .endorsed-status-icon, .shared-drive-status-icon { width: 26px; } + .endorsed-status, .shared-drive-status { + padding-left: 6px; width: 15rem; - p { font-size: smaller; width: 10rem; } + p { font-size: smaller; width: 10rem; margin-bottom: 0px; } + } + .shared-drive-quota { padding-left: 24px; width: 12rem; + div { position: relative; width: 14rem; left: -.85rem; + div:first-child { clear: left; float: left; width: 1.65rem; } + div:last-child { font-size: smaller; float: left; width: 10rem; } + } } .endorsed-reason { width: 13rem; } .endorsed-action, .shared-drive-action { width: 12rem; white-space: nowrap; } diff --git a/endorsement/static/endorsement/js/tab/google.js b/endorsement/static/endorsement/js/tab/google.js index 42678830..604df5f1 100644 --- a/endorsement/static/endorsement/js/tab/google.js +++ b/endorsement/static/endorsement/js/tab/google.js @@ -147,28 +147,31 @@ var ManageSharedDrives = (function () { drive.expiration_days = expiration.diff(now, 'days'); drive.expiration_from_now = expiration.from(now); drive.in_flight = (drive.subscription && drive.subscription.query_priority === 'high'); - drive.future_quotas = []; + drive.quota_notes = [{ + is_capped: drive.drive.drive_usage > drive.drive.drive_quota.quota_limit + }]; + if (drive.subscription) { $.each(drive.subscription.provisions, function () { $.each(this.quantities, function () { - var starting = moment(this.start_date), - ending = moment(this.end_date), - is_future = starting.diff(now) > 0, - is_ending = starting.diff(now) < 0 && ending.diff(now) > 0, - is_increasing = this.quota_limit > drive.drive.drive_quota.quota_limit, - is_decreasing = this.quota_limit < drive.drive.drive_quota.quota_limit, - is_changing = (is_future || is_ending); + var starting = this.start_date ? moment(this.start_date) : null, + ending = this.end_date ? moment(this.end_date): null, + is_future = starting && starting.diff(now) > 0, + is_ending = starting && ending && starting.diff(now) < 0 && ending.diff(now) > 0, + is_increasing = this.quota_limit > drive.drive.drive_quota.quota_limit, + is_decreasing = this.quota_limit < drive.drive.drive_quota.quota_limit, + is_changing = (is_future || is_ending); - drive.future_quotas.push({ - is_future: is_future, - is_ending: is_ending, - quota_limit: this.quota_limit, - is_increasing: is_increasing, - is_decreasing: is_decreasing, - is_changing: is_changing, - start_date: moment(this.start_date).format('M/D/YYYY'), - end_date: moment(this.end_date).format('M/D/YYYY') - }); + drive.quota_notes.push({ + is_future: is_future, + is_ending: is_ending, + quota_limit: this.quota_limit, + is_increasing: is_increasing && is_future, + is_decreasing: is_decreasing && is_future, + is_changing: is_changing, + start_date: moment(this.start_date).format('M/D/YYYY'), + end_date: moment(this.end_date).format('M/D/YYYY') + }); }); }); } diff --git a/endorsement/templates/handlebars/tab/drives/google.html b/endorsement/templates/handlebars/tab/drives/google.html index dbba61b7..7b478c10 100644 --- a/endorsement/templates/handlebars/tab/drives/google.html +++ b/endorsement/templates/handlebars/tab/drives/google.html @@ -56,7 +56,22 @@

{{#or drive.drive_quota.is_subsidized subscription}}Renew{{else}}Select quota{{/or}} by {{expiration_date}}{{#lte expiration_days 90}}
({{expiration_from_now}}){{/lte}}

- + + {{else}} diff --git a/endorsement/templates/handlebars/persistent_messages.html b/endorsement/templates/handlebars/persistent_messages.html index 73e0ca5e..548a11c5 100644 --- a/endorsement/templates/handlebars/persistent_messages.html +++ b/endorsement/templates/handlebars/persistent_messages.html @@ -4,7 +4,7 @@
As the provisioner, you are responsible for the proper use, account maintenance, and file privileges of the provision. diff --git a/endorsement/templates/handlebars/revoke.html b/endorsement/templates/handlebars/revoke.html index 27a7dad4..15862d54 100644 --- a/endorsement/templates/handlebars/revoke.html +++ b/endorsement/templates/handlebars/revoke.html @@ -23,7 +23,7 @@
UW NetID UW NetID Name StatusAccess Type Access Type Action
Est Usage Est Usage QuotaManagers Managers Action
{{drive.drive_usage}}GB{{drive.drive_quota.quota_limit}}GB{{drive.drive_quota.quota_limit}}GB{{#each future_quotas}}{{#if is_changing}}

{{#if is_ending}}Ending on {{end_date}}{{else}}{{#if is_future}}{{#if is_increasing}}In{{else}}De{{/if}}creasing to {{quota_limit}}GB on {{start_date}}{{/if}}{{/if}}

{{/if}}{{/each}}
    {{#gt drive.members.length 5}} {{#slice drive.members 0 4}} {{name}}, {{/slice}} -
- {{#if drive.drive_quota.is_subsidized}} @@ -86,6 +89,7 @@ {{/if}} +{{/if}}
{{drive.drive_usage}}GB{{drive.drive_quota.quota_limit}}GB{{#each future_quotas}}{{#if is_changing}}

{{#if is_ending}}Ending on {{end_date}}{{else}}{{#if is_future}}{{#if is_increasing}}In{{else}}De{{/if}}creasing to {{quota_limit}}GB on {{start_date}}{{/if}}{{/if}}

{{/if}}{{/each}}
{{drive.drive_quota.quota_limit}}GB +{{#each quota_notes}} +{{#if is_capped}} +
Drive capped: usage is greater than current quota. Delete data or increase quota to resolve.
+{{else}} + {{#if is_ending}} +
 
Ending on {{end_date}}
+ {{else}} + {{#or is_increasing is_decreasing}} +
 
{{#if is_increasing}}In{{else}}De{{/if}}creasing to {{quota_limit}}GB on {{start_date}}
+ {{/or}} + {{/if}} +{{/if}} +{{/each}} +
    {{#gt drive.members.length 5}} diff --git a/endorsement/test/api/test_shared_drives.py b/endorsement/test/api/test_shared_drives.py index c1f5c7b9..d72a622a 100644 --- a/endorsement/test/api/test_shared_drives.py +++ b/endorsement/test/api/test_shared_drives.py @@ -25,7 +25,7 @@ def test_shared_drives(self): response = self.client.get(url) self.assertEquals(response.status_code, 200) data = json.loads(response.content) - self.assertEqual(len(data['drives']), 5) + self.assertEqual(len(data['drives']), 6) def test_no_shared_drives(self): self.set_user('jinter') From 42daf3497c056be03d8a443e148d77019fe2b3f7 Mon Sep 17 00:00:00 2001 From: mike seibel Date: Wed, 8 May 2024 12:22:20 -0700 Subject: [PATCH 12/26] shared drive email warnings and tests --- endorsement/dao/shared_drive.py | 7 + endorsement/fixtures/test_data/member.json | 22 +-- .../management/commands/expire_endorsees.py | 2 +- .../migrations/0028_auto_20240506_1259.py | 23 +++ endorsement/models/shared_drive.py | 20 ++- endorsement/notifications/access.py | 1 + endorsement/notifications/endorsement.py | 3 +- endorsement/notifications/shared_drive.py | 70 ++++++++ endorsement/policy/access.py | 125 +++++++++++++ .../{policy.py => policy/endorsement.py} | 10 +- endorsement/policy/shared_drive.py | 125 +++++++++++++ .../email/shared_drive/notice_warning.html | 21 +++ .../email/shared_drive/notice_warning.txt | 17 ++ .../shared_drive/notice_warning_final.html | 18 ++ .../shared_drive/notice_warning_final.txt | 18 ++ .../handlebars/tab/drives/google.html | 6 +- .../templates/support/notifications.html | 38 +++- endorsement/test/notifications/__init__.py | 52 ++++++ .../test_drive_expiration_warnings.py | 104 +++++++++++ .../notifications/test_expiration_warnings.py | 120 +++++++++++++ endorsement/test/test_expiration_warnings.py | 166 ------------------ endorsement/views/api/notification.py | 36 +++- 22 files changed, 808 insertions(+), 196 deletions(-) create mode 100644 endorsement/migrations/0028_auto_20240506_1259.py create mode 100644 endorsement/notifications/shared_drive.py create mode 100644 endorsement/policy/access.py rename endorsement/{policy.py => policy/endorsement.py} (90%) create mode 100644 endorsement/policy/shared_drive.py create mode 100644 endorsement/templates/email/shared_drive/notice_warning.html create mode 100644 endorsement/templates/email/shared_drive/notice_warning.txt create mode 100644 endorsement/templates/email/shared_drive/notice_warning_final.html create mode 100644 endorsement/templates/email/shared_drive/notice_warning_final.txt create mode 100644 endorsement/test/notifications/__init__.py create mode 100644 endorsement/test/notifications/test_drive_expiration_warnings.py create mode 100644 endorsement/test/notifications/test_expiration_warnings.py delete mode 100644 endorsement/test/test_expiration_warnings.py diff --git a/endorsement/dao/shared_drive.py b/endorsement/dao/shared_drive.py index e5c385aa..0f9058a5 100644 --- a/endorsement/dao/shared_drive.py +++ b/endorsement/dao/shared_drive.py @@ -15,6 +15,13 @@ r'^(?P[^@]+)@(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 diff --git a/endorsement/fixtures/test_data/member.json b/endorsement/fixtures/test_data/member.json index ab79fdba..758bfea1 100644 --- a/endorsement/fixtures/test_data/member.json +++ b/endorsement/fixtures/test_data/member.json @@ -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": "yahoodood@yahoo.com"}}, + "netid": "yahoodood@yahoo.com"}}, { "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"}}] diff --git a/endorsement/management/commands/expire_endorsees.py b/endorsement/management/commands/expire_endorsees.py index 670a265b..41b66fbe 100644 --- a/endorsement/management/commands/expire_endorsees.py +++ b/endorsement/management/commands/expire_endorsees.py @@ -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 diff --git a/endorsement/migrations/0028_auto_20240506_1259.py b/endorsement/migrations/0028_auto_20240506_1259.py new file mode 100644 index 00000000..6a856df7 --- /dev/null +++ b/endorsement/migrations/0028_auto_20240506_1259.py @@ -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'), + ), + ] diff --git a/endorsement/models/shared_drive.py b/endorsement/models/shared_drive.py index 0c3174f5..97a745b2 100644 --- a/endorsement/models/shared_drive.py +++ b/endorsement/models/shared_drive.py @@ -11,8 +11,8 @@ 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 @@ -20,10 +20,10 @@ 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: diff --git a/endorsement/notifications/access.py b/endorsement/notifications/access.py index 9d8645cb..f7e3fa79 100644 --- a/endorsement/notifications/access.py +++ b/endorsement/notifications/access.py @@ -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 diff --git a/endorsement/notifications/endorsement.py b/endorsement/notifications/endorsement.py index 8c3e8c25..020b4cc6 100644 --- a/endorsement/notifications/endorsement.py +++ b/endorsement/notifications/endorsement.py @@ -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 diff --git a/endorsement/notifications/shared_drive.py b/endorsement/notifications/shared_drive.py new file mode 100644 index 00000000..3e21b80c --- /dev/null +++ b/endorsement/notifications/shared_drive.py @@ -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 diff --git a/endorsement/policy/access.py b/endorsement/policy/access.py new file mode 100644 index 00000000..7cdbdbe7 --- /dev/null +++ b/endorsement/policy/access.py @@ -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 diff --git a/endorsement/policy.py b/endorsement/policy/endorsement.py similarity index 90% rename from endorsement/policy.py rename to endorsement/policy/endorsement.py index 422ec20e..8190c82c 100644 --- a/endorsement/policy.py +++ b/endorsement/policy/endorsement.py @@ -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, diff --git a/endorsement/policy/shared_drive.py b/endorsement/policy/shared_drive.py new file mode 100644 index 00000000..dc0a8040 --- /dev/null +++ b/endorsement/policy/shared_drive.py @@ -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 diff --git a/endorsement/templates/email/shared_drive/notice_warning.html b/endorsement/templates/email/shared_drive/notice_warning.html new file mode 100644 index 00000000..6d3f1a95 --- /dev/null +++ b/endorsement/templates/email/shared_drive/notice_warning.html @@ -0,0 +1,21 @@ +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: +

    +

    +Click here to log in to the Provisioning Request Tool (PRT) to renew expiring services. +

    +

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

    +Thank you,
    +UW-IT
    diff --git a/endorsement/templates/email/shared_drive/notice_warning.txt b/endorsement/templates/email/shared_drive/notice_warning.txt new file mode 100644 index 00000000..aaf57334 --- /dev/null +++ b/endorsement/templates/email/shared_drive/notice_warning.txt @@ -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 diff --git a/endorsement/templates/email/shared_drive/notice_warning_final.html b/endorsement/templates/email/shared_drive/notice_warning_final.html new file mode 100644 index 00000000..91caddc9 --- /dev/null +++ b/endorsement/templates/email/shared_drive/notice_warning_final.html @@ -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. +

    +

    +Click here to log in to the Provisioning Request Tool (PRT) to renew expiring services. +

    +

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

    +Thank you,
    +UW-IT
    diff --git a/endorsement/templates/email/shared_drive/notice_warning_final.txt b/endorsement/templates/email/shared_drive/notice_warning_final.txt new file mode 100644 index 00000000..dcb97352 --- /dev/null +++ b/endorsement/templates/email/shared_drive/notice_warning_final.txt @@ -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 diff --git a/endorsement/templates/handlebars/tab/drives/google.html b/endorsement/templates/handlebars/tab/drives/google.html index 7b478c10..c22b3d32 100644 --- a/endorsement/templates/handlebars/tab/drives/google.html +++ b/endorsement/templates/handlebars/tab/drives/google.html @@ -76,13 +76,13 @@
      {{#gt drive.members.length 5}} {{#slice drive.members 0 4}} - {{name}}, + {{netid}}, {{/slice}} -
    diff --git a/endorsement/templates/support/notifications.html b/endorsement/templates/support/notifications.html index edafdee9..953e8f6f 100644 --- a/endorsement/templates/support/notifications.html +++ b/endorsement/templates/support/notifications.html @@ -27,7 +27,8 @@

    Lifecycle Notifications

      -
    • Services
    • +
    • Services for NetIDs
    • +
    • Google Shared Drives
    • Elevated Access
    @@ -96,6 +97,41 @@

    Provisioned Services

    -->
    +
    +

    + 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. +

    + +
    + {% csrf_token %} +
    +

    Notification Message Type

    +
    + +
    +
    + +
    + First renewal notice sent to provisioner {{ warning_1 }} days prior to service expiration. +
    +
    + Second renewal notice sent to provisioner {{ warning_2 }} days prior to service expiration. +
    +
    + Third and final renewal notice sent to provisioner {{ warning_3 }} days before expiration. +
    +
    + Notice that provisioned services have expired sent to the provisioner on expiration day. +
    +
    + +

    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. diff --git a/endorsement/test/notifications/__init__.py b/endorsement/test/notifications/__init__.py new file mode 100644 index 00000000..6cf39286 --- /dev/null +++ b/endorsement/test/notifications/__init__.py @@ -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]) diff --git a/endorsement/test/notifications/test_drive_expiration_warnings.py b/endorsement/test/notifications/test_drive_expiration_warnings.py new file mode 100644 index 00000000..efe50f48 --- /dev/null +++ b/endorsement/test/notifications/test_drive_expiration_warnings.py @@ -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) diff --git a/endorsement/test/notifications/test_expiration_warnings.py b/endorsement/test/notifications/test_expiration_warnings.py new file mode 100644 index 00000000..5c9cc215 --- /dev/null +++ b/endorsement/test/notifications/test_expiration_warnings.py @@ -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) diff --git a/endorsement/test/test_expiration_warnings.py b/endorsement/test/test_expiration_warnings.py deleted file mode 100644 index eb760ff7..00000000 --- a/endorsement/test/test_expiration_warnings.py +++ /dev/null @@ -1,166 +0,0 @@ -# Copyright 2024 UW-IT, University of Washington -# SPDX-License-Identifier: Apache-2.0 - -from django.test import TestCase -from django.utils import timezone -from django.core import mail -from django.db.models import F -from endorsement.models import Endorser, Endorsee, EndorsementRecord -from endorsement.policy 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(TestCase): - def setUp(self): - now = timezone.now() - timedelta(hours=1) - 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) - - # expire date long ago - EndorsementRecord.objects.create( - endorser=self.endorser1, endorsee=self.endorsee1, - category_code=o365.category_code, - reason="Just Because", - datetime_endorsed=now - timedelta(days=( - o365.endorsement_lifetime + 200))) - # expire date today - EndorsementRecord.objects.create( - endorser=self.endorser1, endorsee=self.endorsee1, - category_code=google.category_code, reason="Just Because", - datetime_endorsed=now - timedelta(days=( - google.endorsement_lifetime))) - # expire date tomorrow - EndorsementRecord.objects.create( - endorser=self.endorser2, endorsee=self.endorsee2, - category_code=o365.category_code, - reason="I said so", - datetime_endorsed=now - timedelta(days=( - o365.endorsement_lifetime - 1))) - - def _notice_and_expire(self, now, expected): - endorsements = _endorsements_to_expire(now) - self.assertEqual(len(endorsements), expected[0]) - - endorsements = _endorsements_to_warn(now, 1) - self.assertEqual(len(endorsements), expected[1]) - endorsements.update(datetime_notice_1_emailed=now) - - endorsements = _endorsements_to_warn(now, 2) - self.assertEqual(len(endorsements), expected[2]) - endorsements.update(datetime_notice_2_emailed=now) - - endorsements = _endorsements_to_warn(now, 3) - self.assertEqual(len(endorsements), expected[3]) - endorsements.update(datetime_notice_3_emailed=now) - - endorsements = _endorsements_to_warn(now, 4) - self.assertEqual(len(endorsements), expected[4]) - endorsements.update(datetime_notice_4_emailed=now) - - def test_expiration_and_notices(self): - # use first service to get lifecycle dates - service = get_endorsement_service('o365') - - # notice one days prior to expiration: - # two first notices, no expirations - now = timezone.now() - timedelta( - days=service.endorsement_expiration_warning(1)) - self._notice_and_expire(now, [0, 2, 0, 0, 0]) - - # next day: one first notice, no expirations - self._notice_and_expire(now + timedelta(days=1), [0, 1, 0, 0, 0]) - - # 30 days later: no notices, no expiration - self._notice_and_expire(now + timedelta(days=30), [0, 0, 0, 0, 0]) - - # notice two days prior: two second notices, no expiration - now += timedelta( - days=(service.endorsement_expiration_warning(1) - - service.endorsement_expiration_warning(2) + 1)) - self._notice_and_expire(now, [0, 0, 2, 0, 0]) - - # next day: one second notice, no expiration - self._notice_and_expire(now + timedelta(days=1), [0, 0, 1, 0, 0]) - - # next day: no notice, no expiration - self._notice_and_expire(now + timedelta(days=2), [0, 0, 0, 0, 0]) - - # notice three days prior: two third notices, no expiration - now += timedelta( - days=(service.endorsement_expiration_warning(2) - - service.endorsement_expiration_warning(3) + 1)) - self._notice_and_expire(now, [0, 0, 0, 2, 0]) - - # next day: one third notice, no expiration - self._notice_and_expire(now + timedelta(days=1), [0, 0, 0, 1, 0]) - - # next day: no notices, no expiration - self._notice_and_expire(now + timedelta(days=2), [0, 0, 0, 0, 0]) - - # expiration day: two fourth notices, no expiration - now += timedelta( - days=(service.endorsement_expiration_warning(3) - - service.endorsement_expiration_warning(4) + 1)) - self._notice_and_expire(now, [0, 0, 0, 0, 2]) - - # next day: one fourth notices, no expiration - self._notice_and_expire(now + timedelta(days=1), [0, 0, 0, 0, 1]) - - # 89 days forward: no notices, no expiration - now += timedelta(days=service.endorsement_graceperiod) - self._notice_and_expire(now, [0, 0, 0, 0, 0]) - - # next day forward: no notices, two expirations - now += timedelta(days=1) - self._notice_and_expire(now, [2, 0, 0, 0, 0]) - - # next day forward: no notices, three expirations - now += timedelta(days=1) - self._notice_and_expire(now, [3, 0, 0, 0, 0]) - - 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) diff --git a/endorsement/views/api/notification.py b/endorsement/views/api/notification.py index 7b847db4..076286f6 100644 --- a/endorsement/views/api/notification.py +++ b/endorsement/views/api/notification.py @@ -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): From d4d5a11a1d0d43320bda0da3244b9ac0a56067e9 Mon Sep 17 00:00:00 2001 From: mike seibel Date: Wed, 8 May 2024 13:58:08 -0700 Subject: [PATCH 13/26] shared drive notification templating --- .../migrations/0029_auto_20240508_1240.py | 20 +++++ .../migrations/0030_shareddriveacceptance.py | 26 ++++++ endorsement/models/shared_drive.py | 82 +++++++++++-------- endorsement/notifications/shared_drive.py | 3 +- .../email/shared_drive/notice_warning.html | 8 +- .../email/shared_drive/notice_warning.txt | 10 +-- .../shared_drive/notice_warning_final.html | 5 +- .../shared_drive/notice_warning_final.txt | 4 +- endorsement/views/api/notification.py | 13 +-- 9 files changed, 114 insertions(+), 57 deletions(-) create mode 100644 endorsement/migrations/0029_auto_20240508_1240.py create mode 100644 endorsement/migrations/0030_shareddriveacceptance.py diff --git a/endorsement/migrations/0029_auto_20240508_1240.py b/endorsement/migrations/0029_auto_20240508_1240.py new file mode 100644 index 00000000..990438d0 --- /dev/null +++ b/endorsement/migrations/0029_auto_20240508_1240.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.25 on 2024-05-08 19:40 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('endorsement', '0028_auto_20240506_1259'), + ] + + operations = [ + migrations.RemoveField( + model_name='shareddriverecord', + name='acceptance', + ), + migrations.DeleteModel( + name='SharedDriveAcceptance', + ), + ] diff --git a/endorsement/migrations/0030_shareddriveacceptance.py b/endorsement/migrations/0030_shareddriveacceptance.py new file mode 100644 index 00000000..a03dce14 --- /dev/null +++ b/endorsement/migrations/0030_shareddriveacceptance.py @@ -0,0 +1,26 @@ +# Generated by Django 3.2.25 on 2024-05-08 19:42 + +from django.db import migrations, models +import django.db.models.deletion +import django_prometheus.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('endorsement', '0029_auto_20240508_1240'), + ] + + operations = [ + migrations.CreateModel( + name='SharedDriveAcceptance', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('action', models.SmallIntegerField(choices=[(0, 'Accept'), (1, 'Revoke')], default=0)), + ('datetime_accepted', models.DateTimeField(auto_now_add=True)), + ('member', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='endorsement.member')), + ('shared_drive_record', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='endorsement.shareddriverecord')), + ], + bases=(django_prometheus.models.ExportModelOperationsMixin('shared_drive_acceptance'), models.Model), + ), + ] diff --git a/endorsement/models/shared_drive.py b/endorsement/models/shared_drive.py index 97a745b2..729f1abe 100644 --- a/endorsement/models/shared_drive.py +++ b/endorsement/models/shared_drive.py @@ -114,37 +114,6 @@ def json_data(self): def __str__(self): return json.dumps(self.json_data()) - - -class SharedDriveAcceptance( - ExportModelOperationsMixin('shared_drive_acceptance'), models.Model): - """ - SharedDriveAcceptance model records each instance of a shared drive - record being accepted or revoked by a shared drive manager. - """ - ACCEPT = 0 - REVOKE = 1 - - ACCEPTANCE_ACTION_CHOICES = ( - (ACCEPT, "Accept"), - (REVOKE, "Revoke")) - - member = models.ForeignKey(Member, on_delete=models.PROTECT) - action = models.SmallIntegerField( - default=ACCEPT, choices=ACCEPTANCE_ACTION_CHOICES) - datetime_accepted = models.DateTimeField(auto_now_add=True) - - def json_data(self): - return { - "member": self.member.json_data(), - "action": self.ACCEPTANCE_ACTION_CHOICES[self.action][1], - "datetime_accepted": datetime_to_str(self.datetime_accepted) - } - - def __str__(self): - return json.dumps(self.json_data()) - - class SharedDriveRecordManager(models.Manager): def get_member_drives(self, member_netid, drive_id=None): parms = { @@ -183,7 +152,6 @@ class SharedDriveRecord( datetime_notice_4_emailed = models.DateTimeField(null=True) datetime_renewed = models.DateTimeField(null=True) datetime_expired = models.DateTimeField(null=True) - acceptance = models.ManyToManyField(SharedDriveAcceptance, blank=True) is_deleted = models.BooleanField(null=True) objects = SharedDriveRecordManager() @@ -210,20 +178,31 @@ def itbill_form_url(self): f"&remote_key={self.subscription.key_remote}" f"&shared_drive={self.shared_drive.drive_name}") + @property + def acceptor(self): + if not self.datetime_accepted: + return None + + return SharedDriveAcceptance.objects.get( + shared_drive_record=self, + datetime_accepted=self.datetime_accepted) + + def get_acceptance(self): + return SharedDriveAcceptance.objects.filter( + shared_drive_record=self) + def set_acceptance(self, member_netid, accept=True): member = Member.objects.get_member(member_netid) action = SharedDriveAcceptance.ACCEPT if ( accept) else SharedDriveAcceptance.REVOKE acceptance = SharedDriveAcceptance.objects.create( - member=member, action=action) - self.acceptance.add(acceptance) + shared_drive_record=self, member=member, action=action) if accept: self.datetime_accepted = acceptance.datetime_accepted else: self.datetime_expired = acceptance.datetime_accepted - self.is_deleted = True self.save() @@ -251,6 +230,7 @@ def json_data(self): "datetime_notice_4_emailed": datetime_to_str( self.datetime_notice_4_emailed), "datetime_accepted": datetime_to_str(self.datetime_accepted), + "acceptance": [a.json_data() for a in self.get_acceptance()], "datetime_renewed": datetime_to_str(self.datetime_renewed), "datetime_expired": datetime_to_str(self.datetime_expired), "datetime_expiration": datetime_to_str(self.expiration_date), @@ -259,3 +239,35 @@ def json_data(self): def __str__(self): return json.dumps(self.json_data()) + + +class SharedDriveAcceptance( + ExportModelOperationsMixin('shared_drive_acceptance'), models.Model): + """ + SharedDriveAcceptance model records each instance of a shared drive + record being accepted or revoked by a shared drive manager. + """ + ACCEPT = 0 + REVOKE = 1 + + ACCEPTANCE_ACTION_CHOICES = ( + (ACCEPT, "Accept"), + (REVOKE, "Revoke")) + + shared_drive_record = models.ForeignKey( + SharedDriveRecord, on_delete=models.PROTECT) + member = models.ForeignKey( + Member, on_delete=models.PROTECT) + action = models.SmallIntegerField( + default=ACCEPT, choices=ACCEPTANCE_ACTION_CHOICES) + datetime_accepted = models.DateTimeField(auto_now_add=True) + + def json_data(self): + return { + "member": self.member.json_data(), + "action": self.ACCEPTANCE_ACTION_CHOICES[self.action][1], + "datetime_accepted": datetime_to_str(self.datetime_accepted) + } + + def __str__(self): + return json.dumps(self.json_data()) diff --git a/endorsement/notifications/shared_drive.py b/endorsement/notifications/shared_drive.py index 3e21b80c..81ff14a6 100644 --- a/endorsement/notifications/shared_drive.py +++ b/endorsement/notifications/shared_drive.py @@ -23,9 +23,10 @@ def _email_template(template_name): return "email/shared_drive/{}".format(template_name) -def _create_expire_notice_message(notice_level, lifetime, drive): +def _create_notification_expiration_notice(notice_level, lifetime, drive): context = { 'drive': drive, + 'acceptor': drive.acceptor, 'lifetime': lifetime, 'notice_time': expiration_warning(notice_level) } diff --git a/endorsement/templates/email/shared_drive/notice_warning.html b/endorsement/templates/email/shared_drive/notice_warning.html index 6d3f1a95..3e7ad506 100644 --- a/endorsement/templates/email/shared_drive/notice_warning.html +++ b/endorsement/templates/email/shared_drive/notice_warning.html @@ -1,12 +1,14 @@ Hello,

    -You are receiving this email because you are identified as a Manager of the -Google Shared Drive "{{drive.shared_drive.drive_name}}". +You are receiving this email because you are identified as +{%if drive.shared_drive.members.all.count > 0 %}one of {{ drive.shared_drive.members.all.count }}{% else %}the{% endif %} UW manager{{drive.shared_drive.members.all.count|pluralize}} of the Google Shared Drive named +"{{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. +was last acknowledged by the netid {{acceptor.member.netid}} on {{acceptor.datetime_accepted|date:"M j, Y"}}, +and is now within {{notice_time}} days of its required renewal.

    To log in to the Provisioning Request Tool (PRT) and renew the expiring shared drive visit: diff --git a/endorsement/templates/email/shared_drive/notice_warning.txt b/endorsement/templates/email/shared_drive/notice_warning.txt index aaf57334..1ceb0930 100644 --- a/endorsement/templates/email/shared_drive/notice_warning.txt +++ b/endorsement/templates/email/shared_drive/notice_warning.txt @@ -1,11 +1,11 @@ Hello, -You are receiving this email because you are identified as a Manager of the -Google Shared Drive "{{drive.shared_drive.drive_name}}". +You are receiving this email because you are identified as {%if drive.shared_drive.members.all.count > 0 %}one of {{ drive.shared_drive.members.all.count }}{% else %}the{% endif %} UW manager{{drive.shared_drive.members.all.count|pluralize}} +of the Google Shared Drive named "{{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 maintain service, Google Shared Drive use must be actively acknowledged +every {{lifetime}} days. The drive "{{drive.shared_drive.drive_name}}" was last acknowledged by +the netid {{acceptor.member.netid}} on {{acceptor.datetime_accepted|date:"M j, Y"}}, and is now within {{notice_time}} days of its required renewal. To log in to the Provisioning Request Tool (PRT) and renew the expiring shared drive visit: diff --git a/endorsement/templates/email/shared_drive/notice_warning_final.html b/endorsement/templates/email/shared_drive/notice_warning_final.html index 91caddc9..3cba8403 100644 --- a/endorsement/templates/email/shared_drive/notice_warning_final.html +++ b/endorsement/templates/email/shared_drive/notice_warning_final.html @@ -1,7 +1,8 @@ Hello,

    -You are receiving this email because you are identified as a Manager of the -Google Shared Drive "{{drive.shared_drive.drive_name}}". +You are receiving this email because you are identified as +{%if drive.shared_drive.members.all.count > 0 %}one of {{ drive.shared_drive.members.all.count }}{% else %}the{% endif %} UW manager{{drive.shared_drive.members.all.count|pluralize}} of the Google Shared Drive named +"{{drive.shared_drive.drive_name}}".

    To maintain service, Google Shared Drive use must be actively acknowledged diff --git a/endorsement/templates/email/shared_drive/notice_warning_final.txt b/endorsement/templates/email/shared_drive/notice_warning_final.txt index dcb97352..c81eec6f 100644 --- a/endorsement/templates/email/shared_drive/notice_warning_final.txt +++ b/endorsement/templates/email/shared_drive/notice_warning_final.txt @@ -1,7 +1,7 @@ Hello, -You are receiving this email because you are identified as a Manager of the -Google Shared Drive "{{drive.shared_drive.drive_name}}". +You are receiving this email because you are identified as {%if drive.shared_drive.members.all.count > 0 %}one of {{ drive.shared_drive.members.all.count }}{% else %}the{% endif %} UW manager{{drive.shared_drive.members.all.count|pluralize}} +of the Google Shared Drive named "{{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}}" diff --git a/endorsement/views/api/notification.py b/endorsement/views/api/notification.py index 076286f6..7313769d 100644 --- a/endorsement/views/api/notification.py +++ b/endorsement/views/api/notification.py @@ -18,7 +18,7 @@ from endorsement.notifications.access import ( _create_accessor_message) from endorsement.notifications.shared_drive import ( - _create_expire_notice_message) + _create_notification_expiration_notice) from endorsement.dao.accessors import get_accessor_email from datetime import datetime, timedelta import re @@ -176,16 +176,11 @@ def _shared_drive_notification(self, request): 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) + record = SharedDriveRecord.objects.filter( + is_deleted__isnull=True).first() if warning_level: - subject, text, html = _create_expire_notice_message( + subject, text, html = _create_notification_expiration_notice( warning_level, 365, record) else: return self.error_response(400, "Unknown notification.") From e6c7768e1a7fd24f9c664063b785aecb69b3ee07 Mon Sep 17 00:00:00 2001 From: mike seibel Date: Wed, 8 May 2024 14:15:58 -0700 Subject: [PATCH 14/26] shared drive warning email tweaking --- endorsement/models/shared_drive.py | 9 ++++++--- .../templates/email/shared_drive/notice_warning.html | 7 +++---- .../templates/email/shared_drive/notice_warning.txt | 4 ++-- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/endorsement/models/shared_drive.py b/endorsement/models/shared_drive.py index 729f1abe..3bc41525 100644 --- a/endorsement/models/shared_drive.py +++ b/endorsement/models/shared_drive.py @@ -183,9 +183,12 @@ def acceptor(self): if not self.datetime_accepted: return None - return SharedDriveAcceptance.objects.get( - shared_drive_record=self, - datetime_accepted=self.datetime_accepted) + try: + return SharedDriveAcceptance.objects.get( + shared_drive_record=self, + datetime_accepted=self.datetime_accepted) + except SharedDriveAcceptance.DoesNotExist: + return None def get_acceptance(self): return SharedDriveAcceptance.objects.filter( diff --git a/endorsement/templates/email/shared_drive/notice_warning.html b/endorsement/templates/email/shared_drive/notice_warning.html index 3e7ad506..da2424c5 100644 --- a/endorsement/templates/email/shared_drive/notice_warning.html +++ b/endorsement/templates/email/shared_drive/notice_warning.html @@ -5,10 +5,9 @@ "{{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}}" -was last acknowledged by the netid {{acceptor.member.netid}} on {{acceptor.datetime_accepted|date:"M j, Y"}}, -and is now within {{notice_time}} days of its required renewal. +To maintain service, Google Shared Drive use must be actively acknowledged +every {{lifetime}} days. The drive "{{drive.shared_drive.drive_name}}"{%if acceptor %}, last acknowledged by +the netid {{acceptor.member.netid}} on {{acceptor.datetime_accepted|date:"M j, Y"}},{% endif %} is now within {{notice_time}} days of its required renewal.

    To log in to the Provisioning Request Tool (PRT) and renew the expiring shared drive visit: diff --git a/endorsement/templates/email/shared_drive/notice_warning.txt b/endorsement/templates/email/shared_drive/notice_warning.txt index 1ceb0930..d44dc56b 100644 --- a/endorsement/templates/email/shared_drive/notice_warning.txt +++ b/endorsement/templates/email/shared_drive/notice_warning.txt @@ -4,8 +4,8 @@ You are receiving this email because you are identified as {%if drive.shared_dri of the Google Shared Drive named "{{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}}" was last acknowledged by -the netid {{acceptor.member.netid}} on {{acceptor.datetime_accepted|date:"M j, Y"}}, and is now within {{notice_time}} days of its required renewal. +every {{lifetime}} days. The drive "{{drive.shared_drive.drive_name}}"{%if acceptor %}, last acknowledged by +the netid {{acceptor.member.netid}} on {{acceptor.datetime_accepted|date:"M j, Y"}},{% endif %} is now within {{notice_time}} days of its required renewal. To log in to the Provisioning Request Tool (PRT) and renew the expiring shared drive visit: From 8649e0cafac4ff789f893366d9d4c7ef3460ad31 Mon Sep 17 00:00:00 2001 From: mike seibel Date: Wed, 8 May 2024 14:50:42 -0700 Subject: [PATCH 15/26] misnamed metho --- endorsement/notifications/shared_drive.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/endorsement/notifications/shared_drive.py b/endorsement/notifications/shared_drive.py index 81ff14a6..f39fa48c 100644 --- a/endorsement/notifications/shared_drive.py +++ b/endorsement/notifications/shared_drive.py @@ -56,7 +56,7 @@ def warn_members(notice_level): netid) in drive.shared_drive.get_members()] (subject, text_body, - html_body) = _create_expire_notice_message( + html_body) = _create_notification_expiration_notice( notice_level, lifetime, drive) send_notification( members, subject, text_body, html_body, From 1978096e416443943b13e2fc4b3a897390164577 Mon Sep 17 00:00:00 2001 From: mike seibel Date: Thu, 9 May 2024 16:47:35 -0700 Subject: [PATCH 16/26] consolidate notification logic, add access lifecycle warnings --- endorsement/models/access.py | 3 +- endorsement/models/base.py | 64 ++++++++++ endorsement/models/core.py | 3 +- endorsement/models/shared_drive.py | 5 +- endorsement/notifications/__init__.py | 0 endorsement/notifications/access.py | 47 +++++++ endorsement/notifications/endorsement.py | 61 ++------- endorsement/notifications/shared_drive.py | 16 ++- endorsement/policy/__init__.py | 109 ++++++++++++++++ endorsement/policy/access.py | 115 ++--------------- endorsement/policy/endorsement.py | 101 +++------------ endorsement/policy/shared_drive.py | 116 ++---------------- endorsement/services/__init__.py | 30 ----- .../static/endorsement/js/notifications.js | 14 +++ .../email/access/notice_warning.html | 28 +++++ .../templates/email/access/notice_warning.txt | 22 ++++ .../email/access/notice_warning_final.html | 24 ++++ .../email/access/notice_warning_final.txt | 21 ++++ .../templates/support/notifications.html | 2 +- endorsement/test/notifications/__init__.py | 40 +++--- .../test_access_expiration_warnings.py | 102 +++++++++++++++ .../test_drive_expiration_warnings.py | 19 ++- .../notifications/test_expiration_warnings.py | 26 ++-- endorsement/views/api/notification.py | 21 +++- endorsement/views/support/notifications.py | 14 +-- 25 files changed, 557 insertions(+), 446 deletions(-) create mode 100644 endorsement/models/base.py create mode 100644 endorsement/notifications/__init__.py create mode 100644 endorsement/policy/__init__.py create mode 100644 endorsement/templates/email/access/notice_warning.html create mode 100644 endorsement/templates/email/access/notice_warning.txt create mode 100644 endorsement/templates/email/access/notice_warning_final.html create mode 100644 endorsement/templates/email/access/notice_warning_final.txt create mode 100644 endorsement/test/notifications/test_access_expiration_warnings.py diff --git a/endorsement/models/access.py b/endorsement/models/access.py index fd08a074..957a8c6c 100644 --- a/endorsement/models/access.py +++ b/endorsement/models/access.py @@ -4,6 +4,7 @@ from django.db import models from django.utils import timezone from django_prometheus.models import ExportModelOperationsMixin +from endorsement.models.base import RecordManagerBase from endorsement.util.date import datetime_to_str import json @@ -81,7 +82,7 @@ class Meta: db_table = 'uw_service_endorsement_access_right' -class AccessRecordManager(models.Manager): +class AccessRecordManager(RecordManagerBase): def get_access(self, accessor=None, accessee=None): params = { 'is_deleted__isnull': True diff --git a/endorsement/models/base.py b/endorsement/models/base.py new file mode 100644 index 00000000..23a2f090 --- /dev/null +++ b/endorsement/models/base.py @@ -0,0 +1,64 @@ +# Copyright 2024 UW-IT, University of Washington +# SPDX-License-Identifier: Apache-2.0 + +from django.db import models +from django.db.models import Q + + +class RecordManagerBase(models.Manager): + def get_records_to_warn(self, now, level, policy): + """ + Gather provision 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)) + + days_prior = policy.days_till_expiration(level) + params = { + 'is_deleted__isnull': True + } + + params.update(policy.additional_warning_terms()) + + if level == 1: + warn_date = policy.expiration_warning_date(now, level) + params.update({ + f"{policy.datetime_provisioned_key}__lte": warn_date, + 'datetime_notice_1_emailed__isnull': True + }) + else: + prev_warning_date = policy.prior_warning_date(now, level) + + if level == 2: + params.update({ + 'datetime_notice_1_emailed__lte': prev_warning_date, + 'datetime_notice_2_emailed__isnull': True + }) + elif level == 3: + params.update({ + 'datetime_notice_2_emailed__lte': prev_warning_date, + 'datetime_notice_3_emailed__isnull': True + }) + else: + params.update({ + 'datetime_notice_3_emailed__lte': prev_warning_date, + 'datetime_notice_4_emailed__isnull': True + }) + + return self.filter(Q(**params)) + + def get_records_to_expire(self, now, policy): + params = { + 'datetime_notice_4_emailed__lte': policy.expiration_date(now), + 'datetime_notice_3_emailed__isnull': False, + 'datetime_notice_2_emailed__isnull': False, + 'datetime_notice_1_emailed__isnull': False, + 'is_deleted__isnull': True + } + + params.update(policy.additional_warning_terms()) + + return self.filter(Q(**params)) diff --git a/endorsement/models/core.py b/endorsement/models/core.py index e50a830e..9282661c 100644 --- a/endorsement/models/core.py +++ b/endorsement/models/core.py @@ -7,6 +7,7 @@ from django.utils import timezone from django_prometheus.models import ExportModelOperationsMixin from uw_uwnetid.models import Category +from endorsement.models.base import RecordManagerBase from endorsement.util.date import datetime_to_str import hashlib import random @@ -105,7 +106,7 @@ class Meta: db_table = 'uw_service_endorsement_endorsee_email' -class EndorsementRecordManager(models.Manager): +class EndorsementRecordManager(RecordManagerBase): def get_endorsement(self, endorser=None, endorsee=None, category_code=None): params = { diff --git a/endorsement/models/shared_drive.py b/endorsement/models/shared_drive.py index 3bc41525..acf64b49 100644 --- a/endorsement/models/shared_drive.py +++ b/endorsement/models/shared_drive.py @@ -5,6 +5,7 @@ from django.conf import settings from django.utils import timezone from django_prometheus.models import ExportModelOperationsMixin +from endorsement.models.base import RecordManagerBase from endorsement.models.itbill import ITBillSubscription from endorsement.util.date import datetime_to_str import json @@ -114,7 +115,9 @@ def json_data(self): def __str__(self): return json.dumps(self.json_data()) -class SharedDriveRecordManager(models.Manager): + + +class SharedDriveRecordManager(RecordManagerBase): def get_member_drives(self, member_netid, drive_id=None): parms = { "shared_drive__members__member__netid": member_netid, diff --git a/endorsement/notifications/__init__.py b/endorsement/notifications/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/endorsement/notifications/access.py b/endorsement/notifications/access.py index f7e3fa79..c3da97c4 100644 --- a/endorsement/notifications/access.py +++ b/endorsement/notifications/access.py @@ -2,10 +2,13 @@ # SPDX-License-Identifier: Apache-2.0 from endorsement.models import AccessRecord +from endorsement.policy.access import AccessPolicy +from endorsement.util.email import uw_email_address 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 +from django.utils import timezone import logging @@ -56,3 +59,47 @@ def _create_accessor_message(access_record, emails): loader.render_to_string(html_template, params)) +def _create_accessee_expiration_notice(notice_level, access, policy): + context = { + 'access': access, + 'lifetime': policy.lifetime, + 'notice_time': policy.days_till_expiration(notice_level) + } + + if notice_level < 4: + subject = ("Action Required: Office 365 Shared Mailbox " + "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_accessees(notice_level): + policy = AccessPolicy() + drives = policy.records_to_warn(notice_level) + + for drive in drives: + try: + email = [uw_email_address(drive.accessee.netid)] + (subject, + text_body, + html_body) = _create_accessee_expiration_notice( + notice_level, drive, policy) + send_notification( + email, subject, text_body, html_body, + "Mailbox Access Warning") + + sent_date = { + 'datetime_notice_{}_emailed'.format( + notice_level): timezone.now() + } + drives.update(**sent_date) + except EmailFailureException as ex: + pass diff --git a/endorsement/notifications/endorsement.py b/endorsement/notifications/endorsement.py index 020b4cc6..1f014742 100644 --- a/endorsement/notifications/endorsement.py +++ b/endorsement/notifications/endorsement.py @@ -8,7 +8,7 @@ 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.endorsement import endorsements_to_warn +from endorsement.policy.endorsement import EndorsementPolicy from endorsement.util.email import uw_email_address from endorsement.util.string import listed_list from endorsement.exceptions import EmailFailureException @@ -21,36 +21,6 @@ logger = logging.getLogger(__name__) -# This function is a monument to technical debt and intended -# to blow up tests as soon as the first endorsemnnt service lifecycle -# definition strays from current common set of values -def confirm_common_lifecyle_values(): - lifetime = None - warnings = None - - for service in endorsement_services(): - if lifetime is None: - lifetime = service.endorsement_lifetime - else: - if lifetime != service.endorsement_lifetime: - raise Exception( - "Messaging does not support mixed service lifetimes") - - if warnings is None: - warnings = [ - service.endorsement_expiration_warning(1), - service.endorsement_expiration_warning(2), - service.endorsement_expiration_warning(3), - service.endorsement_expiration_warning(4), - ] - elif (warnings[0] != service.endorsement_expiration_warning(1) or - warnings[1] != service.endorsement_expiration_warning(2) or - warnings[2] != service.endorsement_expiration_warning(3) or - warnings[3] != service.endorsement_expiration_warning(4)): - raise Exception( - "Messaging does not support mismatched service warning spans") - - def _email_template(template_name): return "email/endorsement/{}".format(template_name) @@ -269,14 +239,13 @@ def notify_invalid_endorser(invalid_endorsements): pass -def _create_expire_notice_message(notice_level, lifetime, endorsed): +def _create_expire_notice_message(notice_level, endorsed, policy): category_codes = list(set([e.category_code for e in endorsed])) services = [get_endorsement_service(c) for c in category_codes] context = { 'endorser': endorsed[0].endorser, - 'lifetime': lifetime, - 'notice_time': services[0].endorsement_expiration_warning( - notice_level), + 'lifetime': policy.lifetime, + 'notice_time': policy.days_till_expiration(notice_level), 'expiring': endorsed, 'expiring_count': len(set(e.endorsee.netid for e in endorsed)), 'impacts': [] @@ -320,13 +289,10 @@ def _create_expire_notice_message(notice_level, lifetime, endorsed): def warn_endorsers(notice_level): - confirm_common_lifecyle_values() + policy = EndorsementPolicy() + endorsements = policy.records_to_warn(notice_level) - endorsements = endorsements_to_warn(notice_level) - - lifetime = endorsement_services()[0].endorsement_lifetime - - if len(endorsements): + if endorsements.count(): endorsers = {} for e in endorsements: endorsers[e.endorser.id] = 1 @@ -341,7 +307,7 @@ def warn_endorsers(notice_level): (subject, text_body, html_body) = _create_expire_notice_message( - notice_level, lifetime, endorsed) + notice_level, endorsed, policy) send_notification( [email], subject, text_body, html_body, "Invalid endorser") @@ -358,12 +324,11 @@ def warn_new_shared_netid_owner(new_owner, endorsements): if not (endorsements and len(endorsements) > 0): return - confirm_common_lifecyle_values() - + policy = EndorsementPolicy() sent_date = timezone.now() email = uw_email_address(new_owner.netid) (subject, text_body, html_body) = _create_warn_shared_owner_message( - new_owner, endorsements) + new_owner, endorsements, policy) send_notification( [email], subject, text_body, html_body, @@ -374,12 +339,12 @@ def warn_new_shared_netid_owner(new_owner, endorsements): endorsement.save() -def _create_warn_shared_owner_message(owner_netid, endorsements): +def _create_warn_shared_owner_message(owner_netid, endorsements, policy): service = get_endorsement_service(endorsements[0].category_code) context = { 'endorser': owner_netid, - 'lifetime': service.endorsement_lifetime, - 'notice_time': service.endorsement_expiration_warning(1), + 'lifetime': policy.lifetime, + 'notice_time': policy.days_till_expiration(1), 'expiring': endorsements, 'expiring_count': len(endorsements) } diff --git a/endorsement/notifications/shared_drive.py b/endorsement/notifications/shared_drive.py index f39fa48c..9e566457 100644 --- a/endorsement/notifications/shared_drive.py +++ b/endorsement/notifications/shared_drive.py @@ -5,9 +5,7 @@ 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.policy.shared_drive import SharedDrivePolicy from endorsement.util.email import uw_email_address from endorsement.exceptions import EmailFailureException from django.template import loader, Template, Context @@ -23,12 +21,12 @@ def _email_template(template_name): return "email/shared_drive/{}".format(template_name) -def _create_notification_expiration_notice(notice_level, lifetime, drive): +def _create_notification_expiration_notice(notice_level, drive, policy): context = { 'drive': drive, 'acceptor': drive.acceptor, - 'lifetime': lifetime, - 'notice_time': expiration_warning(notice_level) + 'lifetime': policy.lifetime, + 'notice_time': policy.days_till_expiration(notice_level) } if notice_level < 4: @@ -47,8 +45,8 @@ def _create_notification_expiration_notice(notice_level, lifetime, drive): def warn_members(notice_level): - drives = shared_drives_to_warn(notice_level) - lifetime = DEFAULT_SHARED_DRIVE_LIFETIME + policy = SharedDrivePolicy() + drives = policy.records_to_warn(notice_level) for drive in drives: try: @@ -57,7 +55,7 @@ def warn_members(notice_level): (subject, text_body, html_body) = _create_notification_expiration_notice( - notice_level, lifetime, drive) + notice_level, drive, policy) send_notification( members, subject, text_body, html_body, "Shared Drive Warning") diff --git a/endorsement/policy/__init__.py b/endorsement/policy/__init__.py new file mode 100644 index 00000000..3ddcb5a9 --- /dev/null +++ b/endorsement/policy/__init__.py @@ -0,0 +1,109 @@ +# Copyright 2024 UW-IT, University of Washington +# SPDX-License-Identifier: Apache-2.0 + +""" +Provisioned service lifecycle policy base class +""" +from abc import ABC, abstractmethod +from django.utils import timezone +from datetime import timedelta + + +# Default lifecycle day counts +DEFAULT_LIFETIME = 365 +DEFAULT_GRACEPERIOD = 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 + + +class PolicyBase(ABC): + @property + @abstractmethod + def record_model(self): + """Provision Record subect to policy""" + pass + + @property + @abstractmethod + def datetime_provisioned_key(self): + """Model Field storing datetime the service was provisioned""" + pass + + @property + def lifetime(self): + return DEFAULT_LIFETIME + + @property + def graceperiod(self): + return DEFAULT_GRACEPERIOD + + @property + def warning_1(self): + """ day of lifecycle for initial expiration notice""" + return PRIOR_DAYS_NOTICE_WARNING_1 + + @property + def warning_2(self): + """ day of lifecycle for second expiration notice""" + return PRIOR_DAYS_NOTICE_WARNING_2 + + @property + def warning_3(self): + """ day of lifecycle for final expiration notice""" + return PRIOR_DAYS_NOTICE_WARNING_3 + + @property + def warning_4(self): + """ day of lifecycle expired notice""" + return PRIOR_DAYS_NOTICE_WARNING_4 + + def days_till_expiration(self, level): + if level == 1: + return self.warning_1 + elif level == 2: + return self.warning_2 + elif level == 3: + return self.warning_3 + elif level == 4: + return self.warning_4 + + raise Exception('bad warning level {}'.format(level)) + + def expiration_warning_date(self, now, level): + days_prior = self.days_till_expiration(level) + return now - timedelta(days=self.lifetime - days_prior) + + def expiration_date(self, now): + return now - timedelta(days=self.graceperiod) + + def prior_warning_date(self, now, level): + days_prior = self.days_till_expiration(level) + prev_days_prior = self.days_till_expiration(level - 1) + prev_warning_date = now - timedelta(days=prev_days_prior - days_prior) + return prev_warning_date + + def additional_warning_terms(self): + """ + service-specific query terms to include in warning/expiration queries + """ + return {} + + def records_to_warn(self, level): + return self.records_to_warn_on_date(timezone.now(), level) + + def records_to_warn_on_date(self, now, level): + """ + Return query set of shared provision records to warn + """ + return self.record_model.objects.get_records_to_warn(now, level, self) + + def records_to_expire(self): + return self.records_to_expire_on_date(timezone.now()) + + def records_to_expire_on_date(self, now): + """ + Return query set of provision records to expire + """ + return self.record_model.objects.get_records_to_expire(now, self) diff --git a/endorsement/policy/access.py b/endorsement/policy/access.py index 7cdbdbe7..fd4251c8 100644 --- a/endorsement/policy/access.py +++ b/endorsement/policy/access.py @@ -8,118 +8,23 @@ * 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.policy import PolicyBase 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 +class AccessPolicy(PolicyBase): + @property + def record_model(self): + return AccessRecord - -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) + @property + def datetime_provisioned_key(self): + return "datetime_granted" 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 + for a in access_to_expire(gracetime, lifetime): + revoke_access(a) diff --git a/endorsement/policy/endorsement.py b/endorsement/policy/endorsement.py index 8190c82c..2dc01f31 100644 --- a/endorsement/policy/endorsement.py +++ b/endorsement/policy/endorsement.py @@ -10,98 +10,37 @@ * The expiration clock starts on the date of the first warning notice. * An expiration grace period is defined by each service """ -from django.utils import timezone -from django.db.models import Q -from endorsement.services import endorsement_services from endorsement.models import EndorsementRecord +from endorsement.policy import PolicyBase +from endorsement.services import endorsement_services from endorsement.dao.endorse import clear_endorsement -from datetime import timedelta - - -def endorsements_to_warn(level): - """ - """ - return _endorsements_to_warn(timezone.now(), level) - -def _endorsements_to_warn(now, level): - """ - Gather endorsement 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() +DEFAULT_ENDORSEMENT_GRACEPERIOD = 90 - # select on appropriate time span for warning notice index (level) - for service in endorsement_services(): - days_prior = service.endorsement_expiration_warning(level) - if days_prior is None: - continue - if level == 1: - endorsed = now - timedelta( - days=service.endorsement_lifetime - days_prior) - q = q | Q(datetime_endorsed__lte=endorsed, - datetime_notice_1_emailed__isnull=True, - category_code=service.category_code, - is_deleted__isnull=True) - else: - prev_days_prior = service.endorsement_expiration_warning(level - 1) - prev_warning_date = now - timedelta( - days=prev_days_prior - days_prior) +class EndorsementPolicy(PolicyBase): + @property + def record_model(self): + return EndorsementRecord - if level == 2: - 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__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__lte=prev_warning_date, - datetime_notice_4_emailed__isnull=True, - category_code=service.category_code, - is_deleted__isnull=True) - - return EndorsementRecord.objects.filter(q) - - -def endorsements_to_expire(): - """ - Return query set of endorsement records to expire - """ - return _endorsements_to_expire(timezone.now()) - - -def _endorsements_to_expire(now): - """ - Return query set of endorsements to expire for each service - """ - q = Q() + @property + def datetime_provisioned_key(self): + return "datetime_endorsed" - for service in endorsement_services(): - expiration_date = now - timedelta(days=service.endorsement_graceperiod) - 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, - category_code=service.category_code, - is_deleted__isnull=True) + @property + def graceperiod(self): + return DEFAULT_ENDORSEMENT_GRACEPERIOD - return EndorsementRecord.objects.filter(q) + def additional_warning_terms(self): + return { + 'category_code__in': [ + s.category_code for s in endorsement_services()] + } def expire_endorsments(gracetime, lifetime): """ """ - endorsements = endorsements_to_expire(gracetime, lifetime) - if len(endorsements): - for e in endorsements: - clear_endorsement(e) + for e in endorsements_to_expire(gracetime, lifetime): + clear_endorsement(e) diff --git a/endorsement/policy/shared_drive.py b/endorsement/policy/shared_drive.py index dc0a8040..41232649 100644 --- a/endorsement/policy/shared_drive.py +++ b/endorsement/policy/shared_drive.py @@ -8,118 +8,24 @@ * 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.policy import PolicyBase 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) +class SharedDrivePolicy(PolicyBase): + @property + def record_model(self): + return SharedDriveRecord - 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) + @property + def datetime_provisioned_key(self): + return "datetime_accepted" 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 + for drive in shared_drives_to_expire(gracetime, lifetime): + shared_drive_lifecycle_expired(drive) diff --git a/endorsement/services/__init__.py b/endorsement/services/__init__.py index 45360387..097033c0 100644 --- a/endorsement/services/__init__.py +++ b/endorsement/services/__init__.py @@ -40,10 +40,6 @@ # Default lifecycle day counts DEFAULT_ENDORSEMENT_LIFETIME = 365 DEFAULT_ENDORSEMENT_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 class EndorsementServiceBase(ABC): @@ -106,14 +102,6 @@ def category_name(self): """Service's presentable name""" return dict(ER.CATEGORY_CODE_CHOICES)[self.category_code] - @property - def endorsement_lifetime(self): - return DEFAULT_ENDORSEMENT_LIFETIME - - @property - def endorsement_graceperiod(self): - return DEFAULT_ENDORSEMENT_GRACETIME - def get_endorsement(self, endorser, endorsee): return get_endorsement(endorser, endorsee, self.category_code) @@ -237,24 +225,6 @@ def store_endorsement(self, endorser, endorsee, acted_as, reason): def clear_endorsement(self, endorser, endorsee): return clear_endorsement(self.get_endorsement(endorser, endorsee)) - def endorsement_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 - def is_valid_endorser(uwnetid): """ diff --git a/endorsement/static/endorsement/js/notifications.js b/endorsement/static/endorsement/js/notifications.js index 55254f0e..f5d63848 100644 --- a/endorsement/static/endorsement/js/notifications.js +++ b/endorsement/static/endorsement/js/notifications.js @@ -37,6 +37,11 @@ var registerEvents = function() { generateNotification('service'); }); + $('.tabs div#shared_drive').on('endorse:shared_driveTabExposed', function (e) { + showInfoMessage($('div#shared_drive select#notification option:selected').val()); + generateNotification('shared_drive'); + }); + $('.tabs div#access').on('endorse:accessTabExposed', function (e) { if (window.access.hasOwnProperty('office') && window.access.office.hasOwnProperty('types')) { renderAccessTypes(); @@ -138,6 +143,15 @@ var generateNotification = function (notice_type) { data.right_name = $access_type.text(); data.is_group = (delegate_type == 'group'); data.is_shared_netid = (delegate_type == 'shared'); + } else if (notice_type == 'shared_drive') { + var notification = $("select#notification option:selected").val(); + + if (data.notification === '') { + $('#notification_result').html(""); + return; + } + + data.notification = notification; } $.ajax({ diff --git a/endorsement/templates/email/access/notice_warning.html b/endorsement/templates/email/access/notice_warning.html new file mode 100644 index 00000000..0229bfff --- /dev/null +++ b/endorsement/templates/email/access/notice_warning.html @@ -0,0 +1,28 @@ +Hello {{access.accessee.display_name}} ({{access.accessee.netid}}), +

    +You are receiving this message because you used the Provisioning Request +Tool (PRT) to provision access to a UW Office 365 Exchange Online mailbox +for another UW NetID. +

    +

    +Access to the mailbox lasts for {{lifetime}} days and must be renewed or +revoked annually. {%if notice_time < 8%}{%endif%}Unless you take action to renew access, access to a +mailbox for the following UW NetID will be revoked in {{notice_time}} days.{%if notice_time < 8%}{%endif%} +

    + + + + + + + + +
    MailboxNetID/Group with AccessDate ProvisionedAccess Type
    {{access.accessee.netid}}{{access.accessor.name}}{{access.datetime_granted|date:"M d, Y"}}{{access.access_right.display_name}}
    +

    +Click here to log in to the Provisioning Request Tool (PRT) to renew expiring services. +

    +

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

    +Thank you,
    +UW-IT
    diff --git a/endorsement/templates/email/access/notice_warning.txt b/endorsement/templates/email/access/notice_warning.txt new file mode 100644 index 00000000..0efb5e98 --- /dev/null +++ b/endorsement/templates/email/access/notice_warning.txt @@ -0,0 +1,22 @@ +Hello {{access.accessee.display_name}} ({{access.accessee.netid}}), + +You are receiving this message because you used the Provisioning Request +Tool (PRT) to provision access to a UW Office 365 Exchange Online mailbox +for another UW NetID. + +Access to the mailbox lasts for {{lifetime}} days and must be renewed or +revoked annually. Unless you take action to renew access, access to a +mailbox for the following UW NetID will be revoked in {{notice_time}} days. + +Mailbox NetID/Group with Access Date Provisioned Access Type +{{access.accessee.netid|ljust:"14"}}{{access.accessor.name|ljust:"30"}}{{access.datetime_granted|date:"M d, Y"|ljust:"19"}}{{access.access_right.display_name}} + +To log in to the Provisioning Request Tool (PRT) to renew expiring access to +this mailbox 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 diff --git a/endorsement/templates/email/access/notice_warning_final.html b/endorsement/templates/email/access/notice_warning_final.html new file mode 100644 index 00000000..873c1ad4 --- /dev/null +++ b/endorsement/templates/email/access/notice_warning_final.html @@ -0,0 +1,24 @@ +Hello, +

    +You are receiving this message because you used the Provisioning Request Tool (PRT) to provision access to a UW Office 365 Exchange Online mailbox for another UW NetID. +

    +

    +Access to the mailbox lasts for {{lifetime}} days and must be renewed or revoked within that time. +

    + + + + + + + + +
    MailboxNetID/Group with AccessDate ProvisionedAccess Type
    {{access.accessee.netid}}{{access.accessor.name}}{{access.datetime_granted|date:"M d, Y"}}{{access.access_right.display_name}}
    +

    +Log into the Provisioning Request Tool (PRT) to renew access. +

    +

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

    +Thank you,
    +UW-IT
    diff --git a/endorsement/templates/email/access/notice_warning_final.txt b/endorsement/templates/email/access/notice_warning_final.txt new file mode 100644 index 00000000..7890236c --- /dev/null +++ b/endorsement/templates/email/access/notice_warning_final.txt @@ -0,0 +1,21 @@ +Hello, + +xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +You are receiving this message because you used the Provisioning Request +Tool (PRT) to provision access to a UW Office 365 Exchange Online mailbox +for another UW NetID. + +Access to the mailbox lasts for {{lifetime}} days and must be renewed or +revoked within that time. + +Mailbox NetID/Group with Access Date Provisioned Access Type +{{access.accessee.netid|ljust:"14"}}{{access.accessor.name|ljust:"30"}}{{access.datetime_granted|date:"M d, Y"|ljust:"19"}}{{access.access_right.display_name}} + +To log in to the Provisioning Request Tool (PRT) and renew access 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 diff --git a/endorsement/templates/support/notifications.html b/endorsement/templates/support/notifications.html index 953e8f6f..9f255d78 100644 --- a/endorsement/templates/support/notifications.html +++ b/endorsement/templates/support/notifications.html @@ -97,7 +97,7 @@

    Provisioned Services

    --> -
    +

    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.

    diff --git a/endorsement/test/notifications/__init__.py b/endorsement/test/notifications/__init__.py index 6cf39286..2da8036b 100644 --- a/endorsement/test/notifications/__init__.py +++ b/endorsement/test/notifications/__init__.py @@ -10,41 +10,41 @@ class NotificationsTestCase(TestCase): def days_ago(self, days): return self.now - timedelta(days=days) - def notice_and_expire_test(self, expirer, warner, offset, expected): + def notice_and_expire_test(self, offset, expected): test_date = self.now - timedelta(days=offset[1]) - days = self.lifetime - offset[1] + days = self.policy.lifetime - offset[1] - models = expirer(test_date) + models = self.policy.records_to_expire_on_date(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) + models = self.policy.records_to_warn_on_date(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): + def message_timing(self, 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))] + ('one', self.policy.days_till_expiration(1)), + ('one + 1 day', self.policy.days_till_expiration(1) - 1), + ('two - 1 day', self.policy.days_till_expiration(2) + 1), + ('two', self.policy.days_till_expiration(2)), + ('two + 1 day', self.policy.days_till_expiration(2) - 1), + ('three - 1 day', self.policy.days_till_expiration(3) + 1), + ('three', self.policy.days_till_expiration(3)), + ('three + 1 day', self.policy.days_till_expiration(3) - 1), + ('four - 1 day', self.policy.days_till_expiration(4) + 1), + ('four', self.policy.days_till_expiration(4)), + ('four + 1 day', self.policy.days_till_expiration(4) - 1), + ('four + 2 days', self.policy.days_till_expiration(4) - 2), + ('grace - 1 day', -(self.policy.graceperiod - 1)), + ('grace', -(self.policy.graceperiod)), + ('grace + 1 day', -(self.policy.graceperiod + 1))] self.assertEqual(len(offsets), len(results)) diff --git a/endorsement/test/notifications/test_access_expiration_warnings.py b/endorsement/test/notifications/test_access_expiration_warnings.py new file mode 100644 index 00000000..76716f81 --- /dev/null +++ b/endorsement/test/notifications/test_access_expiration_warnings.py @@ -0,0 +1,102 @@ +# 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 AccessRecord, Accessee, Accessor +from endorsement.policy.access import AccessPolicy +from endorsement.notifications.access import warn_accessees +from datetime import timedelta + + +class TestSharedDriveExpirationNotices(NotificationsTestCase): + def setUp(self): + self.now = timezone.now() + self.policy = AccessPolicy() + + accessee1 = Accessee.objects.create( + netid='accessee1', regid='dddddddddddddddddddddddddddddddd', + display_name='Accessee One') + accessee2 = Accessee.objects.create( + netid='accessee2', regid='eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + display_name='Accessee Two') + accessee3 = Accessee.objects.create( + netid='accessee3', regid='ffffffffffffffffffffffffffffffff', + display_name='Accessee Three') + + accessor1 = Accessor.objects.create( + name='accessor1', display_name='Accessor One') + accessor2 = Accessor.objects.create( + name='accessor2', display_name='Accessor Two') + accessor3 = Accessor.objects.create( + name='accessor3', display_name='Accessor Three') + + + # accepted date long ago + AccessRecord.objects.create( + accessor=accessor1, accessee=accessee1, + datetime_granted=self.days_ago(self.policy.lifetime + 200)) + + # expire date today + AccessRecord.objects.create( + accessor=accessor2, accessee=accessee2, + datetime_granted=self.days_ago(self.policy.lifetime)) + + # expire date tomorrow + AccessRecord.objects.create( + accessor=accessor3, accessee=accessee3, + datetime_granted=self.days_ago(self.policy.lifetime - 1)) + + def notice_and_expire(self, offset_days, expected_results): + self.notice_and_expire_test(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(expected_results) + + def test_expiration_and_notice_email(self): + warn_accessees(1) + self.assertEqual(len(mail.outbox), 3) + + AccessRecord.objects.filter( + datetime_notice_1_emailed__isnull=False).update( + datetime_notice_1_emailed=F( + 'datetime_notice_1_emailed')-timedelta(days=61)) + + warn_accessees(2) + self.assertEqual(len(mail.outbox), 6) + + AccessRecord.objects.filter( + datetime_notice_2_emailed__isnull=False).update( + datetime_notice_2_emailed=F( + 'datetime_notice_2_emailed')-timedelta(days=30)) + + warn_accessees(3) + self.assertEqual(len(mail.outbox), 9) + + AccessRecord.objects.filter( + datetime_notice_3_emailed__isnull=False).update( + datetime_notice_3_emailed=F( + 'datetime_notice_2_emailed')-timedelta(days=23)) + + warn_accessees(4) + self.assertEqual(len(mail.outbox), 12) diff --git a/endorsement/test/notifications/test_drive_expiration_warnings.py b/endorsement/test/notifications/test_drive_expiration_warnings.py index efe50f48..4af844c2 100644 --- a/endorsement/test/notifications/test_drive_expiration_warnings.py +++ b/endorsement/test/notifications/test_drive_expiration_warnings.py @@ -7,9 +7,7 @@ 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.policy.shared_drive import SharedDrivePolicy from endorsement.notifications.shared_drive import warn_members from datetime import timedelta @@ -29,31 +27,28 @@ class TestSharedDriveExpirationNotices(NotificationsTestCase): def setUp(self): self.now = timezone.now() - self.lifetime = DEFAULT_SHARED_DRIVE_LIFETIME - self.graceperiod = DEFAULT_SHARED_DRIVE_GRACETIME + self.policy = SharedDrivePolicy() # 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.datetime_accepted = self.days_ago(self.policy.lifetime + 200) drive.save() # expire date today drive = SharedDriveRecord.objects.get(pk=2) - drive.datetime_accepted = self.days_ago(self.lifetime) + drive.datetime_accepted = self.days_ago(self.policy.lifetime) drive.save() # expire date tomorrow drive = SharedDriveRecord.objects.get(pk=6) - drive.datetime_accepted = self.days_ago(self.lifetime - 1) + drive.datetime_accepted = self.days_ago(self.policy.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) + self.notice_and_expire_test(offset_days, expected_results) def test_expiration_and_notices(self): expected_results = [ @@ -73,7 +68,7 @@ def test_expiration_and_notices(self): [0, 0, 0, 0, 0], # grace [0, 0, 0, 0, 0]] # grace plus a day - self.message_timing(expiration_warning, expected_results) + self.message_timing(expected_results) def test_expiration_and_notice_email(self): warn_members(1) diff --git a/endorsement/test/notifications/test_expiration_warnings.py b/endorsement/test/notifications/test_expiration_warnings.py index 5c9cc215..a6d221b3 100644 --- a/endorsement/test/notifications/test_expiration_warnings.py +++ b/endorsement/test/notifications/test_expiration_warnings.py @@ -7,8 +7,7 @@ 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.policy.endorsement import EndorsementPolicy from endorsement.services import get_endorsement_service from endorsement.notifications.endorsement import warn_endorsers from datetime import timedelta @@ -17,6 +16,7 @@ class TestProvisioneExpirationNotices(NotificationsTestCase): def setUp(self): self.now = timezone.now() + self.policy = EndorsementPolicy() self.endorser1 = Endorser.objects.create( netid='endorser1', regid='aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', @@ -31,45 +31,34 @@ def setUp(self): 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)) + datetime_endorsed=self.days_ago(self.policy.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)) + datetime_endorsed=self.days_ago(self.policy.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)) + datetime_endorsed=self.days_ago(self.policy.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) + self.notice_and_expire_test(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 @@ -88,8 +77,7 @@ def test_expiration_and_notices(self): [2, 0, 0, 0, 0], # grace [1, 0, 0, 0, 0]] # grace plus a day - self.message_timing( - service.endorsement_expiration_warning, expected_results) + self.message_timing(expected_results) def test_expiration_and_notice_email(self): warn_endorsers(1) diff --git a/endorsement/views/api/notification.py b/endorsement/views/api/notification.py index 7313769d..a5cb9541 100644 --- a/endorsement/views/api/notification.py +++ b/endorsement/views/api/notification.py @@ -9,6 +9,9 @@ Member, Role, SharedDriveMember, SharedDrive, SharedDriveQuota, SharedDriveRecord, SharedDriveRecord) from endorsement.services import endorsement_services, get_endorsement_service +from endorsement.policy.endorsement import EndorsementPolicy +from endorsement.policy.shared_drive import SharedDrivePolicy +from endorsement.policy.access import AccessPolicy from endorsement.util.auth import SupportGroupAuthentication from endorsement.notifications.endorsement import ( _get_endorsed_unnotified, @@ -16,7 +19,7 @@ _create_endorsee_message, _create_endorser_message, _create_warn_shared_owner_message) from endorsement.notifications.access import ( - _create_accessor_message) + _create_accessor_message, _create_accessee_expiration_notice) from endorsement.notifications.shared_drive import ( _create_notification_expiration_notice) from endorsement.dao.accessors import get_accessor_email @@ -100,7 +103,7 @@ def _service_notification(self, request): try: if warning_level: subject, text, html = _create_expire_notice_message( - warning_level, 365, endorsed) + warning_level, endorsed, EndorsementPolicy()) elif notification == 'endorsee': unendorsed = _get_unendorsed_unnotified(endorsed) for email, endorsers in unendorsed.items(): @@ -115,7 +118,7 @@ def _service_notification(self, request): break elif notification == 'new_shared': subject, text, html = _create_warn_shared_owner_message( - endorser, endorsed) + endorser, endorsed, EndorsementPolicy()) else: return self.error_response( 405, "unknown notification: {}".format(notification)) @@ -154,11 +157,19 @@ def _access_notification(self, request): return self.error_response(400, "Unknown access right.") ar = AccessRecord( - accessee=accessee, accessor=accessor, access_right=access_right) + accessee=accessee, accessor=accessor, + access_right=access_right, datetime_granted=datetime.now()) + + warnings = ['warning_1', 'warning_2', 'warning_3', 'warning_4'] if notification == 'delegate': (subject, text_body, html_body) = _create_accessor_message( ar, get_accessor_email(ar)) + elif notification in warnings: + (subject, + text_body, + html_body) = _create_accessee_expiration_notice( + warnings.index(notification) + 1, ar, AccessPolicy()) else: return self.error_response(400, "Unknown notification.") @@ -181,7 +192,7 @@ def _shared_drive_notification(self, request): if warning_level: subject, text, html = _create_notification_expiration_notice( - warning_level, 365, record) + warning_level, record, SharedDrivePolicy()) else: return self.error_response(400, "Unknown notification.") diff --git a/endorsement/views/support/notifications.py b/endorsement/views/support/notifications.py index 5fadd4ec..08c67f9c 100644 --- a/endorsement/views/support/notifications.py +++ b/endorsement/views/support/notifications.py @@ -7,6 +7,7 @@ from django.views.generic.base import TemplateView from uw_saml.decorators import group_required from endorsement.views.support import set_admin_wrapper_template +from endorsement.policy.endorsement import EndorsementPolicy from endorsement.services import endorsement_services @@ -22,14 +23,11 @@ def get_context_data(self, **kwargs): context['services'] = dict([(s.service_name, { 'name': s.category_name }) for s in endorsement_services()]) - context['warning_1'] = endorsement_services()[ - 0].endorsement_expiration_warning(1) - context['warning_2'] = endorsement_services()[ - 0].endorsement_expiration_warning(2) - context['warning_3'] = endorsement_services()[ - 0].endorsement_expiration_warning(3) - context['warning_4'] = endorsement_services()[ - 0].endorsement_expiration_warning(4) + policy = EndorsementPolicy() + context['warning_1'] = policy.days_till_expiration(1) + context['warning_2'] = policy.days_till_expiration(2) + context['warning_3'] = policy.days_till_expiration(3) + context['warning_4'] = policy.days_till_expiration(4) set_admin_wrapper_template(context) return context From 9de06816e5e615fead5a7f27c8c36648f38e7672 Mon Sep 17 00:00:00 2001 From: mike seibel Date: Fri, 10 May 2024 08:52:31 -0700 Subject: [PATCH 17/26] setting for conditional app exposure, prod value to prevent unintentional release --- docker-compose.yml | 1 + docker/prod-values.yml | 2 ++ docker/settings.py | 3 +++ endorsement/static/endorsement/js/notify.js | 4 ++-- endorsement/static/endorsement/js/tab/google.js | 10 +++++++++- endorsement/templates/index.html | 16 ++++++++++++++++ endorsement/views/page.py | 5 ++++- 7 files changed, 37 insertions(+), 4 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 38ada88d..622d6ece 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,7 @@ services: ITBILL_FORM_URL_BASE: https://uwdev.service-now.com/sp ITBILL_FORM_URL_BASE_ID: sc_cat_item ITBILL_SHARED_DRIVE_PRODUCT_SYS_ID: 7078586b2f6cb076cad75ae9aab3ea05 + XENDORSEMENT_PROVISIONING: "services, shared_drives" restart: always container_name: app-provision build: diff --git a/docker/prod-values.yml b/docker/prod-values.yml index 460a790d..a8a0f229 100644 --- a/docker/prod-values.yml +++ b/docker/prod-values.yml @@ -105,6 +105,8 @@ environmentVariables: value: https://provision.uw.edu/sso/ - name: CLUSTER_CNAME value: provision.uw.edu + - name: ENDORSEMENT_PROVISIONING + value: services externalSecrets: enabled: true secrets: diff --git a/docker/settings.py b/docker/settings.py index e4b94c31..3bf120b2 100644 --- a/docker/settings.py +++ b/docker/settings.py @@ -6,6 +6,9 @@ ENDORSEMENT_SERVICES = [s.strip() for s in os.getenv( 'ENDORSEMENT_SERVICES', '*').split(',')] +ENDORSEMENT_PROVISIONING = [s.strip() for s in os.getenv( + 'ENDORSEMENT_PROVISIONING', '*').split(',')] + ALLOWED_HOSTS = ['*'] CACHES = { diff --git a/endorsement/static/endorsement/js/notify.js b/endorsement/static/endorsement/js/notify.js index d78ccace..ac4d4aea 100644 --- a/endorsement/static/endorsement/js/notify.js +++ b/endorsement/static/endorsement/js/notify.js @@ -64,8 +64,8 @@ var Notify = (function () { }; return { - success: function (msg) { - _notify(msg, 'alert-success'); + success: function (msg, fade=3500) { + _notify(msg, 'alert-success', fade); }, error: function (msg) { diff --git a/endorsement/static/endorsement/js/tab/google.js b/endorsement/static/endorsement/js/tab/google.js index 604df5f1..c4dff3be 100644 --- a/endorsement/static/endorsement/js/tab/google.js +++ b/endorsement/static/endorsement/js/tab/google.js @@ -51,8 +51,16 @@ var ManageSharedDrives = (function () { }).on('endorse:SharedDriveRefreshError', function (e, error) { Notify.error('Sorry, but subscription information unavailable at this time: ' + error); }).on('endorse:SharedDriveResponsibilityAccepted', function (e, data) { + var drive = (data.drives && data.drives.length === 1) ? data.drives[0] : null; + + if (!drive) { + Notify.error('Error retrieving renewal result.'); + return; + } + _modalHide(); - _updateSharedDrivesDiplay(data.drives[0]); + _updateSharedDrivesDiplay(drive); + Notify.success('Shared drive "' + drive.drive.drive_name + '" provision renewed.', 10000); }).on('endorse:SharedDriveResponsibilityAcceptedError', function (e, error) { Notify.error('Sorry, but we cannot accept responsibility at this time: ' + error); }).on('change', '#shared_drive_modal input', function () { diff --git a/endorsement/templates/index.html b/endorsement/templates/index.html index 7f92b35e..7dbbf644 100644 --- a/endorsement/templates/index.html +++ b/endorsement/templates/index.html @@ -10,12 +10,21 @@

    Manage Provisions

    Grant, revoke, and renew access to UW-IT services for UW NetIDs you own and UW NetIDs owned by others.

    +{% if provisioning|length > 1 or '*' in provisioning %}
      + {% if '*' in provisioning or 'services' in provisioning %}
    • Services for NetIds
    • + {% endif %} + {% if '*' in provisioning or 'shared_drives' in provisioning %}
    • Google Shared Drives
    • + {% endif %} + {% if '*' in provisioning or 'mailbox_access' in provisioning %}
    • Elevated Access
    • + {% endif %}
    +{%endif%} + {% if '*' in provisioning or 'services' in provisioning %}
    @@ -31,13 +40,20 @@

    UW NetIDs owned by others

    + {% endif %} + {% if '*' in provisioning or 'shared_drives' in provisioning %}
    + {% endif %} + {% if '*' in provisioning or 'mailbox_access' in provisioning %}
    + {% endif %} +{% if provisioning|length > 1 or '*' in provisioning %}
    +{% endif %} {% include "handlebars/reasons_partial.html" %} {% include "handlebars/email_editor_partial.html" %} diff --git a/endorsement/views/page.py b/endorsement/views/page.py index e0da31a5..465b7a34 100644 --- a/endorsement/views/page.py +++ b/endorsement/views/page.py @@ -1,6 +1,7 @@ # Copyright 2024 UW-IT, University of Washington # SPDX-License-Identifier: Apache-2.0 +from django.conf import settings from django.http import HttpResponseRedirect from django.shortcuts import render from django.contrib.auth.decorators import login_required @@ -38,7 +39,9 @@ def index(request): }, 'services': json.dumps(service_contexts()), 'override_user': user_service.get_override_user(), - 'support_override_user': is_only_support_user(request) + 'support_override_user': is_only_support_user(request), + 'provisioning': getattr( + settings, 'ENDORSEMENT_PROVISIONING', ['*']) } if not (is_valid_endorser(netid) and can_view_endorsements(request)): From a88d308754bf6f675929759c4b7cdc4d489bdcfc Mon Sep 17 00:00:00 2001 From: mike seibel Date: Fri, 10 May 2024 09:11:08 -0700 Subject: [PATCH 18/26] support notification display bug --- endorsement/static/endorsement/js/notifications.js | 4 +--- endorsement/templates/email/shared_drive/notice_warning.html | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/endorsement/static/endorsement/js/notifications.js b/endorsement/static/endorsement/js/notifications.js index f5d63848..aa844529 100644 --- a/endorsement/static/endorsement/js/notifications.js +++ b/endorsement/static/endorsement/js/notifications.js @@ -38,7 +38,7 @@ var registerEvents = function() { }); $('.tabs div#shared_drive').on('endorse:shared_driveTabExposed', function (e) { - showInfoMessage($('div#shared_drive select#notification option:selected').val()); + showInfoMessage($('div.tab#shared_drive select#notification option:selected').val()); generateNotification('shared_drive'); }); @@ -150,8 +150,6 @@ var generateNotification = function (notice_type) { $('#notification_result').html(""); return; } - - data.notification = notification; } $.ajax({ diff --git a/endorsement/templates/email/shared_drive/notice_warning.html b/endorsement/templates/email/shared_drive/notice_warning.html index da2424c5..41085254 100644 --- a/endorsement/templates/email/shared_drive/notice_warning.html +++ b/endorsement/templates/email/shared_drive/notice_warning.html @@ -6,8 +6,8 @@

    To maintain service, Google Shared Drive use must be actively acknowledged -every {{lifetime}} days. The drive "{{drive.shared_drive.drive_name}}"{%if acceptor %}, last acknowledged by -the netid {{acceptor.member.netid}} on {{acceptor.datetime_accepted|date:"M j, Y"}},{% endif %} is now within {{notice_time}} days of its required renewal. +every {{lifetime}} days. {%if notice_time < 8%}{%endif%}The drive "{{drive.shared_drive.drive_name}}"{%if acceptor %}, last acknowledged by +the netid {{acceptor.member.netid}} on {{acceptor.datetime_accepted|date:"M j, Y"}},{% endif %} is now within {{notice_time}} days of its required renewal.{%if notice_time < 8%}{%endif%}

    To log in to the Provisioning Request Tool (PRT) and renew the expiring shared drive visit: From 8f5acce098813d3ed72aefe0a7965d1dd76e2ca5 Mon Sep 17 00:00:00 2001 From: mike seibel Date: Fri, 10 May 2024 09:45:41 -0700 Subject: [PATCH 19/26] improve notification testing --- endorsement/test/notifications/__init__.py | 38 +++++++++---------- .../test_access_expiration_warnings.py | 3 -- .../test_drive_expiration_warnings.py | 3 -- .../notifications/test_expiration_warnings.py | 3 -- 4 files changed, 19 insertions(+), 28 deletions(-) diff --git a/endorsement/test/notifications/__init__.py b/endorsement/test/notifications/__init__.py index 2da8036b..42e80dc7 100644 --- a/endorsement/test/notifications/__init__.py +++ b/endorsement/test/notifications/__init__.py @@ -10,24 +10,6 @@ class NotificationsTestCase(TestCase): def days_ago(self, days): return self.now - timedelta(days=days) - def notice_and_expire_test(self, offset, expected): - test_date = self.now - timedelta(days=offset[1]) - days = self.policy.lifetime - offset[1] - - models = self.policy.records_to_expire_on_date(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 = self.policy.records_to_warn_on_date(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, results): offsets = [ ('one', self.policy.days_till_expiration(1)), @@ -42,7 +24,7 @@ def message_timing(self, results): ('four', self.policy.days_till_expiration(4)), ('four + 1 day', self.policy.days_till_expiration(4) - 1), ('four + 2 days', self.policy.days_till_expiration(4) - 2), - ('grace - 1 day', -(self.policy.graceperiod - 1)), + ('grace - 1 day', self.policy.graceperiod - 1), ('grace', -(self.policy.graceperiod)), ('grace + 1 day', -(self.policy.graceperiod + 1))] @@ -50,3 +32,21 @@ def message_timing(self, results): for i, offset in enumerate(offsets): self.notice_and_expire(offset, results[i]) + + def notice_and_expire(self, offset, expected): + test_date = self.now - timedelta(days=offset[1]) + days = self.policy.lifetime - offset[1] + + models = self.policy.records_to_expire_on_date(test_date) + self.assertEqual( + models.count(), expected[0], + f"{abs(offset[1])} days after expiration (level {offset[0]})") + models.update(datetime_expired=test_date, is_deleted=True) + + for level in range(1, 5): + models = self.policy.records_to_warn_on_date(test_date, level) + self.assertEqual( + models.count(), expected[level], + (f"warning_{level} mismatch: {offset[1]} days before " + f"expiration (warning level: {offset[0]})")) + models.update(**{f"datetime_notice_{level}_emailed": test_date}) diff --git a/endorsement/test/notifications/test_access_expiration_warnings.py b/endorsement/test/notifications/test_access_expiration_warnings.py index 76716f81..73f4c42b 100644 --- a/endorsement/test/notifications/test_access_expiration_warnings.py +++ b/endorsement/test/notifications/test_access_expiration_warnings.py @@ -50,9 +50,6 @@ def setUp(self): accessor=accessor3, accessee=accessee3, datetime_granted=self.days_ago(self.policy.lifetime - 1)) - def notice_and_expire(self, offset_days, expected_results): - self.notice_and_expire_test(offset_days, expected_results) - def test_expiration_and_notices(self): expected_results = [ [0, 2, 0, 0, 0], # level one diff --git a/endorsement/test/notifications/test_drive_expiration_warnings.py b/endorsement/test/notifications/test_drive_expiration_warnings.py index 4af844c2..58f1eec6 100644 --- a/endorsement/test/notifications/test_drive_expiration_warnings.py +++ b/endorsement/test/notifications/test_drive_expiration_warnings.py @@ -47,9 +47,6 @@ def setUp(self): drive.datetime_accepted = self.days_ago(self.policy.lifetime - 1) drive.save() - def notice_and_expire(self, offset_days, expected_results): - self.notice_and_expire_test(offset_days, expected_results) - def test_expiration_and_notices(self): expected_results = [ [0, 2, 0, 0, 0], # level one diff --git a/endorsement/test/notifications/test_expiration_warnings.py b/endorsement/test/notifications/test_expiration_warnings.py index a6d221b3..94f8efd3 100644 --- a/endorsement/test/notifications/test_expiration_warnings.py +++ b/endorsement/test/notifications/test_expiration_warnings.py @@ -55,9 +55,6 @@ def setUp(self): reason="I said so", datetime_endorsed=self.days_ago(self.policy.lifetime - 1)) - def notice_and_expire(self, offset_days, expected_results): - self.notice_and_expire_test(offset_days, expected_results) - def test_expiration_and_notices(self): # use first service to get lifecycle dates expected_results = [ From e459a50cef3bfa2caebc04c68c4e29c528907d00 Mon Sep 17 00:00:00 2001 From: mike seibel Date: Fri, 10 May 2024 09:48:26 -0700 Subject: [PATCH 20/26] unused constants --- endorsement/services/__init__.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/endorsement/services/__init__.py b/endorsement/services/__init__.py index 097033c0..ba2f937a 100644 --- a/endorsement/services/__init__.py +++ b/endorsement/services/__init__.py @@ -37,10 +37,6 @@ # Services available for endorsement ENDORSEMENT_SERVICES = None -# Default lifecycle day counts -DEFAULT_ENDORSEMENT_LIFETIME = 365 -DEFAULT_ENDORSEMENT_GRACETIME = 90 - class EndorsementServiceBase(ABC): """ From 3c0a53806c2bb00927bd3f104122dc0a04a6557a Mon Sep 17 00:00:00 2001 From: mike seibel Date: Fri, 10 May 2024 13:37:03 -0700 Subject: [PATCH 21/26] access notice tweaks --- docker-compose.yml | 2 +- endorsement/templates/email/access/notice_warning_final.html | 2 +- endorsement/templates/email/access/notice_warning_final.txt | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 622d6ece..08e8a6f5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,7 @@ services: ITBILL_FORM_URL_BASE: https://uwdev.service-now.com/sp ITBILL_FORM_URL_BASE_ID: sc_cat_item ITBILL_SHARED_DRIVE_PRODUCT_SYS_ID: 7078586b2f6cb076cad75ae9aab3ea05 - XENDORSEMENT_PROVISIONING: "services, shared_drives" + # ENDORSEMENT_PROVISIONING: "services, shared_drives" restart: always container_name: app-provision build: diff --git a/endorsement/templates/email/access/notice_warning_final.html b/endorsement/templates/email/access/notice_warning_final.html index 873c1ad4..94b59b9c 100644 --- a/endorsement/templates/email/access/notice_warning_final.html +++ b/endorsement/templates/email/access/notice_warning_final.html @@ -1,4 +1,4 @@ -Hello, +Hello {{access.accessee.display_name}} ({{access.accessee.netid}}),

    You are receiving this message because you used the Provisioning Request Tool (PRT) to provision access to a UW Office 365 Exchange Online mailbox for another UW NetID.

    diff --git a/endorsement/templates/email/access/notice_warning_final.txt b/endorsement/templates/email/access/notice_warning_final.txt index 7890236c..3538b428 100644 --- a/endorsement/templates/email/access/notice_warning_final.txt +++ b/endorsement/templates/email/access/notice_warning_final.txt @@ -1,6 +1,5 @@ -Hello, +Hello {{access.accessee.display_name}} ({{access.accessee.netid}}), -xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx You are receiving this message because you used the Provisioning Request Tool (PRT) to provision access to a UW Office 365 Exchange Online mailbox for another UW NetID. From 0c028c7815e22315f402ffa8ca016d3c8eb76b24 Mon Sep 17 00:00:00 2001 From: mike seibel Date: Tue, 14 May 2024 12:31:20 -0700 Subject: [PATCH 22/26] tabs as links --- endorsement/static/endorsement/css/critical.scss | 10 +++++----- endorsement/static/endorsement/js/tabs.js | 9 ++++----- endorsement/templates/index.html | 6 +++--- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/endorsement/static/endorsement/css/critical.scss b/endorsement/static/endorsement/css/critical.scss index 1eb40ce8..7a73430e 100644 --- a/endorsement/static/endorsement/css/critical.scss +++ b/endorsement/static/endorsement/css/critical.scss @@ -676,12 +676,12 @@ td.error, tr.error { margin-right: 1.75em; float: left; padding-bottom: 2px; - span { + a.tab-link { + text-decoration: none; text-transform: uppercase; - font-weight: bold; - } - span:hover { - cursor: pointer; + font-size: 1.1em; + font-weight: 800; + color: #333; } } li.active { diff --git a/endorsement/static/endorsement/js/tabs.js b/endorsement/static/endorsement/js/tabs.js index 639d2354..4d60c1e1 100644 --- a/endorsement/static/endorsement/js/tabs.js +++ b/endorsement/static/endorsement/js/tabs.js @@ -4,12 +4,11 @@ import { History } from "./history.js"; var MainTabs = (function () { var _registerEvents = function () { - $(".tabs .tabs-list li span").click(function(e){ - e.preventDefault(); - }); + $(".tabs .tabs-list .tab-link").click(function(e){ + var $this = $(this), + $li = $this.parent(); - $(".tabs .tabs-list li span").click(function(){ - var $li = $(this).parent(); + e.preventDefault(); if (! $li.hasClass('active')) { var tab = $li.attr("data-tab"), diff --git a/endorsement/templates/index.html b/endorsement/templates/index.html index 7dbbf644..26d57ba1 100644 --- a/endorsement/templates/index.html +++ b/endorsement/templates/index.html @@ -14,13 +14,13 @@

    Manage Provisions

      {% if '*' in provisioning or 'services' in provisioning %} -
    • Services for NetIds
    • +
    • Services for NetIds
    • {% endif %} {% if '*' in provisioning or 'shared_drives' in provisioning %} -
    • Google Shared Drives
    • +
    • Google Shared Drives
    • {% endif %} {% if '*' in provisioning or 'mailbox_access' in provisioning %} -
    • Elevated Access
    • +
    • Elevated Access
    • {% endif %}
    {%endif%} From 3be16be36d72fe8aca9120f34d997b55e7580504 Mon Sep 17 00:00:00 2001 From: mike seibel Date: Wed, 15 May 2024 09:40:00 -0700 Subject: [PATCH 23/26] over quota unsubsidized warning, handlebars logic cleanup --- docker-compose.yml | 1 + docker/test-values.yml | 2 + ...driverecord_datetime_over_quota_emailed.py | 18 +++++++++ endorsement/models/shared_drive.py | 12 +++++- endorsement/notifications/access.py | 8 ++-- endorsement/notifications/shared_drive.py | 40 ++++++++++++++++++- .../endorsement/js/handlebars-helpers.js | 28 ++++--------- .../over_quota_non_subscribed.html | 14 +++++++ .../over_quota_non_subscribed.txt | 13 ++++++ .../handlebars/endorsers_partial.html | 6 +-- .../handlebars/persistent_messages.html | 2 +- .../templates/handlebars/reasons_partial.html | 4 +- endorsement/templates/handlebars/renew.html | 2 +- endorsement/templates/handlebars/revoke.html | 2 +- .../tab/access/modal_action_partial.html | 12 +++--- .../handlebars/tab/access/office.html | 4 +- .../handlebars/tab/drives/google.html | 38 ++++++++++-------- .../handlebars/tab/endorsed/accept.html | 4 +- .../handlebars/tab/shared/accept.html | 2 +- .../handlebars/tab/shared/provisioned.html | 2 +- endorsement/templates/index.html | 2 +- .../templates/support/notifications.html | 13 ++++-- .../test_drive_expiration_warnings.py | 7 +++- endorsement/test/views/api/test_resolve.py | 14 ++----- endorsement/util/itbill/shared_drive.py | 4 ++ endorsement/views/api/notification.py | 24 ++++++----- 26 files changed, 185 insertions(+), 93 deletions(-) create mode 100644 endorsement/migrations/0031_shareddriverecord_datetime_over_quota_emailed.py create mode 100644 endorsement/templates/email/shared_drive/over_quota_non_subscribed.html create mode 100644 endorsement/templates/email/shared_drive/over_quota_non_subscribed.txt diff --git a/docker-compose.yml b/docker-compose.yml index 08e8a6f5..f8a11976 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,7 @@ services: ITBILL_FORM_URL_BASE_ID: sc_cat_item ITBILL_SHARED_DRIVE_PRODUCT_SYS_ID: 7078586b2f6cb076cad75ae9aab3ea05 # ENDORSEMENT_PROVISIONING: "services, shared_drives" + # ENDORSEMENT_PROVISIONING: services,mailbox_access restart: always container_name: app-provision build: diff --git a/docker/test-values.yml b/docker/test-values.yml index 2d1cfa55..536642d0 100644 --- a/docker/test-values.yml +++ b/docker/test-values.yml @@ -112,6 +112,8 @@ environmentVariables: value: https://test.provision.uw.edu/saml - name: CLUSTER_CNAME value: test.provision.uw.edu + - name: ENDORSEMENT_PROVISIONING + value: services,mailbox_access externalSecrets: enabled: true secrets: diff --git a/endorsement/migrations/0031_shareddriverecord_datetime_over_quota_emailed.py b/endorsement/migrations/0031_shareddriverecord_datetime_over_quota_emailed.py new file mode 100644 index 00000000..0db0f857 --- /dev/null +++ b/endorsement/migrations/0031_shareddriverecord_datetime_over_quota_emailed.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.25 on 2024-05-14 17:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('endorsement', '0030_shareddriveacceptance'), + ] + + operations = [ + migrations.AddField( + model_name='shareddriverecord', + name='datetime_over_quota_emailed', + field=models.DateTimeField(null=True), + ), + ] diff --git a/endorsement/models/shared_drive.py b/endorsement/models/shared_drive.py index acf64b49..66b658e2 100644 --- a/endorsement/models/shared_drive.py +++ b/endorsement/models/shared_drive.py @@ -8,6 +8,7 @@ from endorsement.models.base import RecordManagerBase from endorsement.models.itbill import ITBillSubscription from endorsement.util.date import datetime_to_str +from endorsement.util.itbill.shared_drive import shared_drive_subsidized_quota import json @@ -74,8 +75,7 @@ class SharedDriveQuota( @property def is_subsidized(self): - return self.quota_limit <= getattr( - settings, 'ITBILL_SHARED_DRIVE_SUBSIDIZED_QUOTA') + return self.quota_limit <= shared_drive_subsidized_quota() def json_data(self): return { @@ -132,6 +132,13 @@ def get_record_by_drive_id(self, drive_id): return self.get( shared_drive__drive_id=drive_id, is_deleted__isnull=True) + def get_over_quota_non_subscribed(self): + return self.filter( + datetime_over_quota_emailed__isnull=True, + shared_drive__drive_quota__quota_limit__gt=\ + shared_drive_subsidized_quota(), + subscription__isnull=True, is_deleted__isnull=True) + class SharedDriveRecord( ExportModelOperationsMixin('shared_drive_record'), models.Model): @@ -153,6 +160,7 @@ class SharedDriveRecord( datetime_notice_2_emailed = models.DateTimeField(null=True) datetime_notice_3_emailed = models.DateTimeField(null=True) datetime_notice_4_emailed = models.DateTimeField(null=True) + datetime_over_quota_emailed = models.DateTimeField(null=True) datetime_renewed = models.DateTimeField(null=True) datetime_expired = models.DateTimeField(null=True) is_deleted = models.BooleanField(null=True) diff --git a/endorsement/notifications/access.py b/endorsement/notifications/access.py index c3da97c4..c184ee34 100644 --- a/endorsement/notifications/access.py +++ b/endorsement/notifications/access.py @@ -96,10 +96,8 @@ def warn_accessees(notice_level): email, subject, text_body, html_body, "Mailbox Access Warning") - sent_date = { - 'datetime_notice_{}_emailed'.format( - notice_level): timezone.now() - } - drives.update(**sent_date) + setattr(drive, 'datetime_notice_{}_emailed'.format(notice_level), + timezone.now()) + drive.save() except EmailFailureException as ex: pass diff --git a/endorsement/notifications/shared_drive.py b/endorsement/notifications/shared_drive.py index 9e566457..a10c7e7e 100644 --- a/endorsement/notifications/shared_drive.py +++ b/endorsement/notifications/shared_drive.py @@ -7,7 +7,7 @@ from endorsement.dao import display_datetime from endorsement.policy.shared_drive import SharedDrivePolicy from endorsement.util.email import uw_email_address -from endorsement.exceptions import EmailFailureException +from endorsement.util.itbill.shared_drive import shared_drive_subsidized_quota from django.template import loader, Template, Context from django.utils import timezone import re @@ -64,6 +64,42 @@ def warn_members(notice_level): 'datetime_notice_{}_emailed'.format( notice_level): timezone.now() } - drives.update(**sent_date) + + setattr(drive, 'datetime_notice_{}_emailed'.format(notice_level), + timezone.now()) + drive.save() + except EmailFailureException as ex: + pass + + +def _create_notification_over_quota_non_subsidized(drive): + context = { + 'drive': drive, + 'subsidized_quota': shared_drive_subsidized_quota() + } + + subject = "Action Required: Shared Drive quota has been restricted" + text_template = _email_template("over_quota_non_subscribed.txt") + html_template = _email_template("over_quota_non_subscribed.html") + + return (subject, + loader.render_to_string(text_template, context), + loader.render_to_string(html_template, context)) + + +def notify_over_quota_non_subsidized_expired(): + for drive in SharedDriveRecord.objects.get_over_quota_non_subscribed(): + try: + members = [uw_email_address(netid) for ( + netid) in drive.shared_drive.get_members()] + (subject, + text_body, + html_body) = _create_notification_over_quota_non_subsidized(drive) + send_notification( + members, subject, text_body, html_body, + "Over Quota Shared Drive Claim Deadline") + + setattr(drive, 'datetime_over_quota_emailed', timezone.now()) + drive.save() except EmailFailureException as ex: pass diff --git a/endorsement/static/endorsement/js/handlebars-helpers.js b/endorsement/static/endorsement/js/handlebars-helpers.js index a271e4b0..c67a36bd 100644 --- a/endorsement/static/endorsement/js/handlebars-helpers.js +++ b/endorsement/static/endorsement/js/handlebars-helpers.js @@ -21,18 +21,6 @@ $(window.document).ready(function() { return plural; }, - 'equals': function(a, b, options) { - return (a == b) ? options.fn(this) : options.inverse(this); - }, - 'gt': function(a, b, options) { - return (a > b) ? options.fn(this) : options.inverse(this); - }, - 'lte': function(a, b, options) { - return (a <= b) ? options.fn(this) : options.inverse(this); - }, - 'even': function(n, options) { - return ((n % 2) === 0) ? options.fn(this) : options.inverse(this); - }, 'slice': function(a, start, end, options) { if(!a || a.length == 0) return options.inverse(this); @@ -43,14 +31,12 @@ $(window.document).ready(function() { return result.join(''); }, - 'or': function(a, b, options) { - return (a || b) ? options.fn(this) : options.inverse(this); - }, - 'ifAndNot': function(a, b, options) { - return (a && !b) ? options.fn(this) : options.inverse(this); - }, - 'ifNotAndNot': function(a, b, options) { - return (!a && !b) ? options.fn(this) : options.inverse(this); - } + 'even': (n) => (n % 2) === 0, + 'eq': (a, b) => a === b, + 'gt': (a, b) => a > b, + 'lte': (a, b) => a <= b, + 'and': (a, b) => a && b, + 'or': (a, b) => a || b, + 'not': (a) => !a }); }); diff --git a/endorsement/templates/email/shared_drive/over_quota_non_subscribed.html b/endorsement/templates/email/shared_drive/over_quota_non_subscribed.html new file mode 100644 index 00000000..aa775b6c --- /dev/null +++ b/endorsement/templates/email/shared_drive/over_quota_non_subscribed.html @@ -0,0 +1,14 @@ +Hello, +

    +You are receiving this email because you are identified as {%if drive.shared_drive.members.all.count > 0 %}one of {{ drive.shared_drive.members.all.count }}{% else %}the{% endif %} UW manager{{drive.shared_drive.members.all.count|pluralize}} +of the Google Shared Drive named "{{drive.shared_drive.drive_name}}". +

    +

    +This Google Shared Drive's quota has been restricted to the subsidized quota of {{subsidized_quota}}GB and is read-only. +

    +

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

    +Thank you,
    +UW-IT
    + diff --git a/endorsement/templates/email/shared_drive/over_quota_non_subscribed.txt b/endorsement/templates/email/shared_drive/over_quota_non_subscribed.txt new file mode 100644 index 00000000..48e8da93 --- /dev/null +++ b/endorsement/templates/email/shared_drive/over_quota_non_subscribed.txt @@ -0,0 +1,13 @@ +Hello, + +You are receiving this email because you are identified as {%if drive.shared_drive.members.all.count > 0 %}one of {{ drive.shared_drive.members.all.count }}{% else %}the{% endif %} UW manager{{drive.shared_drive.members.all.count|pluralize}} +of the Google Shared Drive named "{{drive.shared_drive.drive_name}}". + +This Google Shared Drive's quota has been restricted to the subsidized quota of {{subsidized_quota}}GB and is read-only. + +If you have any questions, please contact help@uw.edu or 206-221-5000. + +Thank you, +UW-IT + + diff --git a/endorsement/templates/handlebars/endorsers_partial.html b/endorsement/templates/handlebars/endorsers_partial.html index b84f1979..7f90e6c3 100644 --- a/endorsement/templates/handlebars/endorsers_partial.html +++ b/endorsement/templates/handlebars/endorsers_partial.html @@ -18,7 +18,7 @@ {{/if}} {{/if}} {{/if}} - {{/if}}{{#if new_netid}} new_netid{{/if}} endorsement_row_{{#equals endorsement_index 0}}first top-border{{else}}following hidden-names{{/equals}} endorsee_row_{{#even endorsee_index}}even{{else}}odd{{/even}}" data-netid="{{ netid }}" + {{/if}}{{#if new_netid}} new_netid{{/if}} endorsement_row_{{#if (eq endorsement_index 0) }}first top-border{{else}}following hidden-names{{/if}} endorsee_row_{{#if (even endorsee_index)}}even{{else}}odd{{/if}}" data-netid="{{ netid }}" data-netid-name="{{ name }}" data-netid-initial-email="{{ email }}" data-service="{{ svc }}" @@ -60,12 +60,12 @@ {{#if endorsement.error }}
Problem Accessing Provision Status - {{#equals endorsement.error 'INACTIVE_NETID' }} + {{#if (eq endorsement.error 'INACTIVE_NETID') }} UW NetID {{netid}} is currently marked as inactive.
Change UW NetID password, wait 4 hours, and try again. {{else}} {{{endorsement.error}}} - {{/equals}} + {{/if}}