diff --git a/_config.yml b/_config.yml index db4bb357..904f5d74 100644 --- a/_config.yml +++ b/_config.yml @@ -11,10 +11,10 @@ navbar: link: 'index.html' - text: 'About DUB' link: 'aboutdub.html' - - text: 'DUB Seminar' - link: 'seminar.html' -# - text: 'People' -# link: 'people.html' + - text: 'Faculty' + link: 'faculty.html' + - text: 'Calendar' + link: 'calendar.html' # - text: 'Resources' # link: 'resources.html' - text: 'Getting Involved' @@ -70,3 +70,5 @@ exclude: - secrets # Tests - tests + # People raw images that are processed for the images we then use + - _people/faculty/_images_raw diff --git a/_includes/peopletable.md b/_includes/peopletable.md new file mode 100644 index 00000000..df982670 --- /dev/null +++ b/_includes/peopletable.md @@ -0,0 +1,54 @@ +{% assign people = include.people | sort: 'name' %} +{% assign peoplesize = people | size %} +{% assign peoplemod = peoplesize | modulo: 2 %} +{% assign peoplesplit = peoplesize | divided_by: 2 | plus: peoplemod %} + + +
+
+ {% for item_person in people %} + {% assign photo_path = item_person.path | split:"." | first | append:"-processed.jpg" %} + {% capture photo_exists %}{% file_exists {{ photo_path }} %}{% endcapture %} + {% if photo_exists == 'false' %} + {% assign photo_path = item_person.path | split:"." | first | append:"-processed.jpeg" %} + {% capture photo_exists %}{% file_exists {{ photo_path }} %}{% endcapture %} + {% endif %} + {% if photo_exists == 'false' %} + {% assign photo_path = item_person.path | split:"." | first | append:"-processed.png" %} + {% capture photo_exists %}{% file_exists {{ photo_path }} %}{% endcapture %} + {% endif %} + {% if photo_exists == 'true' %} + {% assign photo_url = photo_path | remove: "_" | prepend: "/" | prepend: site.baseurl %} + {% else %} + {% assign photo_url = "default.jpg" | prepend: "/people/" | prepend: site.baseurl %} + {% endif %} +
+
+
+ {% assign assuming_photo_exists_url = photo_path | prepend: "/" | prepend: site.baseurl %} + +
+
+
+

+ + {% for item_name in item_person.name %} + {{ item_name }} + {% endfor %} + {{index}} + +

