├── tests ├── __init__.py ├── context.py ├── test_data │ ├── meeting_details.json │ ├── fake-google-credentials.json │ ├── past_meetings.json │ ├── past_participants_report.json │ ├── past_participants_duplicates.json │ └── past_participants_name_change.json ├── test_zoom_helper.py ├── conftest.py ├── test_data_fetcher.py └── test_report_generator.py ├── processor ├── __init__.py ├── db_helper.py ├── model.py ├── participant_report_generator.py ├── google_helper.py ├── zoom_helper.py ├── data_fetcher.py └── report_generator.py ├── .gitignore ├── .coveragerc ├── pytest.ini ├── Pipfile ├── README.md ├── LICENSE ├── docs └── debugging.md └── Pipfile.lock /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /processor/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | pytest.log 2 | raw_data/ 3 | .secrets 4 | *.db 5 | .coverage 6 | htmlcov/ 7 | run.sh 8 | .pyc 9 | -------------------------------------------------------------------------------- /tests/context.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) 4 | 5 | import processor 6 | 7 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | # omit anything in a .local directory anywhere 4 | */.local/* 5 | # omit everything in /usr 6 | /usr/* 7 | # omit this single file 8 | utils/tirefire.py 9 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | log_cli = 1 3 | log_cli_level = DEBUG 4 | log_cli_format = %(message)s 5 | 6 | log_file = pytest.log 7 | log_file_level = DEBUG 8 | log_file_format = %(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s) 9 | log_file_date_format=%Y-%m-%d %H:%M:%S 10 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | pytest = "*" 8 | peewee = "*" 9 | responses = "*" 10 | mocker = "*" 11 | mock = "*" 12 | pytest-mock = "*" 13 | coverage = "*" 14 | 15 | [packages] 16 | requests = "*" 17 | Authlib = "*" 18 | ratelimit = "*" 19 | google-api-python-client = "*" 20 | pandas = "*" 21 | 22 | [requires] 23 | python_version = "3.8" 24 | -------------------------------------------------------------------------------- /processor/db_helper.py: -------------------------------------------------------------------------------- 1 | from peewee import * 2 | import processor.model as m 3 | 4 | TABLES = [m.Meeting, 5 | m.MeetingInstance, 6 | m.Attendance, 7 | m.Participant, 8 | m.ExecutionLog, 9 | ] 10 | 11 | 12 | class DbHelper: 13 | def __init__(self, db_name): 14 | self.db = SqliteDatabase(db_name) 15 | self.db.connect() 16 | self.db.bind(TABLES) 17 | self.db.create_tables(TABLES) 18 | -------------------------------------------------------------------------------- /tests/test_data/meeting_details.json: -------------------------------------------------------------------------------- 1 | { 2 | "uuid": "Xp7i57Ll7lG279eq7YC72g==", 3 | "id": 87171174674, 4 | "host_id": "l7QIzg7RSF2d7f8gZ7Z9nA", 5 | "type": 3, 6 | "topic": "2 hr Awesome Meeting Saturday 1:00 pm CDT / 8:15 pm CEST", 7 | "user_name": "CircleAnywhere Sessions Odds", 8 | "user_email": "official_email_1@bmail.com", 9 | "start_time": "2020-08-08T18:03:47Z", 10 | "end_time": "2020-08-08T20:30:06Z", 11 | "duration": 147, 12 | "total_minutes": 2360, 13 | "participants_count": 64, 14 | "tracking_fields": [], 15 | "dept": "Official " 16 | } -------------------------------------------------------------------------------- /tests/test_data/fake-google-credentials.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "service_account", 3 | "project_id": "ca-reporting-testing-1", 4 | "private_key_id": "7777777777777777777777777777777777777777", 5 | "private_key": "-----BEGIN PRIVATE KEY-----\nTEST_LINE_ONE\nTEST_LINE_TWO\nTEST_LINE_THREE\nTEST_LINE_FOUR\nTEST_LINE_FIVE\nTEST_LINE_SIX\nTEST_LINE_SEVEN\nTEST_LINE_EIGHT\nTEST_LINE_NINE\nTEST_LINE_TEN\nTEST_LINE_ELEVEN\nTEST_LINE_TWELVE\n-----END PRIVATE KEY-----\n", 6 | "client_email": "ca-service-testing-account@ca-testing-777777.iam.example.com", 7 | "client_id": "777777777777777777777", 8 | "auth_uri": "https://accounts.example.com/o/oauth2/auth", 9 | "token_uri": "https://oauth2.example.com/token", 10 | "auth_provider_x509_cert_url": "https://www.example.com/oauth2/v1/certs", 11 | "client_x509_cert_url": "https://www.example.com/robot/v1/metadata/x509/ca-testing-account%40ca-reporting-777777.iam.example.com" 12 | } 13 | -------------------------------------------------------------------------------- /processor/model.py: -------------------------------------------------------------------------------- 1 | from peewee import * 2 | 3 | 4 | class BaseModel(Model): 5 | pass 6 | 7 | 8 | class Meeting(BaseModel): 9 | meeting_id = CharField(primary_key=True) 10 | topic = CharField() 11 | 12 | 13 | class MeetingInstance(BaseModel): 14 | uuid = CharField(primary_key=True) 15 | meeting = ForeignKeyField(Meeting, backref='instances') 16 | start_time = DateTimeField() 17 | cached = BooleanField(default=False) 18 | 19 | 20 | class Participant(BaseModel): 21 | user_id = CharField() 22 | name = CharField(null=True, index=True) 23 | email = CharField(null=True, index=True) 24 | 25 | 26 | class Attendance(BaseModel): 27 | meeting_instance = ForeignKeyField(MeetingInstance, backref='attendances') 28 | participant = ForeignKeyField(Participant, backref='attendances') 29 | 30 | 31 | class ExecutionLog(BaseModel): 32 | run_time = DateTimeField() 33 | exit_code = IntegerField() 34 | -------------------------------------------------------------------------------- /tests/test_data/past_meetings.json: -------------------------------------------------------------------------------- 1 | { 2 | "meetings": [ 3 | { 4 | "uuid": "7yQY89iC7e70Wj6Um03ULQ==", 5 | "start_time": "2020-08-01T18:06:45Z" 6 | }, 7 | { 8 | "uuid": "8mfM3bNP7X6W7k7pdMOUXw==", 9 | "start_time": "2020-07-04T18:03:14Z" 10 | }, 11 | { 12 | "uuid": "XpPi5wLlRlG279e7fYCn2g==", 13 | "start_time": "2020-08-08T18:03:47Z" 14 | }, 15 | { 16 | "uuid": "YOW1IPq7QGiY71x7FxPD0w==", 17 | "start_time": "2020-07-25T18:04:57Z" 18 | }, 19 | { 20 | "uuid": "k2y0hRm7S5GVt7I3hw85kQ==", 21 | "start_time": "2020-07-18T18:07:27Z" 22 | }, 23 | { 24 | "uuid": "l6xP4I67Rwm6U74a4rt4IA==", 25 | "start_time": "2020-06-27T18:04:12Z" 26 | }, 27 | { 28 | "uuid": "yh7deEZaR7yE1u9B7eGXHw==", 29 | "start_time": "2020-07-11T18:05:55Z" 30 | } 31 | ] 32 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## What This Code Does 2 | 1. Retrieves meeting attendance data from Zoom API and creates a report from it. 3 | 2. Uploads the report to a Google Drive Spreadsheet. 4 | 5 | ## How To Install It 6 | brew install python git pipenv 7 | 8 | git clone https://github.com/BrianRS/zoom_participant_reporting.git 9 | 10 | ## How To Run It 11 | 1. Create a file, ``meetings.txt`` with the list of Zoom meeting IDs you'd like to report on. One meeting ID per line. 12 | 13 | 2. Make sure to have the Zoom API Key and Secret, see [here](https://medium.com/swlh/how-i-automate-my-church-organisations-zoom-meeting-attendance-reporting-with-python-419dfe7da58c) for how to get these. 14 | 15 | 3. Run: 16 | 17 | pipenv shell 18 | 19 | export PYTHONPATH=. 20 | export ZOOM_API_KEY=**Put API key here** 21 | export ZOOM_API_SECRET=**Put API secret here** 22 | python processor/report_generator.py prod.db raw_data/meetings.txt 23 | 24 | 25 | ## Additional Info 26 | See ``docs/`` for detailed examples on debugging meeting data 27 | 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/debugging.md: -------------------------------------------------------------------------------- 1 | ## Get Summary Statistics 2 | 3 | pipenv shell 4 | python 5 | 6 | from processor.model import * 7 | from processor.db_helper import DbHelper 8 | db = DbHelper("prod.db") 9 | 10 | Meeting.select().count() 11 | MeetingInstance.select().count() 12 | Attendance.select().count() 13 | Participant.select().count() 14 | 15 | ## Create zoom connection 16 | 17 | zoom_api_key = 18 | zoom_api_secret = 19 | 20 | from processor.zoom_helper import ZoomHelper 21 | from processor.data_fetcher import DataFetcher 22 | zoom = ZoomHelper("https://api.zoom.us/v2", zoom_api_key, zoom_api_secret) 23 | data_fetcher = DataFetcher(db, zoom) 24 | 25 | 26 | ## Checking participants for a Meeting 27 | 28 | meeting_id = 29 | m = Meeting.select().where(Meeting.meeting_id==meeting_id).first() 30 | 31 | ## Get latest instance of the meeting 32 | 33 | mi = MeetingInstance.select().where(MeetingInstance.meeting == m).order_by(MeetingInstance.start_time.desc()).first() 34 | 35 | data_fetcher.fetch_meeting_participants(mi) 36 | data_fetcher.fetch_meeting_participants_from_zoom(mi) 37 | 38 | ## Update Session Names in the Database 39 | m = Meeting.select().where(Meeting.meeting_id == xxx).first() 40 | m.topic = "set as desired" 41 | m.save() 42 | -------------------------------------------------------------------------------- /tests/test_zoom_helper.py: -------------------------------------------------------------------------------- 1 | import responses 2 | 3 | 4 | @responses.activate 5 | def test_get_meeting_participants_401(zoom_helper): 6 | responses.add(responses.GET, f"{zoom_helper.base_url}/report/meetings/{1}/participants?page_size=300", 7 | json={'error': 'unauthorized'}, status=401) 8 | r = zoom_helper.get_meeting_participants('1', b'1') 9 | assert 401 == r.status_code 10 | 11 | 12 | @responses.activate 13 | def test_get_meeting_participants_success(zoom_helper): 14 | responses.add(responses.GET, f"{zoom_helper.base_url}/report/meetings/{2}/participants?page_size=300", 15 | json={}, status=200) 16 | r = zoom_helper.get_meeting_participants('2', b'2') 17 | assert 200 == r.status_code 18 | 19 | 20 | @responses.activate 21 | def test_get_meeting_details_success(zoom_helper): 22 | responses.add(responses.GET, f"{zoom_helper.base_url}/report/meetings/{2}", 23 | json={}, status=200) 24 | r = zoom_helper.get_meeting_details('2', b'2') 25 | assert 200 == r.status_code 26 | 27 | 28 | @responses.activate 29 | def test_get_meeting_participants_401(zoom_helper): 30 | responses.add(responses.GET, f"{zoom_helper.base_url}/report/meetings/{1}", 31 | json={'error': 'unauthorized'}, status=401) 32 | r = zoom_helper.get_meeting_details('1', b'1') 33 | assert 401 == r.status_code 34 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import uuid 3 | import datetime 4 | import random 5 | 6 | from processor.data_fetcher import DataFetcher 7 | from processor.db_helper import DbHelper 8 | from processor.model import MeetingInstance, Meeting, Participant, Attendance 9 | from processor.report_generator import ReportGenerator 10 | from processor.zoom_helper import ZoomHelper 11 | 12 | 13 | @pytest.fixture() 14 | def zoom_helper(): 15 | return ZoomHelper('https://test.example.com/v2', 'test_key', 'test_secret') 16 | 17 | 18 | @pytest.fixture 19 | def data_fetcher(zoom_helper): 20 | db = DbHelper(':memory:') 21 | return DataFetcher(db, zoom_helper) 22 | 23 | 24 | @pytest.fixture 25 | def meeting(): 26 | meeting_id = str(random.randint(1, 2000)) 27 | topic = f"topic for {meeting_id}" 28 | return Meeting.create(meeting_id=meeting_id, topic=topic) 29 | 30 | 31 | @pytest.fixture 32 | def meeting_instance(meeting): 33 | return make_meeting_instance(meeting) 34 | 35 | 36 | class TestGoogleHelper: 37 | def __init__(self, service_account_file, scopes): 38 | self.service_account_file = service_account_file 39 | self.scopes = scopes 40 | 41 | 42 | @pytest.fixture() 43 | def google_helper(): 44 | return TestGoogleHelper("test_file.json", []) 45 | 46 | 47 | @pytest.fixture() 48 | def report_generator(data_fetcher, google_helper): 49 | return ReportGenerator(data_fetcher, google_helper) 50 | 51 | 52 | def make_meeting_instance(meeting, meeting_instance_id=None, start_time=None): 53 | if meeting_instance_id is None: 54 | meeting_instance_id = uuid.uuid1() 55 | if start_time is None: 56 | start_time = datetime.datetime(2020, 5, 17) 57 | meeting_instance = MeetingInstance.create(uuid=meeting_instance_id, 58 | meeting=meeting, 59 | start_time=start_time, 60 | cached=False) 61 | return meeting_instance 62 | 63 | 64 | def attend_meeting_with_new_participant(meeting_instance, name, email=None): 65 | user_id = str(random.randint(5000, 10000)) 66 | p = Participant.create(user_id=user_id, name=name) 67 | a = Attendance.create(meeting_instance=meeting_instance, participant=p) 68 | return p, a 69 | 70 | 71 | def attend_meeting(meeting_instance, participant): 72 | return Attendance.create(meeting_instance=meeting_instance, participant=participant) 73 | 74 | -------------------------------------------------------------------------------- /processor/participant_report_generator.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import pandas as pd 4 | import datetime 5 | 6 | from processor.data_fetcher import DataFetcher 7 | from processor.db_helper import DbHelper 8 | from processor.report_generator import ReportGenerator 9 | from processor.zoom_helper import ZoomHelper 10 | 11 | 12 | class ParticipantReportGenerator(ReportGenerator): 13 | def __init__(self, data_fetcher, google_helper): 14 | super().__init__(data_fetcher, google_helper) 15 | self.data_fetcher = data_fetcher 16 | self.google = google_helper 17 | 18 | def generate_report(self, meeting_ids): 19 | df = pd.DataFrame(data=[], columns=[ReportGenerator.TOPIC_COLUMN]) 20 | for meeting_id in meeting_ids: 21 | attendances = self.get_attendances(meeting_id) 22 | meeting = self.data_fetcher.fetch_meeting_details(meeting_id) 23 | for meeting_instance, participants in attendances.items(): 24 | # We will use the dates as the column names 25 | # and the meeting id's and topics as the row names 26 | date = meeting_instance.start_time.date().strftime('%Y-%m-%d') 27 | df.loc[meeting_id, date] = self.get_participants_string(participants) 28 | df.loc[meeting_id, ReportGenerator.TOPIC_COLUMN] = meeting.topic 29 | 30 | df = df.fillna('') 31 | return df 32 | 33 | @staticmethod 34 | def get_participants_string(participants): 35 | names = [p.name for p in participants] 36 | #if "CircleAnywhere Sessions Evens" in names: names.remove("CircleAnywhere Sessions Evens") 37 | #if "CircleAnywhere Sessions Odds" in names: names.remove("CircleAnywhere Sessions Odds") 38 | return ", ".join(names) 39 | 40 | 41 | def main(): 42 | db_name = sys.argv[1] 43 | meeting_ids_file = sys.argv[2] 44 | zoom_api_key = os.environ["ZOOM_API_KEY"] 45 | zoom_api_secret = os.environ["ZOOM_API_SECRET"] 46 | 47 | meeting_ids = None 48 | with open(meeting_ids_file) as f: 49 | meeting_ids = f.read().splitlines() 50 | print(f"Generating participant report for {len(meeting_ids)} meetings") 51 | 52 | db = DbHelper(db_name) 53 | 54 | zoom = ZoomHelper(ReportGenerator.ZOOM_URL, zoom_api_key, zoom_api_secret) 55 | 56 | data_fetcher = DataFetcher(db, zoom) 57 | rg = ParticipantReportGenerator(data_fetcher, None) 58 | report = rg.generate_report(meeting_ids) 59 | 60 | run_date = datetime.datetime.now().date().strftime('%Y-%m-%d') 61 | export_file = f'participants_{run_date}.csv' 62 | report.to_csv(export_file, index=True, header=True) 63 | 64 | 65 | if __name__ == "__main__": 66 | main() 67 | -------------------------------------------------------------------------------- /processor/google_helper.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | import json 3 | 4 | from google.oauth2 import service_account 5 | from googleapiclient import discovery 6 | from googleapiclient.errors import HttpError 7 | from pandas import DataFrame 8 | 9 | 10 | class GoogleHelper: 11 | def __init__(self, service_account_file: str, scopes: List[str]): 12 | self.service_account_file = service_account_file 13 | self.scopes = scopes 14 | self.google_sheet_type = "application/vnd.google-apps.spreadsheet" 15 | self.creds = service_account.Credentials.from_service_account_file(self.service_account_file, 16 | scopes=self.scopes) 17 | self.drive = discovery.build("drive", "v3", credentials=self.creds) 18 | self.sheets = discovery.build("sheets", "v4", credentials=self.creds) 19 | 20 | def get_folder_id(self, folder_name: str) -> str: 21 | folders: dict = self.drive.files().list(q="mimeType='application/vnd.google-apps.folder'").execute() 22 | print(f"Folders: {folders}") 23 | for x in folders.get("files"): 24 | if x.get("name") == folder_name: 25 | return x.get("id") 26 | print(f"Could not find folder {folder_name}") 27 | raise RuntimeError 28 | 29 | def create_new_sheet(self, file_name: str, parent_folder_id: str) -> str: 30 | new_sheet_metadata = { 31 | "name": file_name, 32 | "parents": [parent_folder_id], 33 | "mimeType": self.google_sheet_type 34 | } 35 | new_sheet = self.create_new_sheet_helper(new_sheet_metadata) 36 | print(new_sheet) 37 | 38 | return new_sheet.get("id") 39 | 40 | def create_new_sheet_helper(self, new_sheet_metadata): 41 | print(new_sheet_metadata) 42 | try: 43 | return self.drive.files().create(body=new_sheet_metadata).execute() 44 | except HttpError as err: 45 | if err.resp.get('content-type', '').startswith('application/json'): 46 | reason = json.loads(err.content).get('error').get('errors')[0].get('reason') 47 | print(reason) 48 | raise err 49 | 50 | def insert_df_to_sheet(self, google_sheet_id: str, values: List) -> dict: 51 | response = self.sheets.spreadsheets().values().append( 52 | spreadsheetId=google_sheet_id, 53 | valueInputOption="RAW", 54 | range="A1", 55 | body={"majorDimension": "ROWS", 56 | "values": values} 57 | ).execute() 58 | 59 | return response 60 | 61 | def get_sheet_link(self, google_sheet_id: str, 62 | return_all_fields: bool = False, fields_to_return: str = "webViewLink"): 63 | fields = "*" if return_all_fields else fields_to_return 64 | response = self.drive.files().get(fileId=google_sheet_id, fields=fields).execute() 65 | 66 | return response 67 | -------------------------------------------------------------------------------- /processor/zoom_helper.py: -------------------------------------------------------------------------------- 1 | import time 2 | from typing import Any, Dict, Optional, Union 3 | 4 | import requests 5 | from authlib.jose import jwt 6 | from requests import Response 7 | from ratelimit import limits 8 | import urllib.parse 9 | 10 | # see https://marketplace.zoom.us/docs/api-reference/rate-limits#rate-limits 11 | ONE_SECOND = 1 12 | HEAVY_CALLS = 9 13 | MEDIUM_CALLS = 19 14 | 15 | 16 | class ZoomHelper: 17 | def __init__(self, base_url: str, api_key: str, api_secret: str): 18 | self.api_key = api_key 19 | self.api_secret = api_secret 20 | self.base_url = base_url 21 | self.reports_url = f"{self.base_url}/report/meetings" 22 | self.past_meetings_url = f"{self.base_url}/past_meetings" 23 | self.jwt_token_exp = 1800 24 | self.jwt_token_algo = "HS256" 25 | 26 | @limits(calls=HEAVY_CALLS, period=ONE_SECOND) 27 | def get_meeting_participants(self, 28 | meeting_id: str, 29 | jwt_token: bytes, 30 | next_page_token: Optional[str] = None) -> Response: 31 | 32 | encoded_meeting_id = str(meeting_id) 33 | # Encode the meetingId twice to handle meetingIds that have slashes in them 34 | # See https://devforum.zoom.us/t/uuid-with-a-slash-failed-get-meetings-info/10433/2 35 | if '/' in encoded_meeting_id: 36 | encoded_meeting_id = urllib.parse.quote_plus(meeting_id) 37 | encoded_meeting_id = urllib.parse.quote_plus(encoded_meeting_id) 38 | 39 | url = f"{self.reports_url}/{encoded_meeting_id}/participants" 40 | 41 | query_params: Dict[str, Union[int, str]] = {"page_size": 300} 42 | if next_page_token: 43 | query_params.update({"next_page_token": next_page_token}) 44 | print(f"Zoom: Getting participants for {meeting_id}") 45 | r: Response = requests.get(url, 46 | headers={"Authorization": f"Bearer {jwt_token.decode('utf-8')}"}, 47 | params=query_params) 48 | return r 49 | 50 | def generate_jwt_token(self) -> bytes: 51 | iat = int(time.time()) 52 | jwt_payload: Dict[str, Any] = { 53 | "aud": None, 54 | "iss": self.api_key, 55 | "exp": iat + self.jwt_token_exp, 56 | "iat": iat 57 | } 58 | 59 | header: Dict[str, str] = {"alg": self.jwt_token_algo} 60 | jwt_token: bytes = jwt.encode(header, jwt_payload, self.api_secret) 61 | return jwt_token 62 | 63 | @limits(calls=HEAVY_CALLS, period=ONE_SECOND) 64 | def get_meeting_details(self, 65 | meeting_id: str, 66 | jwt_token: bytes) -> Response: 67 | url = f"{self.reports_url}/{meeting_id}" 68 | 69 | print(f"Zoom: Getting meeting details for {meeting_id}") 70 | r: Response = requests.get(url, headers={"Authorization": f"Bearer {jwt_token.decode('utf-8')}"}) 71 | return r 72 | 73 | @limits(calls=MEDIUM_CALLS, period=ONE_SECOND) 74 | def get_past_meeting_instances(self, 75 | meeting_id: str, 76 | jwt_token: bytes) -> Response: 77 | url = f"{self.past_meetings_url}/{meeting_id}/instances" 78 | print(f"Zoom: Getting past meeting instances for {meeting_id}") 79 | r: Response = requests.get(url, headers={"Authorization": f"Bearer {jwt_token.decode('utf-8')}"}) 80 | return r 81 | -------------------------------------------------------------------------------- /processor/data_fetcher.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from processor.model import Participant, MeetingInstance, Meeting, Attendance 4 | 5 | 6 | class DataFetcher: 7 | def __init__(self, db, zoom): 8 | self.db = db 9 | self.zoom = zoom 10 | self.jwt_token = zoom.generate_jwt_token() 11 | 12 | def fetch_meeting_participants(self, meeting_instance): 13 | if not meeting_instance.cached: 14 | return self.fetch_meeting_participants_from_zoom(meeting_instance) 15 | return Participant.select().join(Attendance).where(Attendance.meeting_instance == meeting_instance) 16 | 17 | def fetch_meeting_participants_from_zoom(self, meeting_instance): 18 | response = self.zoom.get_meeting_participants(meeting_instance.uuid, self.jwt_token) 19 | if not response.ok: 20 | print(f"Response failed with code {response.status_code} for meeting {meeting_instance.uuid}") 21 | raise RuntimeError 22 | 23 | participants_json = response.json().get("participants") 24 | 25 | while token := response.json().get("next_page_token"): 26 | response = self.zoom.get_meeting_participants(meeting_instance.uuid, self.jwt_token, token) 27 | participants_json += response.json().get("participants") 28 | 29 | # Create Participants and store their Attendance 30 | participants = self.get_unique_participants(meeting_instance, participants_json) 31 | 32 | # Cache the results 33 | meeting_instance.cached = True 34 | meeting_instance.save() 35 | 36 | return participants 37 | 38 | def get_unique_participants(self, meeting_instance, participants_json): 39 | participants = [] 40 | participant_names = set() 41 | for p in participants_json: 42 | participant, created = self.get_or_create_participant(p) 43 | if created: 44 | print(f"Found a new participant: {participant.name}: {participant.email}") 45 | if participant.name not in participant_names: 46 | participant_names.add(participant.name) 47 | participants.append(participant) 48 | Attendance.get_or_create(meeting_instance=meeting_instance, 49 | participant=participant) 50 | 51 | print(f"{len(participants)} unique participants.") 52 | return participants 53 | 54 | @staticmethod 55 | def get_or_create_participant(participant): 56 | if participant["user_email"] != "": 57 | # Detect name changes by same email 58 | return Participant.get_or_create(email=participant["user_email"], 59 | defaults={'user_id': participant["id"], 60 | 'name': participant["name"]}) 61 | if participant["name"] != "": 62 | # If no email, fallback to just the name 63 | return Participant.get_or_create(name=participant["name"], 64 | defaults={'user_id': participant["id"], 65 | 'email': participant["user_email"]}) 66 | return Participant.get_or_create(name=participant["name"], 67 | email=participant["user_email"], 68 | user_id=participant["id"]) 69 | 70 | def fetch_meeting_details(self, meeting_id): 71 | meeting = Meeting.get_or_none(Meeting.meeting_id == meeting_id) 72 | if meeting is None: 73 | return self.fetch_meeting_details_from_zoom(meeting_id) 74 | return meeting 75 | 76 | def fetch_meeting_details_from_zoom(self, meeting_id): 77 | response = self.zoom.get_meeting_details(meeting_id, self.jwt_token) 78 | if not response.ok: 79 | print(f"Response failed with code {response.status_code} for meeting {meeting_id}") 80 | raise RuntimeError 81 | 82 | topic = response.json().get("topic") 83 | meeting, created = Meeting.get_or_create(meeting_id=meeting_id, topic=topic) 84 | 85 | print(f"Topic: {topic} participants.") 86 | return meeting 87 | 88 | @staticmethod 89 | def fetch_past_meeting_instances_cached(meeting): 90 | """ 91 | We can't mark a Meeting object as cached, because they're not idempotent in the server. 92 | If we want to retrieve past meeting instances from DB, we must explicitly call this method. 93 | """ 94 | return MeetingInstance.select().where(MeetingInstance.meeting == meeting) 95 | 96 | def fetch_past_meeting_instances(self, meeting): 97 | response = self.zoom.get_past_meeting_instances(meeting.meeting_id, self.jwt_token) 98 | if not response.ok: 99 | print(f"Response failed with code {response.status_code} for meeting {meeting.meeting_id}") 100 | raise RuntimeError 101 | 102 | meetings = response.json().get("meetings") 103 | meeting_instances = [] 104 | for m in meetings: 105 | start_time_str = m["start_time"] 106 | start_time = datetime.datetime.strptime(start_time_str, '%Y-%m-%dT%H:%M:%SZ') 107 | mi, created = MeetingInstance.get_or_create(uuid=m["uuid"], meeting=meeting, 108 | defaults={'start_time': start_time}) 109 | meeting_instances.append(mi) 110 | 111 | print(f"Found {len(meetings)} meeting instances for {meeting.meeting_id}") 112 | return meeting_instances 113 | -------------------------------------------------------------------------------- /tests/test_data_fetcher.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import responses 3 | import json 4 | import datetime 5 | 6 | from processor.model import Meeting, Participant 7 | from tests.conftest import make_meeting_instance, attend_meeting_with_new_participant 8 | 9 | 10 | @responses.activate 11 | def test_get_participants_401(data_fetcher, meeting_instance): 12 | base = data_fetcher.zoom.reports_url 13 | responses.add(responses.GET, f"{base}/{meeting_instance.uuid}/participants?page_size=300", 14 | json={'error': 'unauthorized'}, status=401) 15 | with pytest.raises(RuntimeError): 16 | data_fetcher.fetch_meeting_participants(meeting_instance) 17 | 18 | 19 | @responses.activate 20 | def test_get_participants(data_fetcher, meeting_instance): 21 | with open('tests/test_data/past_participants_report.json') as f: 22 | data = json.load(f) 23 | 24 | base = data_fetcher.zoom.reports_url 25 | responses.add(responses.GET, 26 | f"{base}/{meeting_instance.uuid}/participants?page_size=300", 27 | json=data, status=200) 28 | ps = data_fetcher.fetch_meeting_participants(meeting_instance) 29 | 30 | assert 20 == len(ps) 31 | assert meeting_instance.cached 32 | 33 | 34 | def test_get_participants_cache_hit_no_participants(data_fetcher, meeting_instance): 35 | meeting_instance.cached = True 36 | meeting_instance.save() 37 | 38 | ps = data_fetcher.fetch_meeting_participants(meeting_instance) 39 | assert 0 == len(ps) 40 | 41 | 42 | def test_get_participants_cache_hit_some_participants(data_fetcher, meeting_instance): 43 | meeting_instance.cached = True 44 | meeting_instance.save() 45 | a, _ = attend_meeting_with_new_participant(meeting_instance, "Alice") 46 | b, _ = attend_meeting_with_new_participant(meeting_instance, "Bob") 47 | 48 | ps = data_fetcher.fetch_meeting_participants(meeting_instance) 49 | assert 2 == len(ps) 50 | assert ps[0].user_id == a.user_id 51 | assert ps[1].user_id == b.user_id 52 | 53 | 54 | @responses.activate 55 | def test_get_meeting_details_from_zoom_401(data_fetcher): 56 | base = data_fetcher.zoom.reports_url 57 | meeting_id = 14 58 | responses.add(responses.GET, f"{base}/{meeting_id}", 59 | json={'error': 'unauthorized'}, status=401) 60 | with pytest.raises(RuntimeError): 61 | data_fetcher.fetch_meeting_details(meeting_id) 62 | 63 | 64 | @responses.activate 65 | def test_fetch_meeting_details_from_zoom_success(data_fetcher): 66 | meeting_id = 15 67 | with open('tests/test_data/meeting_details.json') as f: 68 | data = json.load(f) 69 | responses.add(responses.GET, f"{data_fetcher.zoom.reports_url}/{meeting_id}", 70 | json=data, status=200) 71 | 72 | meeting = data_fetcher.fetch_meeting_details(meeting_id) 73 | assert meeting.meeting_id == meeting_id 74 | assert meeting.topic == "2 hr Awesome Meeting Saturday 1:00 pm CDT / 8:15 pm CEST" 75 | 76 | 77 | def test_get_meeting_details_cached_hit(data_fetcher): 78 | Meeting.create(meeting_id=13, topic="13 topic") 79 | meeting = data_fetcher.fetch_meeting_details(13) 80 | assert '13' == meeting.meeting_id 81 | assert "13 topic" == meeting.topic 82 | 83 | 84 | @responses.activate 85 | def test_fetch_past_meetings_zoom_401(data_fetcher, meeting): 86 | base = data_fetcher.zoom.past_meetings_url 87 | responses.add(responses.GET, f"{base}/{meeting.meeting_id}/instances", 88 | json={'error': 'unauthorized'}, status=401) 89 | with pytest.raises(RuntimeError): 90 | data_fetcher.fetch_past_meeting_instances(meeting) 91 | 92 | 93 | @responses.activate 94 | def test_fetch_past_meeting_instances_from_zoom_success(data_fetcher, meeting): 95 | with open('tests/test_data/past_meetings.json') as f: 96 | data = json.load(f) 97 | base = data_fetcher.zoom.past_meetings_url 98 | responses.add(responses.GET, f"{base}/{meeting.meeting_id}/instances", 99 | json=data, status=200) 100 | 101 | meetings = data_fetcher.fetch_past_meeting_instances(meeting) 102 | assert 7 == len(meetings) 103 | assert meetings[0].uuid == "7yQY89iC7e70Wj6Um03ULQ==" 104 | assert meetings[0].start_time == datetime.datetime(2020, 8, 1, 18, 6, 45) 105 | 106 | 107 | @responses.activate 108 | def test_fetch_past_meeting_existing_instance(data_fetcher, meeting): 109 | with open('tests/test_data/past_meetings.json') as f: 110 | data = json.load(f) 111 | base = data_fetcher.zoom.past_meetings_url 112 | responses.add(responses.GET, f"{base}/{meeting.meeting_id}/instances", 113 | json=data, status=200) 114 | 115 | start_time = datetime.datetime(2020, 8, 16) 116 | meeting_instance_uuid = "7yQY89iC7e70Wj6Um03ULQ==" 117 | make_meeting_instance(meeting, meeting_instance_uuid, start_time) 118 | 119 | meetings = data_fetcher.fetch_past_meeting_instances(meeting) 120 | assert 7 == len(meetings) 121 | assert meetings[0].uuid == meeting_instance_uuid 122 | assert meetings[0].start_time == start_time 123 | 124 | 125 | def test_get_past_meeting_instances_cache_hit(data_fetcher): 126 | meeting = Meeting.create(meeting_id=9, topic="9 topic", cached=True) 127 | meeting_instances = data_fetcher.fetch_past_meeting_instances_cached(meeting) 128 | 129 | make_meeting_instance(meeting) 130 | make_meeting_instance(meeting) 131 | 132 | assert 2 == len(meeting_instances) 133 | 134 | 135 | @responses.activate 136 | def test_detect_duplicate_names_in_meeting(data_fetcher, meeting_instance): 137 | with open('tests/test_data/past_participants_duplicates.json') as f: 138 | data = json.load(f) 139 | ps = data_fetcher.get_unique_participants(meeting_instance, data.get("participants")) 140 | 141 | assert 15 == len(ps) 142 | 143 | 144 | def test_detect_name_change_same_email(data_fetcher, meeting_instance): 145 | with open('tests/test_data/past_participants_name_change.json') as f: 146 | data = json.load(f) 147 | ps = data_fetcher.get_unique_participants(meeting_instance, data.get("participants")) 148 | 149 | assert 14 == len(ps) 150 | 151 | 152 | def test_does_not_create_new_participant_if_name_already_exists(data_fetcher, meeting_instance): 153 | data = [{"id": "1", "name": "Alice", "user_email": ""}] 154 | p = Participant.create(name="Alice", user_id="1") 155 | ps = data_fetcher.get_unique_participants(meeting_instance, data) 156 | 157 | assert 1 == len(ps) 158 | assert 1 == Participant.select().count() 159 | 160 | 161 | 162 | 163 | -------------------------------------------------------------------------------- /processor/report_generator.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import datetime 4 | from typing import Dict, List 5 | 6 | import pandas as pd 7 | from googleapiclient.errors import HttpError 8 | 9 | from processor.data_fetcher import DataFetcher 10 | from processor.db_helper import DbHelper 11 | from processor.google_helper import GoogleHelper 12 | from processor.model import MeetingInstance, Participant 13 | from processor.zoom_helper import ZoomHelper 14 | 15 | 16 | class ReportGenerator: 17 | SCOPES = ["https://www.googleapis.com/auth/drive", 18 | "https://www.googleapis.com/auth/drive.file", 19 | "https://www.googleapis.com/auth/drive.metadata"] 20 | TOPIC_COLUMN = 'Name' 21 | AVG_COLUMN = 'Average' 22 | LAST_FOUR = 'Last Four Average' 23 | ZOOM_URL = "https://api.zoom.us/v2" 24 | 25 | def __init__(self, data_fetcher, google_helper): 26 | self.data_fetcher = data_fetcher 27 | self.google = google_helper 28 | 29 | def get_attendances(self, meeting_id) -> Dict[MeetingInstance, List[Participant]]: 30 | """ 31 | For any meeting, returns a Dict[MeetingInstance, List[Participant]] 32 | """ 33 | print(f"\nGetting meeting details for {meeting_id}") 34 | meeting = self.data_fetcher.fetch_meeting_details(meeting_id) 35 | print(f"Meeting name: {meeting.topic}") 36 | past_meetings = self.data_fetcher.fetch_past_meeting_instances(meeting) 37 | result = {} 38 | 39 | for mi in past_meetings: 40 | participants = self.data_fetcher.fetch_meeting_participants(mi) 41 | result[mi] = participants 42 | 43 | return result 44 | 45 | @staticmethod 46 | def avg_of_last_four(row): 47 | # Drop non-session days (NaN) 48 | # Drop the first element, which is not numeric (meeting topic) 49 | # Sort by date 50 | # Grab last four elements and take mean 51 | row = row[1:].dropna() 52 | row = row.sort_index(key=lambda x: pd.to_datetime(x)) 53 | print(row) 54 | return row.tail(4).mean() 55 | 56 | def generate_report(self, meeting_ids): 57 | df = pd.DataFrame(data=[], columns=[self.TOPIC_COLUMN]) 58 | for meeting_id in meeting_ids: 59 | attendances = self.get_attendances(meeting_id) 60 | meeting = self.data_fetcher.fetch_meeting_details(meeting_id) 61 | for meeting_instance, participants in attendances.items(): 62 | # We will use the dates as the column names 63 | # and the meeting id's and topics as the row names 64 | date = meeting_instance.start_time.date().strftime('%Y-%m-%d') 65 | df.loc[meeting_id, date] = len(participants) 66 | df.loc[meeting_id, self.TOPIC_COLUMN] = meeting.topic 67 | 68 | # average attendance per meeting 69 | avg = df.mean(skipna=True, numeric_only=True, axis=1) 70 | 71 | last_four = df.apply(ReportGenerator.avg_of_last_four, axis=1) 72 | 73 | df[self.AVG_COLUMN] = avg 74 | df[self.LAST_FOUR] = last_four 75 | 76 | df = df.fillna(0) 77 | return df 78 | 79 | @staticmethod 80 | def dataframe_to_array(df): 81 | rows, cols = df.shape 82 | 83 | names = df.pop(ReportGenerator.TOPIC_COLUMN) 84 | averages = df.pop(ReportGenerator.AVG_COLUMN) 85 | last_fours = df.pop(ReportGenerator.LAST_FOUR) 86 | values_dict = df.to_dict() 87 | 88 | # Create all the rows, and add one for the headers row 89 | values = [['Meeting ID', ReportGenerator.TOPIC_COLUMN]] 90 | 91 | # Populate the content of header row up to the dates 92 | header_row = 0 93 | 94 | # Populate the first and second columns (Meeting Ids, Names) across all rows 95 | row_name_to_num = {} 96 | row_counter = 1 97 | for meeting_id, name in names.iteritems(): 98 | # Add a row for each meeting 99 | row = [meeting_id, name] 100 | values.append(row) 101 | 102 | # Keep a mapping of each row name to its position 103 | row_name_to_num[meeting_id] = row_counter 104 | row_counter += 1 105 | 106 | # Sort the dates 107 | dates = list(values_dict.keys()) 108 | dates.sort() 109 | 110 | for date in dates: 111 | date_values = values_dict[date] 112 | values[header_row].append(date) 113 | # Add all the attendance numbers for each date 114 | for row_name, value in date_values.items(): 115 | row = row_name_to_num[row_name] 116 | if value == 0: 117 | value = '' 118 | values[row].append(value) 119 | 120 | # Add the average at the end of the row 121 | values[header_row].append(ReportGenerator.AVG_COLUMN) 122 | for meeting_id, avg in averages.iteritems(): 123 | row_num = row_name_to_num[meeting_id] 124 | values[row_num].append(avg) 125 | 126 | # Add the last four average at the end of the row 127 | values[header_row].append(ReportGenerator.LAST_FOUR) 128 | for meeting_id, avg in last_fours.iteritems(): 129 | row_num = row_name_to_num[meeting_id] 130 | values[row_num].append(avg) 131 | 132 | return values 133 | 134 | def upload_report(self, report, run_date): 135 | output_file = f"zoom_report_{run_date}" 136 | folder_id = self.google.get_folder_id("CA Reports") 137 | 138 | sheet_id = self.google.create_new_sheet(output_file, folder_id) 139 | result = self.google.insert_df_to_sheet(sheet_id, report) 140 | sheet_link = self.google.get_sheet_link(result.get("spreadsheetId")) 141 | print(f"Finished uploading Zoom report.\n" 142 | f"spreadsheetId: {result.get('updates').get('spreadsheetId')}\n" 143 | f"updatedRange: {result.get('updates').get('updatedRange')}\n" 144 | f"updatedRows: {result.get('updates').get('updatedRows')}\n" 145 | f"link: {sheet_link}") 146 | return sheet_link 147 | 148 | 149 | def main(): 150 | db_name = sys.argv[1] 151 | meeting_ids_file = sys.argv[2] 152 | zoom_api_key = os.environ["ZOOM_API_KEY"] 153 | zoom_api_secret = os.environ["ZOOM_API_SECRET"] 154 | 155 | meeting_ids = None 156 | with open(meeting_ids_file) as f: 157 | meeting_ids = f.read().splitlines() 158 | print(f"Generating report for {len(meeting_ids)} meetings") 159 | 160 | db = DbHelper(db_name) 161 | 162 | zoom = ZoomHelper(ReportGenerator.ZOOM_URL, zoom_api_key, zoom_api_secret) 163 | 164 | service_account_file = f".secrets/{os.listdir('.secrets')[0]}" 165 | google_helper = GoogleHelper(service_account_file, ReportGenerator.SCOPES) 166 | 167 | data_fetcher = DataFetcher(db, zoom) 168 | rg = ReportGenerator(data_fetcher, google_helper) 169 | report = rg.generate_report(meeting_ids) 170 | values = rg.dataframe_to_array(report) 171 | 172 | run_date = datetime.datetime.now().date().strftime('%Y-%m-%d') 173 | 174 | return rg.upload_report(values, run_date) 175 | 176 | 177 | if __name__ == "__main__": 178 | main() 179 | 180 | -------------------------------------------------------------------------------- /tests/test_report_generator.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | import responses 4 | import pandas as pd 5 | import datetime as dt 6 | 7 | from processor.model import Meeting 8 | from processor.report_generator import ReportGenerator 9 | 10 | from tests.conftest import make_meeting_instance, attend_meeting_with_new_participant 11 | from tests.conftest import attend_meeting 12 | 13 | 14 | def test_get_attendances(report_generator, data_fetcher, meeting, meeting_instance, mocker): 15 | # Generate test data 16 | mi_2 = make_meeting_instance(meeting, "second meeting") 17 | a, _ = attend_meeting_with_new_participant(meeting_instance, "a") 18 | b, _ = attend_meeting_with_new_participant(mi_2, "b") 19 | c, _ = attend_meeting_with_new_participant(mi_2, "c") 20 | 21 | mocker.patch.object(data_fetcher, "fetch_meeting_details") 22 | data_fetcher.fetch_meeting_details.return_value = meeting 23 | 24 | mocker.patch.object(data_fetcher, "fetch_past_meeting_instances") 25 | data_fetcher.fetch_past_meeting_instances.return_value = [meeting_instance, mi_2] 26 | 27 | mocker.patch.object(data_fetcher, "fetch_meeting_participants") 28 | data_fetcher.fetch_meeting_participants.side_effect = [[a], [b, c]] 29 | 30 | rep = report_generator.get_attendances(meeting.meeting_id) 31 | 32 | assert len(rep) == 2 33 | 34 | result_participants = rep[meeting_instance] 35 | assert 1 == len(result_participants) 36 | 37 | result_participants = rep[mi_2] 38 | assert 2 == len(result_participants) 39 | 40 | data_fetcher.fetch_meeting_details.assert_called_with(meeting.meeting_id) 41 | 42 | 43 | @responses.activate 44 | def test_report_generation(report_generator, meeting, meeting_instance, mocker): 45 | # Generate some test data 46 | mi_2 = make_meeting_instance(meeting, "second meeting instance id", start_time=dt.datetime(2020, 5, 18)) 47 | a, _ = attend_meeting_with_new_participant(meeting_instance, "a") 48 | b, _ = attend_meeting_with_new_participant(mi_2, "b") 49 | c, _ = attend_meeting_with_new_participant(mi_2, "c") 50 | 51 | m2 = Meeting.create(meeting_id="meeting two", topic="meeting two topic") 52 | mi_3 = make_meeting_instance(m2, "third meeting instance id") 53 | attend_meeting(mi_3, b) 54 | 55 | attendance_m1 = {meeting_instance: [a], mi_2: [b, c]} 56 | attendance_m2 = {mi_3: [b]} 57 | 58 | # mock the call to get_participants_for_meeting 59 | mocker.patch.object(report_generator, "get_attendances") 60 | report_generator.get_attendances.side_effect = [attendance_m1, attendance_m2] 61 | 62 | df = report_generator.generate_report([meeting.meeting_id, m2.meeting_id]) 63 | assert 10 == df.size 64 | 65 | assert 1 == df.at[meeting.meeting_id, "2020-05-17"] 66 | assert 1 == df.at[m2.meeting_id, "2020-05-17"] 67 | assert meeting.topic == df.at[meeting.meeting_id, 'Name'] 68 | avg = df.at[meeting.meeting_id, ReportGenerator.AVG_COLUMN] 69 | assert math.isclose(1.5, avg, rel_tol=1e-5) 70 | avg = df.at[meeting.meeting_id, ReportGenerator.LAST_FOUR] 71 | assert math.isclose(1.5, avg, rel_tol=1e-5) 72 | 73 | assert 2 == df.at[meeting.meeting_id, "2020-05-18"] 74 | assert 0 == df.at[m2.meeting_id, "2020-05-18"] 75 | assert m2.topic == df.at[m2.meeting_id, 'Name'] 76 | avg = df.at[m2.meeting_id, ReportGenerator.AVG_COLUMN] 77 | assert math.isclose(1.0, avg, rel_tol=1e-5) 78 | avg = df.at[m2.meeting_id, ReportGenerator.LAST_FOUR] 79 | assert math.isclose(1.0, avg, rel_tol=1e-5) 80 | 81 | 82 | def test_dataframe_to_array(): 83 | values_dict = {'Name': {'meeting_1': 'topic 1', 'meeting_2': 'topic 2'}, 84 | "2020-08-01": {'meeting_1': 1.0, 'meeting_2': 3.0}, 85 | "2020-08-02": {'meeting_1': 2.0, 'meeting_2': 0.0}, 86 | ReportGenerator.AVG_COLUMN: {'meeting_1': 1.5, 'meeting_2': 3.0}, 87 | ReportGenerator.LAST_FOUR: {'meeting_1': 1.5, 'meeting_2': 3.0}} 88 | df = pd.DataFrame(values_dict) 89 | expected = [['Meeting ID', 'Name', "2020-08-01", "2020-08-02", 90 | ReportGenerator.AVG_COLUMN, ReportGenerator.LAST_FOUR], 91 | ['meeting_1', 'topic 1', 1.0, 2.0, 1.5, 1.5], 92 | ['meeting_2', 'topic 2', 3.0, '', 3.0, 3.0]] 93 | assert ReportGenerator.dataframe_to_array(df) == expected 94 | 95 | 96 | def test_dataframe_to_array_sorted(): 97 | values_dict = {'Name': {'meeting_1': 'topic 1', 'meeting_2': 'topic 2'}, 98 | "2020-08-01": {'meeting_1': 1.0, 'meeting_2': 3.0}, 99 | "2020-08-03": {'meeting_1': 3.0, 'meeting_2': 1.0}, 100 | "2020-08-02": {'meeting_1': 2.0, 'meeting_2': 0.0}, 101 | ReportGenerator.AVG_COLUMN: {'meeting_1': 2.0, 'meeting_2': 2.0}, 102 | ReportGenerator.LAST_FOUR: {'meeting_1': 2.0, 'meeting_2': 2.0}} 103 | df = pd.DataFrame(values_dict) 104 | expected = [['Meeting ID', 'Name', "2020-08-01", "2020-08-02", "2020-08-03", 105 | ReportGenerator.AVG_COLUMN, ReportGenerator.LAST_FOUR], 106 | ['meeting_1', 'topic 1', 1.0, 2.0, 3.0, 2.0, 2.0], 107 | ['meeting_2', 'topic 2', 3.0, '', 1.0, 2.0, 2.0]] 108 | assert ReportGenerator.dataframe_to_array(df) == expected 109 | 110 | 111 | @responses.activate 112 | def test_last_four_average(report_generator, mocker): 113 | # Generate test data 114 | 115 | # Meeting 1 116 | m1 = Meeting.create(meeting_id="meeting one", topic="meeting one topic") 117 | mi_1_1 = make_meeting_instance(m1, "meeting instance 1_1", start_time=dt.datetime(2020, 5, 1)) 118 | a, _ = attend_meeting_with_new_participant(mi_1_1, "a") 119 | 120 | mi_1_2 = make_meeting_instance(m1, "meeting instance 1_2", start_time=dt.datetime(2020, 5, 2)) 121 | b, _ = attend_meeting_with_new_participant(mi_1_2, "b") 122 | c, _ = attend_meeting_with_new_participant(mi_1_2, "c") 123 | d, _ = attend_meeting_with_new_participant(mi_1_2, "d") 124 | e, _ = attend_meeting_with_new_participant(mi_1_2, "e") 125 | 126 | mi_1_3 = make_meeting_instance(m1, "meeting instance 1_3", start_time=dt.datetime(2020, 5, 3)) 127 | mi_1_4 = make_meeting_instance(m1, "meeting instance 1_4", start_time=dt.datetime(2020, 5, 4)) 128 | mi_1_5 = make_meeting_instance(m1, "meeting instance 1_5", start_time=dt.datetime(2020, 5, 5)) 129 | 130 | # Meeting 2 131 | m2 = Meeting.create(meeting_id="meeting two", topic="meeting two topic") 132 | mi_2_1 = make_meeting_instance(m2, "meeting instance 2_1", start_time=dt.datetime(2020, 5, 1)) 133 | mi_2_2 = make_meeting_instance(m2, "meeting instance 2_2", start_time=dt.datetime(2020, 5, 2)) 134 | mi_2_3 = make_meeting_instance(m2, "meeting instance 2_3", start_time=dt.datetime(2020, 5, 3)) 135 | 136 | # Simulate attendances 137 | attendance_m1 = {mi_1_1: [a], mi_1_2: [b, c], mi_1_3: [a, b], mi_1_4: [a, b], mi_1_5: [a, b, c, d, e]} 138 | attendance_m2 = {mi_2_1: [a], mi_2_2: [b], mi_2_3: [c]} 139 | 140 | # mock the call to get_participants_for_meeting 141 | mocker.patch.object(report_generator, "get_attendances") 142 | report_generator.get_attendances.side_effect = [attendance_m1, attendance_m2] 143 | 144 | df = report_generator.generate_report([m1.meeting_id, m2.meeting_id]) 145 | 146 | assert 1 == df.at[m1.meeting_id, "2020-05-01"] 147 | assert 2 == df.at[m1.meeting_id, "2020-05-02"] 148 | assert 2 == df.at[m1.meeting_id, "2020-05-03"] 149 | avg = df.at[m1.meeting_id, ReportGenerator.AVG_COLUMN] 150 | assert math.isclose(2.4, avg, rel_tol=1e-5) 151 | avg = df.at[m1.meeting_id, ReportGenerator.LAST_FOUR] 152 | assert math.isclose(2.75, avg, rel_tol=1e-5) 153 | 154 | assert 1 == df.at[m2.meeting_id, "2020-05-01"] 155 | assert 1 == df.at[m2.meeting_id, "2020-05-02"] 156 | assert 1 == df.at[m2.meeting_id, "2020-05-03"] 157 | avg = df.at[m2.meeting_id, ReportGenerator.AVG_COLUMN] 158 | assert math.isclose(1.0, avg, rel_tol=1e-5) 159 | avg = df.at[m2.meeting_id, ReportGenerator.LAST_FOUR] 160 | assert math.isclose(1.0, avg, rel_tol=1e-5) 161 | 162 | -------------------------------------------------------------------------------- /tests/test_data/past_participants_report.json: -------------------------------------------------------------------------------- 1 | { 2 | "page_count": 1, 3 | "page_size": 200, 4 | "total_records": 66, 5 | "next_page_token": "", 6 | "participants": [ 7 | { 8 | "id": "4hZu08-bRseF0pCvqEqlNA", 9 | "name": "CircleAnywhere Sessions Evens", 10 | "user_email": "official_account@bmail.com" 11 | }, 12 | { 13 | "id": "hknYByqFRRqDzygYOVnztA", 14 | "name": "Alice", 15 | "user_email": "" 16 | }, 17 | { 18 | "id": "8SlKrL_1GdevdBnLYMhZMB", 19 | "name": "Boris Yeltsin", 20 | "user_email": "" 21 | }, 22 | { 23 | "id": "io3jI7zVQ76GDtKfenkB_A", 24 | "name": "Brian Raszap", 25 | "user_email": "" 26 | }, 27 | { 28 | "id": "io3jI7zVQ76GDtKfenkB_A", 29 | "name": "Brian Raszap", 30 | "user_email": "" 31 | }, 32 | { 33 | "id": "ZMDfN8RGTA6jqXVwg9S5yA", 34 | "name": "Oswald", 35 | "user_email": "oswald@bmail.com" 36 | }, 37 | { 38 | "id": "a8uDL_x9RWCPTX1AggjRgA", 39 | "name": "Lama Rinpoche", 40 | "user_email": "lama@bmail.com" 41 | }, 42 | { 43 | "id": "vUudXKToQ-G1ba_UJwpX6A", 44 | "name": "Hieronimous Bosch", 45 | "user_email": "" 46 | }, 47 | { 48 | "id": "ipZ27eMuSqmPgYRmz45UBA", 49 | "name": "Nacho Libre", 50 | "user_email": "" 51 | }, 52 | { 53 | "id": "cDXNGZ5nSm6Wo1mjUTC1kA", 54 | "name": "Monsegnior Maverick", 55 | "user_email": "maverick@bmail.com" 56 | }, 57 | { 58 | "id": "t0r7UoRHTpSuxhWE7UgWZA", 59 | "name": "Javier X", 60 | "user_email": "Javier@bmail.com" 61 | }, 62 | { 63 | "id": "c6O2IenFRD2oYwDEU2QFYA", 64 | "name": "Ignatius Loyola", 65 | "user_email": "ignatius@bmail.com" 66 | }, 67 | { 68 | "id": "qAcLZkbURrCoy8C25F_LzA", 69 | "name": "Kane Citizen", 70 | "user_email": "kane@bmail.com" 71 | }, 72 | { 73 | "id": "cV6TKc-UTm2swvg_ZH-f3A", 74 | "name": "Carl Jung", 75 | "user_email": "carl@bmail.com" 76 | }, 77 | { 78 | "id": "q5Oq3dU8QaquhvhwDK-Q3A", 79 | "name": "Daniel Book", 80 | "user_email": "daniel@bmail.com" 81 | }, 82 | { 83 | "id": "braAOipJTAGHibt-vQqo6A", 84 | "name": "Ernest Being", 85 | "user_email": "ernest@bmail.com" 86 | }, 87 | { 88 | "id": "NsCOmp2xSW6Y9d0voPhB1A", 89 | "name": "Ferdinand Second", 90 | "user_email": "" 91 | }, 92 | { 93 | "id": "5KhLhNiFRE6rSFDQ8jUG6A", 94 | "name": "Gertrude", 95 | "user_email": "" 96 | }, 97 | { 98 | "id": "vUudXKToQ-G1ba_UJwpX6A", 99 | "name": "Hieronimous Bosch", 100 | "user_email": "" 101 | }, 102 | { 103 | "id": "c6O2IenFRD2oYwDEU2QFYA", 104 | "name": "Ignatius Loyola", 105 | "user_email": "ignatius@bmail.com" 106 | }, 107 | { 108 | "id": "t0r7UoRHTpSuxhWE7UgWZA", 109 | "name": "Javier X", 110 | "user_email": "javier@bmail.com" 111 | }, 112 | { 113 | "id": "qAcLZkbURrCoy8C25F_LzA", 114 | "name": "Kane Citizen", 115 | "user_email": "kane@bmail.com" 116 | }, 117 | { 118 | "id": "a8uDL_x9RWCPTX1AggjRgA", 119 | "name": "Lama Rinpoche", 120 | "user_email": "lama@bmail.com" 121 | }, 122 | { 123 | "id": "5KhLhNiFRE6rSFDQ8jUG6A", 124 | "name": "Gertrude", 125 | "user_email": "" 126 | }, 127 | { 128 | "id": "cDXNGZ5nSm6Wo1mjUTC1kA", 129 | "name": "Monsegnior Maverick", 130 | "user_email": "maverick@bmail.com" 131 | }, 132 | { 133 | "id": "ipZ27eMuSqmPgYRmz45UBA", 134 | "name": "Nacho Libre", 135 | "user_email": "" 136 | }, 137 | { 138 | "id": "NsCOmp2xSW6Y9d0voPhB1A", 139 | "name": "Ferdinand Second", 140 | "user_email": "" 141 | }, 142 | { 143 | "id": "ZMDfN8RGTA6jqXVwg9S5yA", 144 | "name": "Oswald", 145 | "user_email": "oswald@bmail.com" 146 | }, 147 | { 148 | "id": "q5Oq3dU8QaquhvhwDK-Q3A", 149 | "name": "Daniel Book", 150 | "user_email": "daniel@bmail.com" 151 | }, 152 | { 153 | "id": "braAOipJTAGHibt-vQqo6A", 154 | "name": "Ernest Being", 155 | "user_email": "ernest@bmail.com" 156 | }, 157 | { 158 | "id": "hknYByqFRRqDzygYOVnztA", 159 | "name": "Alice", 160 | "user_email": "" 161 | }, 162 | { 163 | "id": "cV6TKc-UTm2swvg_ZH-f3A", 164 | "name": "Carl Jung", 165 | "user_email": "carl@bmail.com" 166 | }, 167 | { 168 | "id": "8SlKrL_1GdevdBnLYMhZMB", 169 | "name": "Boris Yeltsin", 170 | "user_email": "" 171 | }, 172 | { 173 | "id": "", 174 | "name": "15556043864", 175 | "user_email": "" 176 | }, 177 | { 178 | "id": "0tfOUobiQ2uj9rYrMC8mRA", 179 | "name": "Precise Pangolin", 180 | "user_email": "" 181 | }, 182 | { 183 | "id": "kInQcFizRHiG3c_g5cyPBA", 184 | "name": "Alice", 185 | "user_email": "alicia3@bmail.com" 186 | }, 187 | { 188 | "id": "kInQcFizRHiG3c_g5cyPBA", 189 | "name": "Alice", 190 | "user_email": "alicia3@bmail.com" 191 | }, 192 | { 193 | "id": "kInQcFizRHiG3c_g5cyPBA", 194 | "name": "Alice", 195 | "user_email": "alicia3@bmail.com" 196 | }, 197 | { 198 | "id": "kInQcFizRHiG3c_g5cyPBA", 199 | "name": "Alice", 200 | "user_email": "alicia3@bmail.com" 201 | }, 202 | { 203 | "id": "kInQcFizRHiG3c_g5cyPBA", 204 | "name": "Alice", 205 | "user_email": "alicia3@bmail.com" 206 | }, 207 | { 208 | "id": "", 209 | "name": "15558146585", 210 | "user_email": "" 211 | }, 212 | { 213 | "id": "kInQcFizRHiG3c_g5cyPBA", 214 | "name": "Alice", 215 | "user_email": "alicia3@bmail.com" 216 | }, 217 | { 218 | "id": "kInQcFizRHiG3c_g5cyPBA", 219 | "name": "Alice", 220 | "user_email": "alicia3@bmail.com" 221 | }, 222 | { 223 | "id": "kInQcFizRHiG3c_g5cyPBA", 224 | "name": "Alice", 225 | "user_email": "alicia3@bmail.com" 226 | }, 227 | { 228 | "id": "kInQcFizRHiG3c_g5cyPBA", 229 | "name": "Alice", 230 | "user_email": "alicia3@bmail.com" 231 | }, 232 | { 233 | "id": "kInQcFizRHiG3c_g5cyPBA", 234 | "name": "Alice", 235 | "user_email": "alicia3@bmail.com" 236 | }, 237 | { 238 | "id": "kInQcFizRHiG3c_g5cyPBA", 239 | "name": "Alice", 240 | "user_email": "alicia3@bmail.com" 241 | }, 242 | { 243 | "id": "kInQcFizRHiG3c_g5cyPBA", 244 | "name": "Alice", 245 | "user_email": "alicia3@bmail.com" 246 | }, 247 | { 248 | "id": "kInQcFizRHiG3c_g5cyPBA", 249 | "name": "Alice", 250 | "user_email": "alicia3@bmail.com" 251 | }, 252 | { 253 | "id": "kInQcFizRHiG3c_g5cyPBA", 254 | "name": "Alice", 255 | "user_email": "alicia3@bmail.com" 256 | }, 257 | { 258 | "id": "kInQcFizRHiG3c_g5cyPBA", 259 | "name": "Alice", 260 | "user_email": "alicia3@bmail.com" 261 | }, 262 | { 263 | "id": "kInQcFizRHiG3c_g5cyPBA", 264 | "name": "Alice", 265 | "user_email": "alicia3@bmail.com" 266 | }, 267 | { 268 | "id": "kInQcFizRHiG3c_g5cyPBA", 269 | "name": "Alice", 270 | "user_email": "alicia3@bmail.com" 271 | }, 272 | { 273 | "id": "kInQcFizRHiG3c_g5cyPBA", 274 | "name": "Alice", 275 | "user_email": "alicia3@bmail.com" 276 | }, 277 | { 278 | "id": "kInQcFizRHiG3c_g5cyPBA", 279 | "name": "Alice", 280 | "user_email": "alicia3@bmail.com" 281 | }, 282 | { 283 | "id": "kInQcFizRHiG3c_g5cyPBA", 284 | "name": "Alice", 285 | "user_email": "alicia3@bmail.com" 286 | }, 287 | { 288 | "id": "kInQcFizRHiG3c_g5cyPBA", 289 | "name": "Alice", 290 | "user_email": "alicia3@bmail.com" 291 | }, 292 | { 293 | "id": "kInQcFizRHiG3c_g5cyPBA", 294 | "name": "Alice", 295 | "user_email": "alicia3@bmail.com" 296 | }, 297 | { 298 | "id": "kInQcFizRHiG3c_g5cyPBA", 299 | "name": "Alice", 300 | "user_email": "alicia3@bmail.com" 301 | }, 302 | { 303 | "id": "kInQcFizRHiG3c_g5cyPBA", 304 | "name": "Alice", 305 | "user_email": "alicia3@bmail.com" 306 | }, 307 | { 308 | "id": "kInQcFizRHiG3c_g5cyPBA", 309 | "name": "Alice", 310 | "user_email": "alicia3@bmail.com" 311 | }, 312 | { 313 | "id": "kInQcFizRHiG3c_g5cyPBA", 314 | "name": "Alice", 315 | "user_email": "alicia3@bmail.com" 316 | }, 317 | { 318 | "id": "kInQcFizRHiG3c_g5cyPBA", 319 | "name": "Alice", 320 | "user_email": "alicia3@bmail.com" 321 | }, 322 | { 323 | "id": "kInQcFizRHiG3c_g5cyPBA", 324 | "name": "Alice", 325 | "user_email": "alicia3@bmail.com" 326 | }, 327 | { 328 | "id": "kInQcFizRHiG3c_g5cyPBA", 329 | "name": "Alice", 330 | "user_email": "alicia3@bmail.com" 331 | }, 332 | { 333 | "id": "kInQcFizRHiG3c_g5cyPBA", 334 | "name": "Alice", 335 | "user_email": "alicia3@bmail.com" 336 | } 337 | ] 338 | } -------------------------------------------------------------------------------- /tests/test_data/past_participants_duplicates.json: -------------------------------------------------------------------------------- 1 | { 2 | "page_count": 1, 3 | "page_size": 200, 4 | "total_records": 39, 5 | "next_page_token": "", 6 | "participants": [ 7 | { 8 | "id": "4hZu08-bRseF0pCvqEqlNg", 9 | "user_id": "16778240", 10 | "name": "CircleAnywhere Sessions Evens", 11 | "user_email": "circleanywhere.calendar@gmail.com", 12 | "join_time": "2020-08-07T21:57:27Z", 13 | "leave_time": "2020-08-07T23:04:03Z", 14 | "duration": 3996, 15 | "attentiveness_score": "" 16 | }, 17 | { 18 | "id": "yb6JpVHwTM6zv7xabg8FPA", 19 | "user_id": "16779264", 20 | "name": "Marc Marshall", 21 | "user_email": "marc@hypnomarc.com", 22 | "join_time": "2020-08-07T21:57:47Z", 23 | "leave_time": "2020-08-07T22:00:07Z", 24 | "duration": 140, 25 | "attentiveness_score": "" 26 | }, 27 | { 28 | "id": "d7PPqJT5RAmnjydFaCjUkA", 29 | "user_id": "16780288", 30 | "name": "Pat Coulston", 31 | "user_email": "", 32 | "join_time": "2020-08-07T21:57:50Z", 33 | "leave_time": "2020-08-07T22:00:04Z", 34 | "duration": 134, 35 | "attentiveness_score": "" 36 | }, 37 | { 38 | "id": "a8uDL_x9RWCPTX1AggjRgw", 39 | "user_id": "16781312", 40 | "name": "Ross Callahan", 41 | "user_email": "rosscallahanpsyd@gmail.com", 42 | "join_time": "2020-08-07T21:57:57Z", 43 | "leave_time": "2020-08-07T22:00:05Z", 44 | "duration": 128, 45 | "attentiveness_score": "" 46 | }, 47 | { 48 | "id": "dKsqAy9sQRmpZhZPhfJdYA", 49 | "user_id": "16782336", 50 | "name": "maybe david", 51 | "user_email": "", 52 | "join_time": "2020-08-07T21:58:01Z", 53 | "leave_time": "2020-08-07T22:00:04Z", 54 | "duration": 123, 55 | "attentiveness_score": "" 56 | }, 57 | { 58 | "id": "cxwaJ7OQQ06MN3qDjUM-dg", 59 | "user_id": "16783360", 60 | "name": "Thea Kremser", 61 | "user_email": "", 62 | "join_time": "2020-08-07T21:58:10Z", 63 | "leave_time": "2020-08-07T22:00:08Z", 64 | "duration": 118, 65 | "attentiveness_score": "" 66 | }, 67 | { 68 | "id": "xKTbi09KQ9Ss4KccBbkU8w", 69 | "user_id": "16784384", 70 | "name": "Rosalie Marktl", 71 | "user_email": "", 72 | "join_time": "2020-08-07T21:59:11Z", 73 | "leave_time": "2020-08-07T22:00:06Z", 74 | "duration": 55, 75 | "attentiveness_score": "" 76 | }, 77 | { 78 | "id": "O_vvLGheSyGQaFJzNSnbhA", 79 | "user_id": "16785408", 80 | "name": "Kim", 81 | "user_email": "", 82 | "join_time": "2020-08-07T21:59:13Z", 83 | "leave_time": "2020-08-07T22:00:04Z", 84 | "duration": 51, 85 | "attentiveness_score": "" 86 | }, 87 | { 88 | "id": "cV6TKc-UTm2swvg_ZH-f3w", 89 | "user_id": "16786432", 90 | "name": "Zachary Farber", 91 | "user_email": "prozach805@gmail.com", 92 | "join_time": "2020-08-07T21:59:27Z", 93 | "leave_time": "2020-08-07T22:00:05Z", 94 | "duration": 38, 95 | "attentiveness_score": "" 96 | }, 97 | { 98 | "id": "qAcLZkbURrCoy8C25F_Lzg", 99 | "user_id": "16787456", 100 | "name": "Alex", 101 | "user_email": "alexperrone@gmail.com", 102 | "join_time": "2020-08-07T21:59:33Z", 103 | "leave_time": "2020-08-07T22:00:04Z", 104 | "duration": 31, 105 | "attentiveness_score": "" 106 | }, 107 | { 108 | "id": "8h8YO-foRqqorU-ZyTz8OA", 109 | "user_id": "16788480", 110 | "name": "Danielle Leff", 111 | "user_email": "danielleleff@gmail.com", 112 | "join_time": "2020-08-07T21:59:47Z", 113 | "leave_time": "2020-08-07T22:00:10Z", 114 | "duration": 23, 115 | "attentiveness_score": "" 116 | }, 117 | { 118 | "id": "d7PPqJT5RAmnjydFaCjUkA", 119 | "user_id": "16789504", 120 | "name": "Pat Coulston", 121 | "user_email": "", 122 | "join_time": "2020-08-07T22:00:04Z", 123 | "leave_time": "2020-08-07T23:04:03Z", 124 | "duration": 3839, 125 | "attentiveness_score": "" 126 | }, 127 | { 128 | "id": "O_vvLGheSyGQaFJzNSnbhA", 129 | "user_id": "16790528", 130 | "name": "Kim", 131 | "user_email": "", 132 | "join_time": "2020-08-07T22:00:05Z", 133 | "leave_time": "2020-08-07T23:04:03Z", 134 | "duration": 3838, 135 | "attentiveness_score": "" 136 | }, 137 | { 138 | "id": "qAcLZkbURrCoy8C25F_Lzg", 139 | "user_id": "16791552", 140 | "name": "Alex", 141 | "user_email": "alexperrone@gmail.com", 142 | "join_time": "2020-08-07T22:00:05Z", 143 | "leave_time": "2020-08-07T23:03:59Z", 144 | "duration": 3834, 145 | "attentiveness_score": "" 146 | }, 147 | { 148 | "id": "dKsqAy9sQRmpZhZPhfJdYA", 149 | "user_id": "16792576", 150 | "name": "maybe david", 151 | "user_email": "", 152 | "join_time": "2020-08-07T22:00:05Z", 153 | "leave_time": "2020-08-07T23:04:03Z", 154 | "duration": 3838, 155 | "attentiveness_score": "" 156 | }, 157 | { 158 | "id": "cV6TKc-UTm2swvg_ZH-f3w", 159 | "user_id": "16793600", 160 | "name": "Zachary Farber", 161 | "user_email": "prozach805@gmail.com", 162 | "join_time": "2020-08-07T22:00:06Z", 163 | "leave_time": "2020-08-07T22:04:37Z", 164 | "duration": 271, 165 | "attentiveness_score": "" 166 | }, 167 | { 168 | "id": "a8uDL_x9RWCPTX1AggjRgw", 169 | "user_id": "16794624", 170 | "name": "Ross Callahan", 171 | "user_email": "rosscallahanpsyd@gmail.com", 172 | "join_time": "2020-08-07T22:00:06Z", 173 | "leave_time": "2020-08-07T23:03:52Z", 174 | "duration": 3826, 175 | "attentiveness_score": "" 176 | }, 177 | { 178 | "id": "xKTbi09KQ9Ss4KccBbkU8w", 179 | "user_id": "16795648", 180 | "name": "Rosalie Marktl", 181 | "user_email": "", 182 | "join_time": "2020-08-07T22:00:07Z", 183 | "leave_time": "2020-08-07T22:00:54Z", 184 | "duration": 47, 185 | "attentiveness_score": "" 186 | }, 187 | { 188 | "id": "yb6JpVHwTM6zv7xabg8FPA", 189 | "user_id": "16796672", 190 | "name": "Marc Marshall", 191 | "user_email": "marc@hypnomarc.com", 192 | "join_time": "2020-08-07T22:00:08Z", 193 | "leave_time": "2020-08-07T23:03:53Z", 194 | "duration": 3825, 195 | "attentiveness_score": "" 196 | }, 197 | { 198 | "id": "cxwaJ7OQQ06MN3qDjUM-dg", 199 | "user_id": "16797696", 200 | "name": "Thea Kremser", 201 | "user_email": "", 202 | "join_time": "2020-08-07T22:00:08Z", 203 | "leave_time": "2020-08-07T22:42:00Z", 204 | "duration": 2512, 205 | "attentiveness_score": "" 206 | }, 207 | { 208 | "id": "8h8YO-foRqqorU-ZyTz8OA", 209 | "user_id": "16798720", 210 | "name": "Danielle Leff", 211 | "user_email": "danielleleff@gmail.com", 212 | "join_time": "2020-08-07T22:00:10Z", 213 | "leave_time": "2020-08-07T22:01:59Z", 214 | "duration": 109, 215 | "attentiveness_score": "" 216 | }, 217 | { 218 | "id": "9sMGG7EwSyWlQPrsK4tBKw", 219 | "user_id": "16799744", 220 | "name": "Yvonne yaya Sherajoycry", 221 | "user_email": "lafondahome@gmail.com", 222 | "join_time": "2020-08-07T22:01:24Z", 223 | "leave_time": "2020-08-07T22:01:30Z", 224 | "duration": 6, 225 | "attentiveness_score": "" 226 | }, 227 | { 228 | "id": "9sMGG7EwSyWlQPrsK4tBKw", 229 | "user_id": "16800768", 230 | "name": "Yvonne yaya Sherajoycry", 231 | "user_email": "lafondahome@gmail.com", 232 | "join_time": "2020-08-07T22:01:30Z", 233 | "leave_time": "2020-08-07T22:30:47Z", 234 | "duration": 1757, 235 | "attentiveness_score": "" 236 | }, 237 | { 238 | "id": "8h8YO-foRqqorU-ZyTz8OA", 239 | "user_id": "16801792", 240 | "name": "Danielle Leff", 241 | "user_email": "danielleleff@gmail.com", 242 | "join_time": "2020-08-07T22:02:01Z", 243 | "leave_time": "2020-08-07T22:02:49Z", 244 | "duration": 48, 245 | "attentiveness_score": "" 246 | }, 247 | { 248 | "id": "8h8YO-foRqqorU-ZyTz8OA", 249 | "user_id": "16802816", 250 | "name": "Danielle Leff", 251 | "user_email": "danielleleff@gmail.com", 252 | "join_time": "2020-08-07T22:03:04Z", 253 | "leave_time": "2020-08-07T22:03:58Z", 254 | "duration": 54, 255 | "attentiveness_score": "" 256 | }, 257 | { 258 | "id": "cV6TKc-UTm2swvg_ZH-f3w", 259 | "user_id": "16803840", 260 | "name": "Zachary Farber", 261 | "user_email": "prozach805@gmail.com", 262 | "join_time": "2020-08-07T22:04:37Z", 263 | "leave_time": "2020-08-07T23:03:59Z", 264 | "duration": 3562, 265 | "attentiveness_score": "" 266 | }, 267 | { 268 | "id": "V6UqacGeRYSl1OhAS7T5yg", 269 | "user_id": "16804864", 270 | "name": "Diana Ospina", 271 | "user_email": "", 272 | "join_time": "2020-08-07T22:06:13Z", 273 | "leave_time": "2020-08-07T22:06:20Z", 274 | "duration": 7, 275 | "attentiveness_score": "" 276 | }, 277 | { 278 | "id": "V6UqacGeRYSl1OhAS7T5yg", 279 | "user_id": "16805888", 280 | "name": "Diana Ospina", 281 | "user_email": "", 282 | "join_time": "2020-08-07T22:06:21Z", 283 | "leave_time": "2020-08-07T23:04:03Z", 284 | "duration": 3462, 285 | "attentiveness_score": "" 286 | }, 287 | { 288 | "id": "g0Lur-VfT4GcAgwhE6FSlw", 289 | "user_id": "16806912", 290 | "name": "Andres E.", 291 | "user_email": "", 292 | "join_time": "2020-08-07T22:08:15Z", 293 | "leave_time": "2020-08-07T22:08:25Z", 294 | "duration": 10, 295 | "attentiveness_score": "" 296 | }, 297 | { 298 | "id": "g0Lur-VfT4GcAgwhE6FSlw", 299 | "user_id": "16807936", 300 | "name": "Andres E.", 301 | "user_email": "", 302 | "join_time": "2020-08-07T22:08:26Z", 303 | "leave_time": "2020-08-07T22:18:29Z", 304 | "duration": 603, 305 | "attentiveness_score": "" 306 | }, 307 | { 308 | "id": "8h8YO-foRqqorU-ZyTz8OA", 309 | "user_id": "16808960", 310 | "name": "Danielle Leff", 311 | "user_email": "danielleleff@gmail.com", 312 | "join_time": "2020-08-07T22:09:07Z", 313 | "leave_time": "2020-08-07T22:09:15Z", 314 | "duration": 8, 315 | "attentiveness_score": "" 316 | }, 317 | { 318 | "id": "8h8YO-foRqqorU-ZyTz8OA", 319 | "user_id": "16809984", 320 | "name": "Danielle Leff", 321 | "user_email": "danielleleff@gmail.com", 322 | "join_time": "2020-08-07T22:09:16Z", 323 | "leave_time": "2020-08-07T22:40:10Z", 324 | "duration": 1854, 325 | "attentiveness_score": "" 326 | }, 327 | { 328 | "id": "PqotsLAvTEGSXS8VayIPgQ", 329 | "user_id": "16811008", 330 | "name": "Andres E.", 331 | "user_email": "", 332 | "join_time": "2020-08-07T22:26:42Z", 333 | "leave_time": "2020-08-07T22:28:10Z", 334 | "duration": 88, 335 | "attentiveness_score": "" 336 | }, 337 | { 338 | "id": "PqotsLAvTEGSXS8VayIPgQ", 339 | "user_id": "16812032", 340 | "name": "Andres E.", 341 | "user_email": "", 342 | "join_time": "2020-08-07T22:28:10Z", 343 | "leave_time": "2020-08-07T22:43:57Z", 344 | "duration": 947, 345 | "attentiveness_score": "" 346 | }, 347 | { 348 | "id": "8h8YO-foRqqorU-ZyTz8OA", 349 | "user_id": "16813056", 350 | "name": "Danielle Leff", 351 | "user_email": "danielleleff@gmail.com", 352 | "join_time": "2020-08-07T22:40:10Z", 353 | "leave_time": "2020-08-07T22:43:50Z", 354 | "duration": 220, 355 | "attentiveness_score": "" 356 | }, 357 | { 358 | "id": "cxwaJ7OQQ06MN3qDjUM-dg", 359 | "user_id": "16814080", 360 | "name": "Thea", 361 | "user_email": "", 362 | "join_time": "2020-08-07T22:42:08Z", 363 | "leave_time": "2020-08-07T23:04:33Z", 364 | "duration": 1345, 365 | "attentiveness_score": "" 366 | }, 367 | { 368 | "id": "UgfZxUqDSAeD7BemdlSVng", 369 | "user_id": "16815104", 370 | "name": "Andres E.", 371 | "user_email": "anecca.iii@gmail.com", 372 | "join_time": "2020-08-07T22:43:35Z", 373 | "leave_time": "2020-08-07T22:43:46Z", 374 | "duration": 11, 375 | "attentiveness_score": "" 376 | }, 377 | { 378 | "id": "UgfZxUqDSAeD7BemdlSVng", 379 | "user_id": "16816128", 380 | "name": "Andres E.", 381 | "user_email": "anecca.iii@gmail.com", 382 | "join_time": "2020-08-07T22:43:46Z", 383 | "leave_time": "2020-08-07T23:03:59Z", 384 | "duration": 1213, 385 | "attentiveness_score": "" 386 | }, 387 | { 388 | "id": "8h8YO-foRqqorU-ZyTz8OA", 389 | "user_id": "16817152", 390 | "name": "Danielle Leff", 391 | "user_email": "danielleleff@gmail.com", 392 | "join_time": "2020-08-07T22:44:07Z", 393 | "leave_time": "2020-08-07T23:03:54Z", 394 | "duration": 1187, 395 | "attentiveness_score": "" 396 | } 397 | ] 398 | } -------------------------------------------------------------------------------- /tests/test_data/past_participants_name_change.json: -------------------------------------------------------------------------------- 1 | { 2 | "page_count": 1, 3 | "page_size": 200, 4 | "total_records": 39, 5 | "next_page_token": "", 6 | "participants": [ 7 | { 8 | "id": "123408-bRseF0pCvqEqlNg", 9 | "user_id": "55578240", 10 | "name": "Official", 11 | "user_email": "official.calendar@bmail.com", 12 | "join_time": "2020-08-07T21:57:27Z", 13 | "leave_time": "2020-08-07T23:04:03Z", 14 | "duration": 3996, 15 | "attentiveness_score": "" 16 | }, 17 | { 18 | "id": "1234pVHwTM6zv7xabg8FPA", 19 | "user_id": "55579264", 20 | "name": "Alice", 21 | "user_email": "alice@alicesite.com", 22 | "join_time": "2020-08-07T21:57:47Z", 23 | "leave_time": "2020-08-07T22:00:07Z", 24 | "duration": 140, 25 | "attentiveness_score": "" 26 | }, 27 | { 28 | "id": "1234qJT5RAmnjydFaCjUkA", 29 | "user_id": "55580288", 30 | "name": "Bob McBob", 31 | "user_email": "official.calendar@bmail.com", 32 | "join_time": "2020-08-07T21:57:50Z", 33 | "leave_time": "2020-08-07T22:00:04Z", 34 | "duration": 134, 35 | "attentiveness_score": "" 36 | }, 37 | { 38 | "id": "1234L_x9RWCPTX1AggjRgw", 39 | "user_id": "55581312", 40 | "name": "Craig McCraig", 41 | "user_email": "craigmccraig@bmail.com", 42 | "join_time": "2020-08-07T21:57:57Z", 43 | "leave_time": "2020-08-07T22:00:05Z", 44 | "duration": 128, 45 | "attentiveness_score": "" 46 | }, 47 | { 48 | "id": "1234Ay9sQRmpZhZPhfJdYA", 49 | "user_id": "55582336", 50 | "name": "Dave Davidson", 51 | "user_email": "", 52 | "join_time": "2020-08-07T21:58:01Z", 53 | "leave_time": "2020-08-07T22:00:04Z", 54 | "duration": 123, 55 | "attentiveness_score": "" 56 | }, 57 | { 58 | "id": "1234J7OQQ06MN3qDjUM-dg", 59 | "user_id": "55583360", 60 | "name": "Elice Elipsis", 61 | "user_email": "", 62 | "join_time": "2020-08-07T21:58:10Z", 63 | "leave_time": "2020-08-07T22:00:08Z", 64 | "duration": 118, 65 | "attentiveness_score": "" 66 | }, 67 | { 68 | "id": "1234i09KQ9Ss4KccBbkU8w", 69 | "user_id": "55584384", 70 | "name": "Felicia Felix", 71 | "user_email": "", 72 | "join_time": "2020-08-07T21:59:11Z", 73 | "leave_time": "2020-08-07T22:00:06Z", 74 | "duration": 55, 75 | "attentiveness_score": "" 76 | }, 77 | { 78 | "id": "1234LGheSyGQaFJzNSnbhA", 79 | "user_id": "55585408", 80 | "name": "Gertrude", 81 | "user_email": "", 82 | "join_time": "2020-08-07T21:59:13Z", 83 | "leave_time": "2020-08-07T22:00:04Z", 84 | "duration": 51, 85 | "attentiveness_score": "" 86 | }, 87 | { 88 | "id": "1234Kc-UTm2swvg_ZH-f3w", 89 | "user_id": "55586432", 90 | "name": "Hello World", 91 | "user_email": "helloworld@bmail.com", 92 | "join_time": "2020-08-07T21:59:27Z", 93 | "leave_time": "2020-08-07T22:00:05Z", 94 | "duration": 38, 95 | "attentiveness_score": "" 96 | }, 97 | { 98 | "id": "1234ZkbURrCoy8C25F_Lzg", 99 | "user_id": "55587456", 100 | "name": "Ignatius", 101 | "user_email": "ignatius@bmail.com", 102 | "join_time": "2020-08-07T21:59:33Z", 103 | "leave_time": "2020-08-07T22:00:04Z", 104 | "duration": 31, 105 | "attentiveness_score": "" 106 | }, 107 | { 108 | "id": "1234O-foRqqorU-ZyTz8OA", 109 | "user_id": "55588480", 110 | "name": "Jeraldine Joss", 111 | "user_email": "jeraldinejoss@bmail.com", 112 | "join_time": "2020-08-07T21:59:47Z", 113 | "leave_time": "2020-08-07T22:00:10Z", 114 | "duration": 23, 115 | "attentiveness_score": "" 116 | }, 117 | { 118 | "id": "1234qJT5RAmnjydFaCjUkA", 119 | "user_id": "55589504", 120 | "name": "Bob McBob", 121 | "user_email": "official.calendar@bmail.com", 122 | "join_time": "2020-08-07T22:00:04Z", 123 | "leave_time": "2020-08-07T23:04:03Z", 124 | "duration": 3839, 125 | "attentiveness_score": "" 126 | }, 127 | { 128 | "id": "1234LGheSyGQaFJzNSnbhA", 129 | "user_id": "55590528", 130 | "name": "Gertrude", 131 | "user_email": "", 132 | "join_time": "2020-08-07T22:00:05Z", 133 | "leave_time": "2020-08-07T23:04:03Z", 134 | "duration": 3838, 135 | "attentiveness_score": "" 136 | }, 137 | { 138 | "id": "1234ZkbURrCoy8C25F_Lzg", 139 | "user_id": "55591552", 140 | "name": "Ignatius", 141 | "user_email": "ignatius@bmail.com", 142 | "join_time": "2020-08-07T22:00:05Z", 143 | "leave_time": "2020-08-07T23:03:59Z", 144 | "duration": 3834, 145 | "attentiveness_score": "" 146 | }, 147 | { 148 | "id": "1234Ay9sQRmpZhZPhfJdYA", 149 | "user_id": "55592576", 150 | "name": "Dave Davidson", 151 | "user_email": "", 152 | "join_time": "2020-08-07T22:00:05Z", 153 | "leave_time": "2020-08-07T23:04:03Z", 154 | "duration": 3838, 155 | "attentiveness_score": "" 156 | }, 157 | { 158 | "id": "1234Kc-UTm2swvg_ZH-f3w", 159 | "user_id": "55593600", 160 | "name": "Hello World", 161 | "user_email": "helloworld@bmail.com", 162 | "join_time": "2020-08-07T22:00:06Z", 163 | "leave_time": "2020-08-07T22:04:37Z", 164 | "duration": 271, 165 | "attentiveness_score": "" 166 | }, 167 | { 168 | "id": "1234L_x9RWCPTX1AggjRgw", 169 | "user_id": "55594624", 170 | "name": "Craig McCraig", 171 | "user_email": "craigmccraig@bmail.com", 172 | "join_time": "2020-08-07T22:00:06Z", 173 | "leave_time": "2020-08-07T23:03:52Z", 174 | "duration": 3826, 175 | "attentiveness_score": "" 176 | }, 177 | { 178 | "id": "1234i09KQ9Ss4KccBbkU8w", 179 | "user_id": "55595648", 180 | "name": "Felicia Felix", 181 | "user_email": "", 182 | "join_time": "2020-08-07T22:00:07Z", 183 | "leave_time": "2020-08-07T22:00:54Z", 184 | "duration": 47, 185 | "attentiveness_score": "" 186 | }, 187 | { 188 | "id": "1234pVHwTM6zv7xabg8FPA", 189 | "user_id": "55596672", 190 | "name": "Alice", 191 | "user_email": "alice@alicesite.com", 192 | "join_time": "2020-08-07T22:00:08Z", 193 | "leave_time": "2020-08-07T23:03:53Z", 194 | "duration": 3825, 195 | "attentiveness_score": "" 196 | }, 197 | { 198 | "id": "1234J7OQQ06MN3qDjUM-dg", 199 | "user_id": "55597696", 200 | "name": "Elice Elipsis", 201 | "user_email": "", 202 | "join_time": "2020-08-07T22:00:08Z", 203 | "leave_time": "2020-08-07T22:42:00Z", 204 | "duration": 2512, 205 | "attentiveness_score": "" 206 | }, 207 | { 208 | "id": "1234O-foRqqorU-ZyTz8OA", 209 | "user_id": "55598720", 210 | "name": "Jeraldine Joss", 211 | "user_email": "jeraldinejoss@bmail.com", 212 | "join_time": "2020-08-07T22:00:10Z", 213 | "leave_time": "2020-08-07T22:01:59Z", 214 | "duration": 109, 215 | "attentiveness_score": "" 216 | }, 217 | { 218 | "id": "1234G7EwSyWlQPrsK4tBKw", 219 | "user_id": "55599744", 220 | "name": "Kate Katies", 221 | "user_email": "katekaties@bmail.com", 222 | "join_time": "2020-08-07T22:01:24Z", 223 | "leave_time": "2020-08-07T22:01:30Z", 224 | "duration": 6, 225 | "attentiveness_score": "" 226 | }, 227 | { 228 | "id": "1234G7EwSyWlQPrsK4tBKw", 229 | "user_id": "55500768", 230 | "name": "Kate Katies", 231 | "user_email": "katekaties@bmail.com", 232 | "join_time": "2020-08-07T22:01:30Z", 233 | "leave_time": "2020-08-07T22:30:47Z", 234 | "duration": 1757, 235 | "attentiveness_score": "" 236 | }, 237 | { 238 | "id": "1234O-foRqqorU-ZyTz8OA", 239 | "user_id": "55501792", 240 | "name": "Jeraldine Joss", 241 | "user_email": "jeraldinejoss@bmail.com", 242 | "join_time": "2020-08-07T22:02:01Z", 243 | "leave_time": "2020-08-07T22:02:49Z", 244 | "duration": 48, 245 | "attentiveness_score": "" 246 | }, 247 | { 248 | "id": "1234O-foRqqorU-ZyTz8OA", 249 | "user_id": "55502816", 250 | "name": "Jeraldine Joss", 251 | "user_email": "jeraldinejoss@bmail.com", 252 | "join_time": "2020-08-07T22:03:04Z", 253 | "leave_time": "2020-08-07T22:03:58Z", 254 | "duration": 54, 255 | "attentiveness_score": "" 256 | }, 257 | { 258 | "id": "1234Kc-UTm2swvg_ZH-f3w", 259 | "user_id": "55503840", 260 | "name": "Hello World", 261 | "user_email": "helloworld@bmail.com", 262 | "join_time": "2020-08-07T22:04:37Z", 263 | "leave_time": "2020-08-07T23:03:59Z", 264 | "duration": 3562, 265 | "attentiveness_score": "" 266 | }, 267 | { 268 | "id": "1234acGeRYSl1OhAS7T5yg", 269 | "user_id": "55504864", 270 | "name": "Lize Lazy", 271 | "user_email": "", 272 | "join_time": "2020-08-07T22:06:13Z", 273 | "leave_time": "2020-08-07T22:06:20Z", 274 | "duration": 7, 275 | "attentiveness_score": "" 276 | }, 277 | { 278 | "id": "1234acGeRYSl1OhAS7T5yg", 279 | "user_id": "55505888", 280 | "name": "Lize Lazy", 281 | "user_email": "", 282 | "join_time": "2020-08-07T22:06:21Z", 283 | "leave_time": "2020-08-07T23:04:03Z", 284 | "duration": 3462, 285 | "attentiveness_score": "" 286 | }, 287 | { 288 | "id": "1234r-VfT4GcAgwhE6FSlw", 289 | "user_id": "55506912", 290 | "name": "Mario Mario", 291 | "user_email": "", 292 | "join_time": "2020-08-07T22:08:15Z", 293 | "leave_time": "2020-08-07T22:08:25Z", 294 | "duration": 10, 295 | "attentiveness_score": "" 296 | }, 297 | { 298 | "id": "1234r-VfT4GcAgwhE6FSlw", 299 | "user_id": "55507936", 300 | "name": "Mario Mario", 301 | "user_email": "", 302 | "join_time": "2020-08-07T22:08:26Z", 303 | "leave_time": "2020-08-07T22:18:29Z", 304 | "duration": 603, 305 | "attentiveness_score": "" 306 | }, 307 | { 308 | "id": "1234O-foRqqorU-ZyTz8OA", 309 | "user_id": "55508960", 310 | "name": "Jeraldine Joss", 311 | "user_email": "jeraldinejoss@bmail.com", 312 | "join_time": "2020-08-07T22:09:07Z", 313 | "leave_time": "2020-08-07T22:09:15Z", 314 | "duration": 8, 315 | "attentiveness_score": "" 316 | }, 317 | { 318 | "id": "1234O-foRqqorU-ZyTz8OA", 319 | "user_id": "55509984", 320 | "name": "Jeraldine Joss", 321 | "user_email": "jeraldinejoss@bmail.com", 322 | "join_time": "2020-08-07T22:09:16Z", 323 | "leave_time": "2020-08-07T22:40:10Z", 324 | "duration": 1854, 325 | "attentiveness_score": "" 326 | }, 327 | { 328 | "id": "1234sLAvTEGSXS8VayIPgQ", 329 | "user_id": "55511008", 330 | "name": "Mario Mario", 331 | "user_email": "", 332 | "join_time": "2020-08-07T22:26:42Z", 333 | "leave_time": "2020-08-07T22:28:10Z", 334 | "duration": 88, 335 | "attentiveness_score": "" 336 | }, 337 | { 338 | "id": "1234sLAvTEGSXS8VayIPgQ", 339 | "user_id": "55512032", 340 | "name": "Mario Mario", 341 | "user_email": "", 342 | "join_time": "2020-08-07T22:28:10Z", 343 | "leave_time": "2020-08-07T22:43:57Z", 344 | "duration": 947, 345 | "attentiveness_score": "" 346 | }, 347 | { 348 | "id": "1234O-foRqqorU-ZyTz8OA", 349 | "user_id": "55513056", 350 | "name": "Jeraldine Joss", 351 | "user_email": "jeraldinejoss@bmail.com", 352 | "join_time": "2020-08-07T22:40:10Z", 353 | "leave_time": "2020-08-07T22:43:50Z", 354 | "duration": 220, 355 | "attentiveness_score": "" 356 | }, 357 | { 358 | "id": "1234J7OQQ06MN3qDjUM-dg", 359 | "user_id": "55514080", 360 | "name": "Elise", 361 | "user_email": "", 362 | "join_time": "2020-08-07T22:42:08Z", 363 | "leave_time": "2020-08-07T23:04:33Z", 364 | "duration": 1345, 365 | "attentiveness_score": "" 366 | }, 367 | { 368 | "id": "1234xUqDSAeD7BemdlSVng", 369 | "user_id": "55515104", 370 | "name": "Mario Mario", 371 | "user_email": "mario.iii@bmail.com", 372 | "join_time": "2020-08-07T22:43:35Z", 373 | "leave_time": "2020-08-07T22:43:46Z", 374 | "duration": 11, 375 | "attentiveness_score": "" 376 | }, 377 | { 378 | "id": "1234xUqDSAeD7BemdlSVng", 379 | "user_id": "55516128", 380 | "name": "Mario Mario", 381 | "user_email": "mario.iii@bmail.com", 382 | "join_time": "2020-08-07T22:43:46Z", 383 | "leave_time": "2020-08-07T23:03:59Z", 384 | "duration": 1213, 385 | "attentiveness_score": "" 386 | }, 387 | { 388 | "id": "1234O-foRqqorU-ZyTz8OA", 389 | "user_id": "55517152", 390 | "name": "Jeraldine Joss", 391 | "user_email": "jeraldinejoss@bmail.com", 392 | "join_time": "2020-08-07T22:44:07Z", 393 | "leave_time": "2020-08-07T23:03:54Z", 394 | "duration": 1187, 395 | "attentiveness_score": "" 396 | } 397 | ] 398 | } 399 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "68d4d21ba3ae5171686aef9f6450d050b29f69df1ef1de19117c6e6be0d74145" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.8" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "authlib": { 20 | "hashes": [ 21 | "sha256:270e778201590af8873cf7d5e8e8ca5b625a16f7afba6a4280b6fb4efdd791bf", 22 | "sha256:cc52908e9e996f3de2ac2f61bf1ee6c6f1c5ce8e67c89ff2ca473008fffc92f6" 23 | ], 24 | "index": "pypi", 25 | "version": "==0.14.3" 26 | }, 27 | "cachetools": { 28 | "hashes": [ 29 | "sha256:513d4ff98dd27f85743a8dc0e92f55ddb1b49e060c2d5961512855cda2c01a98", 30 | "sha256:bbaa39c3dede00175df2dc2b03d0cf18dd2d32a7de7beb68072d13043c9edb20" 31 | ], 32 | "markers": "python_version ~= '3.5'", 33 | "version": "==4.1.1" 34 | }, 35 | "certifi": { 36 | "hashes": [ 37 | "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3", 38 | "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41" 39 | ], 40 | "version": "==2020.6.20" 41 | }, 42 | "cffi": { 43 | "hashes": [ 44 | "sha256:267adcf6e68d77ba154334a3e4fc921b8e63cbb38ca00d33d40655d4228502bc", 45 | "sha256:26f33e8f6a70c255767e3c3f957ccafc7f1f706b966e110b855bfe944511f1f9", 46 | "sha256:3cd2c044517f38d1b577f05927fb9729d3396f1d44d0c659a445599e79519792", 47 | "sha256:4a03416915b82b81af5502459a8a9dd62a3c299b295dcdf470877cb948d655f2", 48 | "sha256:4ce1e995aeecf7cc32380bc11598bfdfa017d592259d5da00fc7ded11e61d022", 49 | "sha256:4f53e4128c81ca3212ff4cf097c797ab44646a40b42ec02a891155cd7a2ba4d8", 50 | "sha256:4fa72a52a906425416f41738728268072d5acfd48cbe7796af07a923236bcf96", 51 | "sha256:66dd45eb9530e3dde8f7c009f84568bc7cac489b93d04ac86e3111fb46e470c2", 52 | "sha256:6923d077d9ae9e8bacbdb1c07ae78405a9306c8fd1af13bfa06ca891095eb995", 53 | "sha256:833401b15de1bb92791d7b6fb353d4af60dc688eaa521bd97203dcd2d124a7c1", 54 | "sha256:8416ed88ddc057bab0526d4e4e9f3660f614ac2394b5e019a628cdfff3733849", 55 | "sha256:892daa86384994fdf4856cb43c93f40cbe80f7f95bb5da94971b39c7f54b3a9c", 56 | "sha256:98be759efdb5e5fa161e46d404f4e0ce388e72fbf7d9baf010aff16689e22abe", 57 | "sha256:a6d28e7f14ecf3b2ad67c4f106841218c8ab12a0683b1528534a6c87d2307af3", 58 | "sha256:b1d6ebc891607e71fd9da71688fcf332a6630b7f5b7f5549e6e631821c0e5d90", 59 | "sha256:b2a2b0d276a136146e012154baefaea2758ef1f56ae9f4e01c612b0831e0bd2f", 60 | "sha256:b87dfa9f10a470eee7f24234a37d1d5f51e5f5fa9eeffda7c282e2b8f5162eb1", 61 | "sha256:bac0d6f7728a9cc3c1e06d4fcbac12aaa70e9379b3025b27ec1226f0e2d404cf", 62 | "sha256:c991112622baee0ae4d55c008380c32ecfd0ad417bcd0417ba432e6ba7328caa", 63 | "sha256:cda422d54ee7905bfc53ee6915ab68fe7b230cacf581110df4272ee10462aadc", 64 | "sha256:d3148b6ba3923c5850ea197a91a42683f946dba7e8eb82dfa211ab7e708de939", 65 | "sha256:d6033b4ffa34ef70f0b8086fd4c3df4bf801fee485a8a7d4519399818351aa8e", 66 | "sha256:ddff0b2bd7edcc8c82d1adde6dbbf5e60d57ce985402541cd2985c27f7bec2a0", 67 | "sha256:e23cb7f1d8e0f93addf0cae3c5b6f00324cccb4a7949ee558d7b6ca973ab8ae9", 68 | "sha256:effd2ba52cee4ceff1a77f20d2a9f9bf8d50353c854a282b8760ac15b9833168", 69 | "sha256:f90c2267101010de42f7273c94a1f026e56cbc043f9330acd8a80e64300aba33", 70 | "sha256:f960375e9823ae6a07072ff7f8a85954e5a6434f97869f50d0e41649a1c8144f", 71 | "sha256:fcf32bf76dc25e30ed793145a57426064520890d7c02866eb93d3e4abe516948" 72 | ], 73 | "version": "==1.14.1" 74 | }, 75 | "chardet": { 76 | "hashes": [ 77 | "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", 78 | "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" 79 | ], 80 | "version": "==3.0.4" 81 | }, 82 | "cryptography": { 83 | "hashes": [ 84 | "sha256:0c608ff4d4adad9e39b5057de43657515c7da1ccb1807c3a27d4cf31fc923b4b", 85 | "sha256:0cbfed8ea74631fe4de00630f4bb592dad564d57f73150d6f6796a24e76c76cd", 86 | "sha256:124af7255ffc8e964d9ff26971b3a6153e1a8a220b9a685dc407976ecb27a06a", 87 | "sha256:384d7c681b1ab904fff3400a6909261cae1d0939cc483a68bdedab282fb89a07", 88 | "sha256:45741f5499150593178fc98d2c1a9c6722df88b99c821ad6ae298eff0ba1ae71", 89 | "sha256:4b9303507254ccb1181d1803a2080a798910ba89b1a3c9f53639885c90f7a756", 90 | "sha256:4d355f2aee4a29063c10164b032d9fa8a82e2c30768737a2fd56d256146ad559", 91 | "sha256:51e40123083d2f946794f9fe4adeeee2922b581fa3602128ce85ff813d85b81f", 92 | "sha256:8713ddb888119b0d2a1462357d5946b8911be01ddbf31451e1d07eaa5077a261", 93 | "sha256:8e924dbc025206e97756e8903039662aa58aa9ba357d8e1d8fc29e3092322053", 94 | "sha256:8ecef21ac982aa78309bb6f092d1677812927e8b5ef204a10c326fc29f1367e2", 95 | "sha256:8ecf9400d0893836ff41b6f977a33972145a855b6efeb605b49ee273c5e6469f", 96 | "sha256:9367d00e14dee8d02134c6c9524bb4bd39d4c162456343d07191e2a0b5ec8b3b", 97 | "sha256:a09fd9c1cca9a46b6ad4bea0a1f86ab1de3c0c932364dbcf9a6c2a5eeb44fa77", 98 | "sha256:ab49edd5bea8d8b39a44b3db618e4783ef84c19c8b47286bf05dfdb3efb01c83", 99 | "sha256:bea0b0468f89cdea625bb3f692cd7a4222d80a6bdafd6fb923963f2b9da0e15f", 100 | "sha256:bec7568c6970b865f2bcebbe84d547c52bb2abadf74cefce396ba07571109c67", 101 | "sha256:ce82cc06588e5cbc2a7df3c8a9c778f2cb722f56835a23a68b5a7264726bb00c", 102 | "sha256:dea0ba7fe6f9461d244679efa968d215ea1f989b9c1957d7f10c21e5c7c09ad6" 103 | ], 104 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 105 | "version": "==3.0" 106 | }, 107 | "google-api-core": { 108 | "hashes": [ 109 | "sha256:aaedc40ae977dbc2710f0de0012b673c8c7644f81ca0c93e839d22895f2ff29d", 110 | "sha256:c4e3b3d914e09d181287abb7101b42f308204fa5e8f89efc4839f607303caa2f" 111 | ], 112 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 113 | "version": "==1.22.0" 114 | }, 115 | "google-api-python-client": { 116 | "hashes": [ 117 | "sha256:51ac5158e5a011c2f5ff78c8a08dec6975efc74221a9714438a75ea26555e04a", 118 | "sha256:fa24f07f6124ff2e91ee9b7550e240481bcb31b8f77a75e8d481be1c44a6ff07" 119 | ], 120 | "index": "pypi", 121 | "version": "==1.10.0" 122 | }, 123 | "google-auth": { 124 | "hashes": [ 125 | "sha256:2f34dd810090d0d4c9d5787c4ad7b4413d1fbfb941e13682c7a2298d3b6cdcc8", 126 | "sha256:ce1fb80b5c6d3dd038babcc43e221edeafefc72d983b3dc28b67b996f76f00b9" 127 | ], 128 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 129 | "version": "==1.20.1" 130 | }, 131 | "google-auth-httplib2": { 132 | "hashes": [ 133 | "sha256:8d092cc60fb16517b12057ec0bba9185a96e3b7169d86ae12eae98e645b7bc39", 134 | "sha256:aeaff501738b289717fac1980db9711d77908a6c227f60e4aa1923410b43e2ee" 135 | ], 136 | "version": "==0.0.4" 137 | }, 138 | "googleapis-common-protos": { 139 | "hashes": [ 140 | "sha256:560716c807117394da12cecb0a54da5a451b5cf9866f1d37e9a5e2329a665351", 141 | "sha256:c8961760f5aad9a711d37b675be103e0cc4e9a39327e0d6d857872f698403e24" 142 | ], 143 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 144 | "version": "==1.52.0" 145 | }, 146 | "httplib2": { 147 | "hashes": [ 148 | "sha256:8af66c1c52c7ffe1aa5dc4bcd7c769885254b0756e6e69f953c7f0ab49a70ba3", 149 | "sha256:ca2914b015b6247791c4866782fa6042f495b94401a0f0bd3e1d6e0ba2236782" 150 | ], 151 | "version": "==0.18.1" 152 | }, 153 | "idna": { 154 | "hashes": [ 155 | "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", 156 | "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" 157 | ], 158 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 159 | "version": "==2.10" 160 | }, 161 | "numpy": { 162 | "hashes": [ 163 | "sha256:082f8d4dd69b6b688f64f509b91d482362124986d98dc7dc5f5e9f9b9c3bb983", 164 | "sha256:1bc0145999e8cb8aed9d4e65dd8b139adf1919e521177f198529687dbf613065", 165 | "sha256:309cbcfaa103fc9a33ec16d2d62569d541b79f828c382556ff072442226d1968", 166 | "sha256:3673c8b2b29077f1b7b3a848794f8e11f401ba0b71c49fbd26fb40b71788b132", 167 | "sha256:480fdd4dbda4dd6b638d3863da3be82873bba6d32d1fc12ea1b8486ac7b8d129", 168 | "sha256:56ef7f56470c24bb67fb43dae442e946a6ce172f97c69f8d067ff8550cf782ff", 169 | "sha256:5a936fd51049541d86ccdeef2833cc89a18e4d3808fe58a8abeb802665c5af93", 170 | "sha256:5b6885c12784a27e957294b60f97e8b5b4174c7504665333c5e94fbf41ae5d6a", 171 | "sha256:667c07063940e934287993366ad5f56766bc009017b4a0fe91dbd07960d0aba7", 172 | "sha256:7ed448ff4eaffeb01094959b19cbaf998ecdee9ef9932381420d514e446601cd", 173 | "sha256:8343bf67c72e09cfabfab55ad4a43ce3f6bf6e6ced7acf70f45ded9ebb425055", 174 | "sha256:92feb989b47f83ebef246adabc7ff3b9a59ac30601c3f6819f8913458610bdcc", 175 | "sha256:935c27ae2760c21cd7354402546f6be21d3d0c806fffe967f745d5f2de5005a7", 176 | "sha256:aaf42a04b472d12515debc621c31cf16c215e332242e7a9f56403d814c744624", 177 | "sha256:b12e639378c741add21fbffd16ba5ad25c0a1a17cf2b6fe4288feeb65144f35b", 178 | "sha256:b1cca51512299841bf69add3b75361779962f9cee7d9ee3bb446d5982e925b69", 179 | "sha256:b8456987b637232602ceb4d663cb34106f7eb780e247d51a260b84760fd8f491", 180 | "sha256:b9792b0ac0130b277536ab8944e7b754c69560dac0415dd4b2dbd16b902c8954", 181 | "sha256:c9591886fc9cbe5532d5df85cb8e0cc3b44ba8ce4367bd4cf1b93dc19713da72", 182 | "sha256:cf1347450c0b7644ea142712619533553f02ef23f92f781312f6a3553d031fc7", 183 | "sha256:de8b4a9b56255797cbddb93281ed92acbc510fb7b15df3f01bd28f46ebc4edae", 184 | "sha256:e1b1dc0372f530f26a03578ac75d5e51b3868b9b76cd2facba4c9ee0eb252ab1", 185 | "sha256:e45f8e981a0ab47103181773cc0a54e650b2aef8c7b6cd07405d0fa8d869444a", 186 | "sha256:e4f6d3c53911a9d103d8ec9518190e52a8b945bab021745af4939cfc7c0d4a9e", 187 | "sha256:ed8a311493cf5480a2ebc597d1e177231984c818a86875126cfd004241a73c3e", 188 | "sha256:ef71a1d4fd4858596ae80ad1ec76404ad29701f8ca7cdcebc50300178db14dfc" 189 | ], 190 | "markers": "python_version >= '3.6'", 191 | "version": "==1.19.1" 192 | }, 193 | "pandas": { 194 | "hashes": [ 195 | "sha256:0210f8fe19c2667a3817adb6de2c4fd92b1b78e1975ca60c0efa908e0985cbdb", 196 | "sha256:0227e3a6e3a22c0e283a5041f1e3064d78fbde811217668bb966ed05386d8a7e", 197 | "sha256:0bc440493cf9dc5b36d5d46bbd5508f6547ba68b02a28234cd8e81fdce42744d", 198 | "sha256:16504f915f1ae424052f1e9b7cd2d01786f098fbb00fa4e0f69d42b22952d798", 199 | "sha256:182a5aeae319df391c3df4740bb17d5300dcd78034b17732c12e62e6dd79e4a4", 200 | "sha256:35db623487f00d9392d8af44a24516d6cb9f274afaf73cfcfe180b9c54e007d2", 201 | "sha256:40ec0a7f611a3d00d3c666c4cceb9aa3f5bf9fbd81392948a93663064f527203", 202 | "sha256:47a03bfef80d6812c91ed6fae43f04f2fa80a4e1b82b35aa4d9002e39529e0b8", 203 | "sha256:4b21d46728f8a6be537716035b445e7ef3a75dbd30bd31aa1b251323219d853e", 204 | "sha256:4d1a806252001c5db7caecbe1a26e49a6c23421d85a700960f6ba093112f54a1", 205 | "sha256:60e20a4ab4d4fec253557d0fc9a4e4095c37b664f78c72af24860c8adcd07088", 206 | "sha256:9f61cca5262840ff46ef857d4f5f65679b82188709d0e5e086a9123791f721c8", 207 | "sha256:a15835c8409d5edc50b4af93be3377b5dd3eb53517e7f785060df1f06f6da0e2", 208 | "sha256:b39508562ad0bb3f384b0db24da7d68a2608b9ddc85b1d931ccaaa92d5e45273", 209 | "sha256:ed60848caadeacecefd0b1de81b91beff23960032cded0ac1449242b506a3b3f", 210 | "sha256:fc714895b6de6803ac9f661abb316853d0cd657f5d23985222255ad76ccedc25" 211 | ], 212 | "index": "pypi", 213 | "version": "==1.1.0" 214 | }, 215 | "protobuf": { 216 | "hashes": [ 217 | "sha256:0b00429b87821f1e6f3d641327864e6f271763ae61799f7540bc58a352825fe2", 218 | "sha256:2636c689a6a2441da9a2ef922a21f9b8bfd5dfe676abd77d788db4b36ea86bee", 219 | "sha256:2becd0e238ae34caf96fa7365b87f65b88aebcf7864dfe5ab461c5005f4256d9", 220 | "sha256:2db6940c1914fa3fbfabc0e7c8193d9e18b01dbb4650acac249b113be3ba8d9e", 221 | "sha256:32f0bcdf85e0040f36b4f548c71177027f2a618cab00ba235197fa9e230b7289", 222 | "sha256:3d59825cba9447e8f4fcacc1f3c892cafd28b964e152629b3f420a2fb5918b5a", 223 | "sha256:4794a7748ee645d2ae305f3f4f0abd459e789c973b5bc338008960f83e0c554b", 224 | "sha256:50b7bb2124f6a1fb0ddc6a44428ae3a21e619ad2cdf08130ac6c00534998ef07", 225 | "sha256:6009f3ebe761fad319b52199a49f1efa7a3729302947a78a3f5ea8e7e89e3ac2", 226 | "sha256:a7b6cf201e67132ca99b8a6c4812fab541fdce1ceb54bb6f66bc336ab7259138", 227 | "sha256:b6842284bb15f1b19c50c5fd496f1e2a4cfefdbdfa5d25c02620cb82793295a7", 228 | "sha256:c0c8d7c8f07eacd9e98a907941b56e57883cf83de069cfaeaa7e02c582f72ddb", 229 | "sha256:c99e5aea75b6f2b29c8d8da5bdc5f5ed8d9a5b4f15115c8316a3f0a850f94656", 230 | "sha256:e2bd5c98952db3f1bb1af2e81b6a208909d3b8a2d32f7525c5cc10a6338b6593", 231 | "sha256:e77ca4e1403b363a88bde9e31c11d093565e925e1685f40b29385a52f2320794", 232 | "sha256:ef991cbe34d7bb935ba6349406a210d3558b9379c21621c6ed7b99112af7350e", 233 | "sha256:f10ba89f9cd508dc00e469918552925ef7cba38d101ca47af1e78f2f9982c6b3", 234 | "sha256:f1796e0eb911bf5b08e76b753953effbeb6bc42c95c16597177f627eaa52c375" 235 | ], 236 | "version": "==3.12.4" 237 | }, 238 | "pyasn1": { 239 | "hashes": [ 240 | "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359", 241 | "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576", 242 | "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf", 243 | "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7", 244 | "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d", 245 | "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00", 246 | "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8", 247 | "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86", 248 | "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12", 249 | "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776", 250 | "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba", 251 | "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2", 252 | "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3" 253 | ], 254 | "version": "==0.4.8" 255 | }, 256 | "pyasn1-modules": { 257 | "hashes": [ 258 | "sha256:0845a5582f6a02bb3e1bde9ecfc4bfcae6ec3210dd270522fee602365430c3f8", 259 | "sha256:0fe1b68d1e486a1ed5473f1302bd991c1611d319bba158e98b106ff86e1d7199", 260 | "sha256:15b7c67fabc7fc240d87fb9aabf999cf82311a6d6fb2c70d00d3d0604878c811", 261 | "sha256:426edb7a5e8879f1ec54a1864f16b882c2837bfd06eee62f2c982315ee2473ed", 262 | "sha256:65cebbaffc913f4fe9e4808735c95ea22d7a7775646ab690518c056784bc21b4", 263 | "sha256:905f84c712230b2c592c19470d3ca8d552de726050d1d1716282a1f6146be65e", 264 | "sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74", 265 | "sha256:a99324196732f53093a84c4369c996713eb8c89d360a496b599fb1a9c47fc3eb", 266 | "sha256:b80486a6c77252ea3a3e9b1e360bc9cf28eaac41263d173c032581ad2f20fe45", 267 | "sha256:c29a5e5cc7a3f05926aff34e097e84f8589cd790ce0ed41b67aed6857b26aafd", 268 | "sha256:cbac4bc38d117f2a49aeedec4407d23e8866ea4ac27ff2cf7fb3e5b570df19e0", 269 | "sha256:f39edd8c4ecaa4556e989147ebf219227e2cd2e8a43c7e7fcb1f1c18c5fd6a3d", 270 | "sha256:fe0644d9ab041506b62782e92b06b8c68cca799e1a9636ec398675459e031405" 271 | ], 272 | "version": "==0.2.8" 273 | }, 274 | "pycparser": { 275 | "hashes": [ 276 | "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", 277 | "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" 278 | ], 279 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 280 | "version": "==2.20" 281 | }, 282 | "python-dateutil": { 283 | "hashes": [ 284 | "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", 285 | "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a" 286 | ], 287 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 288 | "version": "==2.8.1" 289 | }, 290 | "pytz": { 291 | "hashes": [ 292 | "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed", 293 | "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048" 294 | ], 295 | "version": "==2020.1" 296 | }, 297 | "ratelimit": { 298 | "hashes": [ 299 | "sha256:af8a9b64b821529aca09ebaf6d8d279100d766f19e90b5059ac6a718ca6dee42" 300 | ], 301 | "index": "pypi", 302 | "version": "==2.2.1" 303 | }, 304 | "requests": { 305 | "hashes": [ 306 | "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b", 307 | "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898" 308 | ], 309 | "index": "pypi", 310 | "version": "==2.24.0" 311 | }, 312 | "rsa": { 313 | "hashes": [ 314 | "sha256:109ea5a66744dd859bf16fe904b8d8b627adafb9408753161e766a92e7d681fa", 315 | "sha256:6166864e23d6b5195a5cfed6cd9fed0fe774e226d8f854fcb23b7bbef0350233" 316 | ], 317 | "markers": "python_version >= '3.5'", 318 | "version": "==4.6" 319 | }, 320 | "six": { 321 | "hashes": [ 322 | "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", 323 | "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" 324 | ], 325 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 326 | "version": "==1.15.0" 327 | }, 328 | "uritemplate": { 329 | "hashes": [ 330 | "sha256:07620c3f3f8eed1f12600845892b0e036a2420acf513c53f7de0abd911a5894f", 331 | "sha256:5af8ad10cec94f215e3f48112de2022e1d5a37ed427fbd88652fa908f2ab7cae" 332 | ], 333 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 334 | "version": "==3.0.1" 335 | }, 336 | "urllib3": { 337 | "hashes": [ 338 | "sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a", 339 | "sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461" 340 | ], 341 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", 342 | "version": "==1.25.10" 343 | } 344 | }, 345 | "develop": { 346 | "attrs": { 347 | "hashes": [ 348 | "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", 349 | "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" 350 | ], 351 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 352 | "version": "==19.3.0" 353 | }, 354 | "certifi": { 355 | "hashes": [ 356 | "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3", 357 | "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41" 358 | ], 359 | "version": "==2020.6.20" 360 | }, 361 | "chardet": { 362 | "hashes": [ 363 | "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", 364 | "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" 365 | ], 366 | "version": "==3.0.4" 367 | }, 368 | "coverage": { 369 | "hashes": [ 370 | "sha256:098a703d913be6fbd146a8c50cc76513d726b022d170e5e98dc56d958fd592fb", 371 | "sha256:16042dc7f8e632e0dcd5206a5095ebd18cb1d005f4c89694f7f8aafd96dd43a3", 372 | "sha256:1adb6be0dcef0cf9434619d3b892772fdb48e793300f9d762e480e043bd8e716", 373 | "sha256:27ca5a2bc04d68f0776f2cdcb8bbd508bbe430a7bf9c02315cd05fb1d86d0034", 374 | "sha256:28f42dc5172ebdc32622a2c3f7ead1b836cdbf253569ae5673f499e35db0bac3", 375 | "sha256:2fcc8b58953d74d199a1a4d633df8146f0ac36c4e720b4a1997e9b6327af43a8", 376 | "sha256:304fbe451698373dc6653772c72c5d5e883a4aadaf20343592a7abb2e643dae0", 377 | "sha256:30bc103587e0d3df9e52cd9da1dd915265a22fad0b72afe54daf840c984b564f", 378 | "sha256:40f70f81be4d34f8d491e55936904db5c527b0711b2a46513641a5729783c2e4", 379 | "sha256:4186fc95c9febeab5681bc3248553d5ec8c2999b8424d4fc3a39c9cba5796962", 380 | "sha256:46794c815e56f1431c66d81943fa90721bb858375fb36e5903697d5eef88627d", 381 | "sha256:4869ab1c1ed33953bb2433ce7b894a28d724b7aa76c19b11e2878034a4e4680b", 382 | "sha256:4f6428b55d2916a69f8d6453e48a505c07b2245653b0aa9f0dee38785939f5e4", 383 | "sha256:52f185ffd3291196dc1aae506b42e178a592b0b60a8610b108e6ad892cfc1bb3", 384 | "sha256:538f2fd5eb64366f37c97fdb3077d665fa946d2b6d95447622292f38407f9258", 385 | "sha256:64c4f340338c68c463f1b56e3f2f0423f7b17ba6c3febae80b81f0e093077f59", 386 | "sha256:675192fca634f0df69af3493a48224f211f8db4e84452b08d5fcebb9167adb01", 387 | "sha256:700997b77cfab016533b3e7dbc03b71d33ee4df1d79f2463a318ca0263fc29dd", 388 | "sha256:8505e614c983834239f865da2dd336dcf9d72776b951d5dfa5ac36b987726e1b", 389 | "sha256:962c44070c281d86398aeb8f64e1bf37816a4dfc6f4c0f114756b14fc575621d", 390 | "sha256:9e536783a5acee79a9b308be97d3952b662748c4037b6a24cbb339dc7ed8eb89", 391 | "sha256:9ea749fd447ce7fb1ac71f7616371f04054d969d412d37611716721931e36efd", 392 | "sha256:a34cb28e0747ea15e82d13e14de606747e9e484fb28d63c999483f5d5188e89b", 393 | "sha256:a3ee9c793ffefe2944d3a2bd928a0e436cd0ac2d9e3723152d6fd5398838ce7d", 394 | "sha256:aab75d99f3f2874733946a7648ce87a50019eb90baef931698f96b76b6769a46", 395 | "sha256:b1ed2bdb27b4c9fc87058a1cb751c4df8752002143ed393899edb82b131e0546", 396 | "sha256:b360d8fd88d2bad01cb953d81fd2edd4be539df7bfec41e8753fe9f4456a5082", 397 | "sha256:b8f58c7db64d8f27078cbf2a4391af6aa4e4767cc08b37555c4ae064b8558d9b", 398 | "sha256:c1bbb628ed5192124889b51204de27c575b3ffc05a5a91307e7640eff1d48da4", 399 | "sha256:c2ff24df02a125b7b346c4c9078c8936da06964cc2d276292c357d64378158f8", 400 | "sha256:c890728a93fffd0407d7d37c1e6083ff3f9f211c83b4316fae3778417eab9811", 401 | "sha256:c96472b8ca5dc135fb0aa62f79b033f02aa434fb03a8b190600a5ae4102df1fd", 402 | "sha256:ce7866f29d3025b5b34c2e944e66ebef0d92e4a4f2463f7266daa03a1332a651", 403 | "sha256:e26c993bd4b220429d4ec8c1468eca445a4064a61c74ca08da7429af9bc53bb0" 404 | ], 405 | "index": "pypi", 406 | "version": "==5.2.1" 407 | }, 408 | "idna": { 409 | "hashes": [ 410 | "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", 411 | "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" 412 | ], 413 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 414 | "version": "==2.10" 415 | }, 416 | "iniconfig": { 417 | "hashes": [ 418 | "sha256:80cf40c597eb564e86346103f609d74efce0f6b4d4f30ec8ce9e2c26411ba437", 419 | "sha256:e5f92f89355a67de0595932a6c6c02ab4afddc6fcdc0bfc5becd0d60884d3f69" 420 | ], 421 | "version": "==1.0.1" 422 | }, 423 | "mock": { 424 | "hashes": [ 425 | "sha256:3f9b2c0196c60d21838f307f5825a7b86b678cedc58ab9e50a8988187b4d81e0", 426 | "sha256:dd33eb70232b6118298d516bbcecd26704689c386594f0f3c4f13867b2c56f72" 427 | ], 428 | "index": "pypi", 429 | "version": "==4.0.2" 430 | }, 431 | "mocker": { 432 | "hashes": [ 433 | "sha256:2009911707c74b28bdc9959b5224717e45fcb93475b4b7bb4efad3285fcf2919" 434 | ], 435 | "index": "pypi", 436 | "version": "==1.1.1" 437 | }, 438 | "more-itertools": { 439 | "hashes": [ 440 | "sha256:68c70cc7167bdf5c7c9d8f6954a7837089c6a36bf565383919bb595efb8a17e5", 441 | "sha256:b78134b2063dd214000685165d81c154522c3ee0a1c0d4d113c80361c234c5a2" 442 | ], 443 | "markers": "python_version >= '3.5'", 444 | "version": "==8.4.0" 445 | }, 446 | "packaging": { 447 | "hashes": [ 448 | "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", 449 | "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" 450 | ], 451 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 452 | "version": "==20.4" 453 | }, 454 | "peewee": { 455 | "hashes": [ 456 | "sha256:1269a9736865512bd4056298003aab190957afe07d2616cf22eaf56cb6398369" 457 | ], 458 | "index": "pypi", 459 | "version": "==3.13.3" 460 | }, 461 | "pluggy": { 462 | "hashes": [ 463 | "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", 464 | "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" 465 | ], 466 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 467 | "version": "==0.13.1" 468 | }, 469 | "py": { 470 | "hashes": [ 471 | "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2", 472 | "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342" 473 | ], 474 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 475 | "version": "==1.9.0" 476 | }, 477 | "pyparsing": { 478 | "hashes": [ 479 | "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", 480 | "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" 481 | ], 482 | "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", 483 | "version": "==2.4.7" 484 | }, 485 | "pytest": { 486 | "hashes": [ 487 | "sha256:85228d75db9f45e06e57ef9bf4429267f81ac7c0d742cc9ed63d09886a9fe6f4", 488 | "sha256:8b6007800c53fdacd5a5c192203f4e531eb2a1540ad9c752e052ec0f7143dbad" 489 | ], 490 | "index": "pypi", 491 | "version": "==6.0.1" 492 | }, 493 | "pytest-mock": { 494 | "hashes": [ 495 | "sha256:5564c7cd2569b603f8451ec77928083054d8896046830ca763ed68f4112d17c7", 496 | "sha256:7122d55505d5ed5a6f3df940ad174b3f606ecae5e9bc379569cdcbd4cd9d2b83" 497 | ], 498 | "index": "pypi", 499 | "version": "==3.2.0" 500 | }, 501 | "requests": { 502 | "hashes": [ 503 | "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b", 504 | "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898" 505 | ], 506 | "index": "pypi", 507 | "version": "==2.24.0" 508 | }, 509 | "responses": { 510 | "hashes": [ 511 | "sha256:cf55b7c89fc77b9ebbc5e5924210b6d0ef437061b80f1273d7e202069e43493c", 512 | "sha256:fa125311607ab3e57d8fcc4da20587f041b4485bdfb06dd6bdf19d8b66f870c1" 513 | ], 514 | "index": "pypi", 515 | "version": "==0.10.16" 516 | }, 517 | "six": { 518 | "hashes": [ 519 | "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", 520 | "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" 521 | ], 522 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 523 | "version": "==1.15.0" 524 | }, 525 | "toml": { 526 | "hashes": [ 527 | "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f", 528 | "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88" 529 | ], 530 | "version": "==0.10.1" 531 | }, 532 | "urllib3": { 533 | "hashes": [ 534 | "sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a", 535 | "sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461" 536 | ], 537 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", 538 | "version": "==1.25.10" 539 | } 540 | } 541 | } 542 | --------------------------------------------------------------------------------