+ {% for item_position in item_person.positions %} + {{ item_position.title }}, {{ item_position.affiliation }} +
+ {% endfor %} +
+
+ {% assign loopindex = forloop.index | modulo: 2 %} + {% if loopindex == 0 %} +
+ {% endif %} + {% endfor %} +
+
+ diff --git a/_people/_template.j2 b/_people/_template.j2 new file mode 100644 index 00000000..323b576e --- /dev/null +++ b/_people/_template.j2 @@ -0,0 +1,99 @@ +--- +################################################################################ +# Version of the people format. The only valid value for this is 1. +# We may increment this in the future to simplify maintenance of old people. +################################################################################ +version: 1 + +################################################################################ +# A people file might exist but lack values for some fields. These are 'TBD'. +# The only valid value is 'True'. A TBD field should not be present if 'False'. +################################################################################ +{% for tbd in tbds %} +{{ tbd }}: true +{% endfor %} + +################################################################################ +# Full name listed in the order of fields provided. +# +# name: +# - First name field +# - Second name field +# - etc (up to five fields) +################################################################################ +name: + {% for name_part in name %} + - {{ name_part }} + {% endfor %} + +################################################################################ +# Each person has a single main role. +# +# Valid roles: faculty +################################################################################ +role: + - {{ role }} + +################################################################################ +# UW NetID, used for unique identification +################################################################################ +netid: {{ netid }} + +################################################################################ +# A person may have multiple positions, which consist of a title and affiliation. +# +# Faculty have one or more title and affiliations. +# +# Valid faculty titles: +# Assistant Professor +# Adjunct Assistant Professor +# Affiliate Assistant Professor +# Associate Professor +# Adjunct Associate Professor +# Affiliate Associate Professor +# Professor +# Adjunct Professor +# Affiliate Professor +# Lecturer +# Senior Lecturer +# Professor Emeritus + +# +# Valid faculty affiliations: +# Computer Science & Engineering +# Division of Design +# Human Centered Design & Engineering +# Information School +# Human-Computer Interaction & Design +# Architecture +# Biomedical & Health Informatics +# Civil & Environmental Engineering +# Communication +# DXARTS Digital Arts +# Electrical Engineering +# Industrial & Systems Engineering +# Mechanical Engineering +# Nursing +# Psychology +# Rehabilitation Medicine +# Other + +################################################################################ +positions: + {% for position in positions %} + - title: {{ position.title }} + affiliation: {{ position.affiliation }} + {% endfor %} + +################################################################################ +# A person may have a website. If not, this field should not be present. +# +# web: +# - https://homes.cs.washington.edu/~jfogarty/ +################################################################################ +{% if 'homepage_link' %} +web: + - {{ homepage_link }} +{% endif %} + +--- diff --git a/_people/_template.md b/_people/_template.md new file mode 100644 index 00000000..2136964c --- /dev/null +++ b/_people/_template.md @@ -0,0 +1,82 @@ +--- +################################################################################ +# Version of the people format. The only valid value for this is 1. +# We may increment this in the future to simplify maintenance of old people. +################################################################################ +version: 1 + +################################################################################ +# A people file might exist but lack values for some fields. These are 'TBD'. +# The only valid value is 'True'. A TBD field should not be present if 'False'. +################################################################################ +tbd_web: true + +################################################################################ +# Full name listed in the order of last name, first name, middle name(s). +# +# name: +# - Surname +# - First +# - Middle +# - More +################################################################################ +name: +- Surname +- First +- Middle + +################################################################################ +# Each person has a single main role. +# +# Valid roles: faculty +################################################################################ +role: +- faculty + +################################################################################ +# A person may have multiple positions, which consist of titles and affiliations. +# +# Faculty have one or more title and affiliations. +# +# Valid faculty titles: +# Assistant Professor +# Associate Professor +# Professor +# +# Lecturer +# Senior Lecturer +# +# Professor Emeritus +# +# Valid faculty and doctoral affiliations: +# Computer Science & Engineering +# Division of Design +# Human Centered Design & Engineering +# Information School +# +# Human Computer Interaction & Design +# +# Architecture +# Biomedical & Health Informatics +# Civil & Environmental Engineering +# Communication +# DXARTS Digital Arts +# Electrical Engineering +# Industrial & Systems Engineering +# Mechanical Engineering +# Nursing +# Psychology +# Rehabilitation Medicine +################################################################################ +positions: +- title: Associate Professor + affiliation: Computer Science & Engineering + +################################################################################ +# A person may have a website. If not, this field should not be present. +# +# web: +# - https://homes.cs.washington.edu/~jfogarty/ +################################################################################ + +--- diff --git a/_people/default.jpg b/_people/default.jpg new file mode 100644 index 00000000..08f7758d Binary files /dev/null and b/_people/default.jpg differ diff --git a/_people/faculty-new/README.md b/_people/faculty-new/README.md new file mode 100644 index 00000000..15831931 --- /dev/null +++ b/_people/faculty-new/README.md @@ -0,0 +1 @@ +# This file exists so git will commit this directory if it's empty. diff --git a/_people/faculty/README.md b/_people/faculty/README.md new file mode 100644 index 00000000..15831931 --- /dev/null +++ b/_people/faculty/README.md @@ -0,0 +1 @@ +# This file exists so git will commit this directory if it's empty. diff --git a/_remote_data_sequences.yml b/_remote_data_sequences.yml new file mode 100644 index 00000000..8e826667 --- /dev/null +++ b/_remote_data_sequences.yml @@ -0,0 +1,3 @@ +roles: + faculty: + last_accessed_row: 6 diff --git a/base/invoke/tasks/build-faculty-profiles.py b/base/invoke/tasks/build-faculty-profiles.py new file mode 100644 index 00000000..c2649a0b --- /dev/null +++ b/base/invoke/tasks/build-faculty-profiles.py @@ -0,0 +1,181 @@ +# +# build-faculty-profiles.py +# +# Fetch a file of faculty bio information from Google Sheets and convert it +# to Markdown files suitable for consumption by Jekyll. Fetch profile images +# referenced on the spreadsheet and save them locally. +# +# Usage (from root of project): invoke build_faculty_profiles +# + +# NOTE: This script is very sensitive to the naming of fields in Google Forms. +# Namely, the Title, Affiliation, and Name fields need to start with the +# words Title, Affiliation, and Name, respectively, and need to contain a +# sequential index at the end of the field name. If we change the name of +# those fields on the form, this script might break. +# TODO: It would be better if we could use some kind of internal field name that +# is independenet of the user-facing field name. But it's unclear whether +# this is possible in Google Forms. + +import invoke +import os +import re +import io +import httplib2 +import yaml +from jinja2 import Environment, FileSystemLoader +from urllib.parse import urlparse, parse_qs +from apiclient import discovery, http as google_http +from oauth2client.service_account import ServiceAccountCredentials + + +OUTPUT_DIR = './_people/faculty/' +# max number of position blocks (title and affiliation) that we allow on the +# Google form +NUM_POSITION_BLOCKS_MAX = 4 +# max number of name fields that we allow on the Google form +NUM_NAME_FIELDS_MAX = 5 +GOOGLE_SHEETS_ID = '1G7eBpUsG3QPA46IhAio4ViXKzLtNF2oqnNInHWpifyE' +# the sheet name and column range to use +GOOGLE_SHEETS_RANGE = 'Form Responses 1!A:AT' +GOOGLE_CREDENTIALS_PATH = 'secrets/google-api-credentials.json' + +LAST_ACCESSED_ROW = 0 +with open('_remote_data_sequences.yml', 'r') as f: + sequences = yaml.load(f) + LAST_ACCESSED_ROW = sequences['roles']['faculty']['last_accessed_row'] + f.close() + +def normalize(fields, sep="_"): + """ + Join together any number of strings, replacing all whitespace with sep, + converting to lowercase, and stripping out non-alphanumeric characters + """ + fields = [fields] if not type(fields) is list else fields + regex = '[^A-Za-z0-9\\' + sep + ']' + return re.sub(regex, '', sep.join(fields).lower().replace(' ', sep)) + + +def pluck_field_index(field): + """ + Certain fields--like title, affiliation, and name--have a sequential index in + the header. This function pulls that index out for use + """ + return int(field.split('_')[-1]) - 1 + + +@invoke.task() +def build_faculty_profiles(): + if not os.path.exists(GOOGLE_CREDENTIALS_PATH): + print('Error: Missing credentials file %s. Aborting.' % GOOGLE_CREDENTIALS_PATH) + return + + # establish our Google API credentials + scope = ['https://www.googleapis.com/auth/spreadsheets.readonly'] + credentials = ServiceAccountCredentials.from_json_keyfile_name(GOOGLE_CREDENTIALS_PATH, scope) + http = credentials.authorize(httplib2.Http()) + + # fetch data from the spreadsheet + discovery_url = ('https://sheets.googleapis.com/$discovery/rest?' + 'version=v4') + google_service = discovery.build('sheets', 'v4', http=http, + discoveryServiceUrl=discovery_url) + result = (google_service + .spreadsheets() + .values() + .get(spreadsheetId=GOOGLE_SHEETS_ID, range=GOOGLE_SHEETS_RANGE) + .execute() + .get('values', [])) + headers = result[0] + + # set up our jinja2 template + env = Environment( + loader=FileSystemLoader(searchpath='./_people'), + trim_blocks=True, + lstrip_blocks=True + ) + template = env.get_template('_template.j2') + + # build portfolios for any new data on the spreadsheet + num_new_portfolios = 0 + start_row = LAST_ACCESSED_ROW + 1 + for row in result[start_row:]: + print('Building portfolio %d of %d' % (num_new_portfolios + 1, len(result[start_row:])), end='\r') + + # context object to feed to the jinja2 template + ctx = { + 'role': 'faculty', + 'positions': [{} for _ in range(NUM_POSITION_BLOCKS_MAX)], + 'name': ['' for _ in range(NUM_NAME_FIELDS_MAX)], + + # TODO: determine TBD fields, which will depend on which fields we require + # via the input form. + 'tbds': [] + } + + # add data for each field to our context object + for i in range(len(row)): + header = normalize(headers[i]) + val = row[i].strip() + + # title and affiliation fields need to be combined into a 'position' + # block, one for each set of titles and affiliations + if header.startswith('title') or header.startswith('affiliation'): + if val: + ctx['positions'][pluck_field_index(header)][header.split('_')[0]] = val + + # names can consist of several fields + elif header.startswith('name'): + if val: + ctx['name'][pluck_field_index(header)] = val + + else: + ctx[header] = val + + # filter out any unused position blocks and name fields + ctx['positions'] = [x for x in ctx['positions'] if x] + ctx['name'] = [x for x in ctx['name'] if x] + + outfile_base = normalize(ctx['name'], sep='-') + with open(OUTPUT_DIR + outfile_base + '.md', 'w') as fhand: + fhand.write(template.render(ctx)) + fhand.close() + + num_new_portfolios += 1 + + # fetch and save raw profile image + if ctx['profile_picture']: + + # pull the image file id out of the saved URL + parsed_url = urlparse(ctx['profile_picture']) + parsed_qs = parse_qs(parsed_url.query) + file_id = parsed_qs['id'][0] + + # establish our Google API credentials + scope = ['https://www.googleapis.com/auth/drive.readonly'] + credentials = ServiceAccountCredentials.from_json_keyfile_name(GOOGLE_CREDENTIALS_PATH, scope) + http = credentials.authorize(httplib2.Http()) + + # fetch raw image from Google Drive + google_service = discovery.build('drive', 'v3', http=http) + metadata = google_service.files().get(fileId=file_id).execute() + file_extension = metadata['mimeType'].split('/')[-1] + request = google_service.files().get_media(fileId=file_id) + fhand = io.BytesIO() + downloader = google_http.MediaIoBaseDownload(fhand, request) + done = False + while done is False: + status, done = downloader.next_chunk() + + with open(OUTPUT_DIR + outfile_base + '-raw.' + file_extension, 'wb') as f: + f.write(fhand.getvalue()) + f.close() + + with open('_remote_data_sequences.yml', 'r+') as f: + sequences = yaml.load(f) + sequences['roles']['faculty']['last_accessed_row'] = LAST_ACCESSED_ROW + num_new_portfolios + f.seek(0) + yaml.dump(sequences, f, default_flow_style=False) + f.close() + + print("Done! Built %d new faculty portfolios" % num_new_portfolios) diff --git a/base/invoke/tasks/process-faculty-images.py b/base/invoke/tasks/process-faculty-images.py new file mode 100644 index 00000000..7c7f31ef --- /dev/null +++ b/base/invoke/tasks/process-faculty-images.py @@ -0,0 +1,92 @@ +# +# process-faculty-images.py +# +# Detect face regions in raw faculty profile images via AWS Rekognition, and +# save cropped versions. +# +# Usage (from root of project): invoke process_faculty_images +# + +import os +import io +import glob +import invoke +import boto3 +from PIL import Image + + +os.environ['AWS_CONFIG_FILE'] = 'secrets/aws-config' +IMAGES_DIRECTORY = '_people/faculty' + +def bound_lower(val, bound): + return val if val > bound else bound + +def bound_upper(val, bound): + return val if val < bound else bound + +@invoke.task() +def process_faculty_images(): + if not os.path.exists(os.environ['AWS_CONFIG_FILE']): + print('Error: Missing credentials file %s. Aborting.' % os.environ['AWS_CONFIG_FILE']) + return + + # find all files in need of processing. We assume that if we have a *-raw + # file and no corresponding *-processed file, we need to process this file. + img_types = ('.jpg', '.jpeg', '.png') + unprocessed_images = [] + for img_type in img_types: + for image in glob.glob(os.path.join(IMAGES_DIRECTORY, '*-raw' + img_type)): + base_filename = os.path.basename(image).rstrip(img_type).split('-raw')[0] + if not os.path.exists(os.path.join(IMAGES_DIRECTORY, base_filename + + '-processed' + img_type)): + unprocessed_images.append(image) + + # establish a connection to the Rekognition service + session = boto3.Session(profile_name='rekognition') + client = boto3.client('rekognition') + + # process images + num_processed_images = 0 + for image in unprocessed_images: + print('Processing', image) + img = Image.open(image) + img_bytes = io.BytesIO() + img.save(img_bytes, format=img.format) + + # send unprocessed image to Rekognition for face detection + response = client.detect_faces( + Image={ + 'Bytes': img_bytes.getvalue() + }, + Attributes=[ + 'DEFAULT' + ] + ) + + # Rekognition returns bounding box information as a percentage of original + # image dimensions, so we multiply those values by our image width and height, + # plus some padding. We also make sure that the padding doesn't extend past + # the dimensions of the original image. + # TODO: how well will fixed padding values work in general? + b_box = response['FaceDetails'][0]['BoundingBox'] + img_width, img_height = img.size + padding_left, padding_top, padding_bottom, padding_right = (100, 150, 200, 200) + face_left = bound_lower((b_box['Left']*img_width) - padding_left, 0) + face_top = bound_lower((b_box['Top']*img_height) - padding_top, 0) + face_width = b_box['Width']*img_width + face_height = b_box['Height']*img_height + face_right = bound_upper(face_left + face_width + padding_right, img_width) + face_bottom = bound_upper(face_top + face_height + padding_bottom, img_height) + + # save the final cropped image + img_type = '.' + img.format.lower() + base_filename = os.path.basename(image).rstrip(img_type).split('-raw')[0] + (img.crop((face_left, face_top, face_right, face_bottom)) + .save(os.path.join(IMAGES_DIRECTORY, base_filename + + '-processed' + img_type))) + num_processed_images += 1 + + # remove the raw image + os.remove(os.path.join(IMAGES_DIRECTORY, base_filename + '-raw' + img_type)) + + print('Done! Processed %d images' % num_processed_images) diff --git a/css/styles.less b/css/styles.less index 3155a078..531d1c56 100644 --- a/css/styles.less +++ b/css/styles.less @@ -119,3 +119,20 @@ text-align: center; border: 1px solid gray; } + +// Used in people styling +.img-circle +{ + width: 80px; + height: 80px; +} + +.people-col.media:first-child +{ + margin-top: 15px; +} + +.people-col .media-body +{ + padding-top: 15px; +} diff --git a/faculty.md b/faculty.md new file mode 100644 index 00000000..c69fd04f --- /dev/null +++ b/faculty.md @@ -0,0 +1,8 @@ +--- +layout: base/bar/bar-sidebar-none +title: "Faculty" +--- + +## Faculty +{% assign faculty = (site.people | person_has_role: 'faculty') %} +{% include peopletable.md people=faculty %} diff --git a/requirements3.in b/requirements3.in index 656d3fb0..8f1b2797 100644 --- a/requirements3.in +++ b/requirements3.in @@ -8,3 +8,6 @@ nose pip-tools pyyaml requests +google-api-python-client +boto3 +pillow diff --git a/requirements3.in.in b/requirements3.in.in index 9cdba661..b557ead4 100644 --- a/requirements3.in.in +++ b/requirements3.in.in @@ -9,4 +9,7 @@ nose pip-tools pyyaml requests +google-api-python-client +boto3 +pillow {# This comment gives us a newline at the end of the generated file #} diff --git a/requirements3.txt b/requirements3.txt index a4acaebf..ddb1d99f 100644 --- a/requirements3.txt +++ b/requirements3.txt @@ -6,19 +6,33 @@ # certifi==2017.4.17 # via requests chardet==3.0.3 # via requests +boto3==1.5.1 +botocore==1.8.15 # via boto3, s3transfer click==6.7 # via pip-tools +docutils==0.14 # via botocore first==2.0.1 # via pip-tools icalendar==3.11.4 idna==2.5 # via requests +google-api-python-client==1.6.4 +httplib2==0.10.3 # via google-api-python-client, oauth2client invoke==0.12.2 jinja2==2.9.6 markdown==2.6.8 +jmespath==0.9.3 # via boto3, botocore markupsafe==1.0 # via jinja2 nose==1.3.7 pip-tools==1.9.0 -python-dateutil==2.6.0 # via icalendar pytz==2017.2 # via icalendar +oauth2client==4.1.2 # via google-api-python-client +olefile==0.44 # via pillow +pillow==4.3.0 +pyasn1-modules==0.2.1 # via oauth2client +pyasn1==0.4.2 # via oauth2client, pyasn1-modules, rsa +python-dateutil==2.6.1 # via botocore, icalendar pyyaml==3.12 requests==2.16.0 -six==1.10.0 # via pip-tools, python-dateutil urllib3==1.21.1 # via requests +rsa==3.4.2 # via oauth2client +s3transfer==0.1.12 # via boto3 +six==1.11.0 # via google-api-python-client, oauth2client, pip-tools, python-dateutil +uritemplate==3.0.0 # via google-api-python-client diff --git a/tasks.py b/tasks.py index 2adb4d9d..f445092a 100644 --- a/tasks.py +++ b/tasks.py @@ -7,6 +7,8 @@ 'base.invoke.tasks.docker', 'base.invoke.tasks.jekyll', 'base.invoke.tasks.update', + 'base.invoke.tasks.build-faculty-profiles', + 'base.invoke.tasks.process-faculty-images' ] # Create our task collection diff --git a/tests/fast/test_people.py b/tests/fast/test_people.py new file mode 100644 index 00000000..b966e99c --- /dev/null +++ b/tests/fast/test_people.py @@ -0,0 +1,179 @@ +import os +import unittest +import yaml + + +class TestPeople(unittest.TestCase): + def test_people_parse(self): + """ + All people files parse as valid YAML. + """ + extensions = {".jpg", ".png", ".gif"} # etc + + people_paths = [ + people_file_entry.path + for people_file_entry + in os.scandir('_people') + if (people_file_entry.is_file()) and (os.path.splitext(people_file_entry.path)[1] == ".md") + ] + for people_path_current in people_paths: + try: + with open(people_path_current) as f: + blocks = list(yaml.safe_load_all(f)) + except UnicodeDecodeError as e: + self.assertIsNone( + e, + 'Unicode error parsing people file: {}'.format(people_path_current) + ) + + self.assertIsNotNone( + blocks, + 'Could not parse people file: {}'.format(people_path_current) + ) + + def test_people_fields(self): + """ + All people files have valid contents. + """ + people_paths = [ + people_file_entry.path + for people_file_entry + in os.scandir('_people') + and os.scandir('_people/faculty') + if people_file_entry.is_file() + and not people_file_entry.path.endswith('README.md') + and os.path.splitext(people_file_entry.path)[1] == ".md" + ] + + for people_path_current in people_paths: + with open(people_path_current) as f: + # First block should be our header + person = list(yaml.safe_load_all(f))[0] + + # It has a version with an allowable value + self.assertIn( + 'version', + person, + 'No version in {}'.format(people_path_current) + ) + self.assertIn( + str(person['version']), + ['1'], + 'Invalid version in {}'.format(people_path_current) + ) + + # Role should exist + self.assertIn( + 'role', + person, + 'Missing role in {}'.format(people_path_current) + ) + # Should be a valid role + for role_current in person['role']: + self.assertIn( + role_current, + [ + 'faculty', + ], + 'Invalid role in {}'.format(people_path_current) + ) + + # Positions should exist + self.assertIn( + 'positions', + person, + 'Missing positions in {}'.format(people_path_current) + ) + # Should be valid positions + for position_current in person['positions']: + if 'faculty' in person['role']: + # Faculty title should exist + self.assertIn( + 'title', + position_current, + 'Missing title in {}'.format(people_path_current) + ) + # Faculty title should be valid + self.assertIn( + position_current['title'], + [ + 'Adjunct Assistant Professor', + 'Adjunct Associate Professor', + 'Adjunct Lecturer', + 'Adjunct Senior Lecturer', + 'Adjunct Professor', + 'Affiliate Assistant Professor', + 'Affiliate Associate Professor', + 'Affiliate Professor', + 'Assistant Professor', + 'Associate Professor', + 'Lecturer', + 'Other ', + 'Professor', + 'Professor Emeritus', + 'Senior Lecturer', + ], + 'Invalid title in {}'.format(people_path_current) + ) + else: + # Non-faculty title should not exist + self.assertNotIn( + 'title', + position_current, + 'Title should not exist in {}'.format(people_path_current) + ) + + # Affiliation should exist + self.assertIn( + 'affiliation', + position_current, + 'Missing affiliation in {}'.format(people_path_current) + ) + # Faculty affiliation should be valid + self.assertIn( + position_current['affiliation'], + [ + 'Architecture', + 'Biomedical Informatics and Medical Education', + 'Civil & Environmental Engineering', + 'Communication', + 'Computer Science & Engineering', + 'Division of Design', + 'Electrical Engineering', + 'Human Centered Design & Engineering', + 'Human Computer Interaction + Design', + 'Industrial & Systems Engineering', + 'Information School', + 'Mechanical Engineering', + 'Other ', + ], + 'Invalid affiliation in {}'.format(people_path_current) + ) + + # The website may be tbd + if 'tbd_web' in person: + # If the field exists, its value must be True + self.assertTrue( + person['tbd_web'], + 'Invalid tbd_web in {}'.format(people_path_current) + ) + # Should be missing or have our template value + if 'web' in person: + self.assertEqual( + person['web'], + None, + 'Inconsistent tbd_web and web in {}'.format(people_path_current) + ) + else: + # Website should exist + self.assertIn( + 'web', + person, + 'Inconsistent tbd_web and web in {}'.format(people_path_current) + ) + # Should not be our template value + self.assertNotEqual( + person['web'], + None, + 'Inconsistent tbd_web and web in {}'.format(people_path_current) + )