-
Notifications
You must be signed in to change notification settings - Fork 0
/
client.py
1215 lines (972 loc) · 50 KB
/
client.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
__author__ = 'Peter Xu'
import requests
import re
import datetime
import time
import os
import string
import HTMLParser
import sys
import urllib
import urls
from codes import codes
class Client:
def __init__(self, debug = False):
self.session = requests.session()
self.jsf_state = 0
self.debug = debug
self._location_cache = {} # Maps location strings to location codes
self.last_resp = None # Keep the last response for debugging
self.prev_currency_code = self._translate_code('ALCurrencyFormat', 'USD') # Default currency for the
# _annotate_fx function
self._log_file = open('log_' + time.strftime('%Y-%m-%d %H%M%S') + '.txt', 'wb')
@staticmethod
def _get_numeric_code(code_type, code):
try:
code_num = int(code)
except ValueError:
code_num = codes[code_type][code]
return code_num
def _translate_code(self, code_type, code):
"""
Check if code is a text code, and if it is, translate it; if it's already
a number, just return it directly
"""
return 'type:{0};id:{1};'.format(code_type, self._get_numeric_code(code_type, code))
@staticmethod
def _format_date(date):
"""
Convert dates to the correct, needed string format
"""
if isinstance(date, datetime.datetime):
return date.strftime('%m/%d/%y')
# Assume it's in an okay format otherwise
return date
def log(self, *args):
for arg in args:
print >> self._log_file, arg
def _update_state(self, resp):
# Get the jsf_tree_64 and jsf_state_64 variables to be used next
match = re.search('jsf_tree_64" value="(\d+)', resp.text)
orig_resp = resp
if match is None:
# Some pages return a window.location redirect
match = re.search('window.location = "([^"]*)', resp.text)
if match is not None:
sso_url = match.group(1)
resp = self.session.get(sso_url, verify=False)
self.log('Opening SSO URL: ' + sso_url)
# Some pages return a frame, and we need to get the contents of that frame to get the ER
elif resp.text.find(urls.TAB_FRAME_PATH) != -1:
resp = self.session.get(urls.TAB_FRAME, verify=False)
self.log('Opening tab frame: ' + urls.TAB_FRAME)
match = re.search('jsf_tree_64" value="(\d+)', resp.text)
jsf_state = match.group(1)
match2 = re.search('XM_FORM_TOKEN" value="([^"]+)', resp.text)
xm_form_token = match2.group(1)
match2 = re.search('XM_FORM_NAME" value="([^"]+)', resp.text)
xm_form_name = match2.group(1)
resp = self.session.post(urls.TAB_FRAME, {
'jsf_tree_64': jsf_state,
'jsf_state_64': jsf_state,
'XM_FORM_TOKEN': xm_form_token,
'XM_FORM_NAME': xm_form_name,
'jsf_viewid': "/suite/common/initial_tab_frame.jsp",
'startForm_SUBMIT': '1'
}, verify=False)
match = re.search('jsf_tree_64" value="(\d+)', resp.text)
if match is None:
raise Exception('Could not find jsf_tree_64 for response to {0} ({1}): {2}'.format(
resp.url, orig_resp.url, resp.text))
self.jsf_state = match.group(1)
# Get the AJAX token
match = re.search('XM_FORM_TOKEN" value="([^"]+)', resp.text)
if match is None:
raise Exception('Could not find XM_FORM_TOKEN for response to {0} ({1}): {2}'.format(
resp.url, orig_resp.url, resp.text))
self.xm_form_token = match.group(1)
# Get the AJAX token
match = re.search('XM_FORM_NAME" value="([^"]+)', resp.text)
if match is None:
raise Exception('Could not find XM_FORM_NAME for response to {0} ({1}): {2}'.format(
resp.url, orig_resp.url, resp.text))
self.xm_form_name = match.group(1)
def _get(self, url, update_state = True, **kwargs):
self.log('GET: ' + url)
resp = self.session.get(url, verify=False, **kwargs)
self.log('GET response: ' + resp.text)
self.last_resp = resp
if update_state:
self._update_state(resp)
return resp
def _post(self, url, post_fields = None, update_state = True, **kwargs):
if post_fields is None:
post_fields = {}
post_fields['jsf_tree_64'] = self.jsf_state
post_fields['jsf_state_64'] = self.jsf_state
if self.xm_form_token and self.xm_form_name:
post_fields['XM_FORM_TOKEN'] = self.xm_form_token
post_fields['XM_FORM_NAME'] = self.xm_form_name
self.log('POST: ' + url)
self.log('POST fields: ', post_fields)
resp = self.session.post(url, post_fields, verify=False, **kwargs)
self.last_resp = resp
self.log('Response: ' + resp.text)
if update_state:
self._update_state(resp)
return resp
def login_expenses(self, username, tracking_num = None):
s = self.session
# Log in to expenses
params = {
'externalURL': '/ExtnWebApplication?documentType=100',
'userLogin': username
}
if tracking_num is not None:
params['trackingNum'] = tracking_num
r = s.get(urls.ER_AUTHENTICATION, verify=False, params=params)
# Do Javascript redirect manually
match = re.search('window.location = "([^"]*)', r.text)
if match is None:
raise Exception('Could not log in to expense reporting')
sso_url = match.group(1)
resp = self._get(sso_url)
return resp
@staticmethod
def _get_form_data(resp_text):
"""
Given a page's HTML, get the form values and submit URL
:param resp_text: page's HTML
:return: tuple of (url, dict with form values)
"""
h = HTMLParser.HTMLParser()
# Get the submission URL
match = re.search('action="([^"]+)', resp_text)
url = h.unescape(match.group(1))
# Get the form fields
matches = re.findall('<input[^>]* name="([^"]+)"[^>]* value="([^"]+)', resp_text)
form_fields = {match[0]: h.unescape(match[1]) for match in matches}
return (url, form_fields)
def login(self, username, password):
# Submit analytics
self.submit_analytics(username)
s = self.session
# SSO login
r = s.get(urls.LOGIN, verify=False)
if r.text.find('Access rights validated') == -1:
raise Exception('Could not log in; please make sure you are logged in on McKinsey VPN')
(url, form_fields) = self._get_form_data(r.text)
# Re-request as POST
r = s.post(url, form_fields, verify=False)
# VPN login required since we don't have proxy pre-installed
r = s.post(urls.VPN_LOGIN, {
'username': username,
'password': password,
'vhost': 'standard'
})
match = re.search('input type="hidden" name="dummy" value="([^"]+)', r.text)
if match is None:
print >> sys.stderr, r.text
raise Exception('Could not log in to proxy; make sure you\'re connected on McKinsey VPN and your password is correct')
dummy = match.group(1)
# Finish login by doing SSO again
r = s.post(url, {
'dummy': dummy
}, verify=False)
(url, form_fields) = self._get_form_data(r.text)
r = s.post(url, form_fields, verify=False)
# Final login to expenses tool with SSO password
(url, form_fields) = self._get_form_data(r.text)
r = s.post(urls.AUTHENTICATION, form_fields, verify=False)
self.username = username
# Log in to expenses
self.login_expenses(username)
def submit_analytics(self, username):
s = self.session
r = s.get(urls.ANALYTICS, params={'user': username})
def open_expense_report(self, tracking_num):
self.login_expenses(self.username, tracking_num)
resp = self._post(urls.START_APP, {
'jsf_viewid': '/app/document/startApp.jsp',
'startForm:RealUserLoginInput': '',
'startForm:copyDocInput': 'false',
'startForm:DocNumInput': tracking_num,
'startForm:HtmlAuditInput': 'false',
'startForm:StartWithSpreadsheetImportInput': 'false',
'startForm:QuickItemInput': 'false',
'startForm:VoiceItemInput': 'false',
'startForm:startBtn': 'StartApp',
'startForm_SUBMIT': '1',
'startForm:_link_hidden_': ''
})
if resp.text.find(tracking_num) == -1:
raise Exception('Could not open ER step 1 ' + str(tracking_num) + ': ' + resp.text)
first_jsf_state = self.jsf_state
resp = self._post(urls.TAB_FRAME, {
'jsf_viewid': '/suite/common/initial_tab_frame.jsp',
'startForm_SUBMIT': '1',
'autoScroll': '',
'startForm:_link_hidden_': ''
})
if self.jsf_state <= first_jsf_state:
# This should have incremented the jsf_state by 1
raise Exception('Could not open ER step 2 '+ resp.text)
return resp
def close_expense_report(self, tracking_num, skip_open = False):
if not skip_open:
self.open_expense_report(tracking_num)
# First load the prerequisite page
resp = self._post(urls.CLOSE_EXPENSE_REPORT, {})
# Actually close the expense report
resp = self._post(urls.CLOSE_EXPENSE_REPORT, {
'mainForm_SUBMIT': '1',
'mainForm:_link_hidden_': 'mainForm:closeBtn2'
}, False)
# Check for correct response
if resp.text.find('Closing window!') == -1:
raise Exception('Error while closing expense report: ' + resp.text)
return resp
def create_expense_report(self, title):
# Do the start form
resp = self._post(urls.START_APP)
if resp.status_code >= 400:
raise Exception('Error while starting app: ' + resp.text)
# Create expense report
resp = self._post(urls.CREATE_ER, {
'headerForm:title-PFAField': title,
'headerForm:ADC_1210321500': '', # Charge code ID
'headerForm:ADC_1210321500_input': '', # Charge code
'headerForm_SUBMIT': '1',
'headerForm:continueBtn2': 'Continue'
})
if resp.status_code >= 400:
raise Exception('Error while starting app: ' + resp.text)
# Return the ER code
match = re.search('ER\d+', resp.text)
if match is None:
raise Exception('Error while getting Expense Report ID: ' + resp.text)
return match.group(0)
def delete_expense_report(self, er, skip_close=False):
if not skip_close:
self.close_expense_report(er)
resp = self._get(urls.REAL_INDEX)
# Get the form submission URL
pattern = 'action="([^"]*)"'
match = re.search(pattern, resp.text)
if match is None:
raise Exception('Unexpected HTML while getting list of reports; could not find form submission URL')
form_submit_url = match.group(1)
# Split it based on the tr
split_text = re.split('<tr class="itemTable_Row\d">', resp.text)
# Find the entry corresponding to our ER
for text in split_text:
if text.find(er) != -1:
pattern = "(myDocumentsForm:workItemsListId_\d+:_id\d+)';" +\
"document.forms\['myDocumentsForm'\].elements\['trackingNo'\].value='([\d-]+)'"
match = re.search(pattern, text)
if match is None:
raise Exception('Unexpected HTML while getting list of reports: could not find trackingNo: ' +
pattern + ' in ' + text)
listId = match.group(1)
trackingNo = match.group(2)
resp = self._post(form_submit_url, {
'recallMessage': 'Are you sure you want to recall this document?',
'jsf_viewid': '/portal/inbox/mydocuments.jsp',
'myDocumentsForm_SUBMIT': '1',
'trackingNo': trackingNo,
'skipRequiredValidations': '',
'workitemId': '',
'navPath': '',
'myDocumentsForm:_link_hidden_': str(listId)
})
if resp.text.find('You cannot delete this document') != -1:
# Get the actual error message if possible
pattern = 'You cannot delete this document at this time.[^<]*'
match = re.search(pattern, resp.text)
if match is not None:
raise Exception('Could not delete document: ' + match.group(0))
else:
raise Exception('Could not delete document: ' + resp.text)
return
raise Exception('Could not find the report with code ' + str(er))
def create_entry(self, expenseType):
"""
Create an expense entry
:param expenseType: string of the expense type (e.g. "Hotel", "Meals 1 - Travel Dining Alone")
:rtype response
"""
# Initial load of the page to set state
self.log('Initial add expense page load')
resp = self._post(urls.ADD_EXPENSE)
self.log('Creating expense: ' + expenseType)
resp = self._post(urls.ADD_EXPENSE, {
'expenseTypeId': self._get_numeric_code('ExpenseType', expenseType),
'mainForm_SUBMIT': '1',
'mainForm:_link_hidden_': "mainForm:headerExpenseTypesList_3:headerExpenseTypeSelectLink"
})
if resp.status_code >= 400 or resp.text.find('Expense Item') == -1:
raise Exception('Error while creating expense: ' + resp.text)
return resp
def confirm_line_warnings(self, resp):
"""
Check if we have line warnings from submitting a request, and if it does,
auto-accept it
"""
if resp.text.find('lineViolationForm') != -1:
new_resp = self._post(urls.SKIP_VIOLATION, {
'lineViolationForm:continueBtn2': 'Continue',
'lineViolationForm_SUBMIT': '1',
'lineViolationForm:_link_hidden_': ''
})
return new_resp
return resp
suggestions_cache = {}
def _get_suggestions(self, field, value):
# Use the cache when possible
if field in self.suggestions_cache and value in self.suggestions_cache[field]:
return self.suggestions_cache[field][value]
resp = self._post(urls.LOOKUP_CODE + '?affectedAjaxComponent=' + field,
{field: value}, False)
matches = re.findall('id="[^"]*:([^:"]*)">([^<]*)</li>', resp.text)
if field not in self.suggestions_cache:
self.suggestions_cache[field] = {}
if value not in self.suggestions_cache[field]:
self.suggestions_cache[field][value] = matches
return matches
def _get_ajax_suggestions(self, field, value):
# Use the cache when possible
if field in self.suggestions_cache and value in self.suggestions_cache[field]:
return self.suggestions_cache[field][value]
resp = self._post(urls.LOOKUP_CODE, {
'affectedAjaxComponent': field,
'affectedAjaxComponentValue': value
}, update_state=False)
# Build the cache structures
if field not in self.suggestions_cache:
self.suggestions_cache[field] = {}
results = resp.json()
matches = []
for result in results:
code = result['value']
label = urllib.unquote(result['label']).decode('utf8')
if label != '':
matches.append((code, label))
self.suggestions_cache[field][value] = matches
return matches
def _get_location_suggestions(self, location):
"""
Given a string for a location, returns the list of all matches and codes
:param location: city name (e.g. "San Francisco")
:return: [(code, locationName), (code2, locationName2), ...]
:rtype: list
"""
return self._get_ajax_suggestions('editItemForm:ADC_3048906', location)
@staticmethod
def _clarify(suggestions, strings, allow_none = False):
"""
Prompt the user for clarification from a list of options
:param suggestions: list of suggestions
:return: the item in the list the user eventually selects
"""
for i in xrange(len(suggestions)):
print '[{0}] {1}'.format(i + 1, strings[i])
if allow_none:
print '[0] NONE of the above'
confirmed = False
selection = -1
while not confirmed:
while not ((selection >= 1 and selection <= len(suggestions)) or (allow_none and selection == 0)):
try:
print 'Enter the number: '
selection = int(raw_input())
except:
print 'That was not a number: please try again'
selected_string = strings[selection - 1] if selection != 0 else 'None of the above'
print 'You selected "{0}"; is that correct?'.format(selected_string)
print '(Enter y for yes, n for no) '
confirm = raw_input()
if confirm == 'y':
confirmed = True
if selection >= 1:
return suggestions[selection - 1]
return None # allow_none is true
def _get_location(self, location, prompt = True):
"""
Get the code for a location, prompting the user to clarify if multiple
options
:param location: city name string
:return: a tuple of the code and the name (e.g. (3001280, 'San Salvador/El Salvador'))
"""
if location in self._location_cache:
return self._location_cache[location]
suggestions = self._get_location_suggestions(location)
while len(suggestions) == 0:
print 'We could not find a location starting with "' + location + '"; did you misspell it? Try another:'
new_location = raw_input()
suggestions = self._get_location_suggestions(location)
if len(suggestions) == 1:
# Single suggestion: just use it
self._location_cache[location] = suggestions[0]
else:
# Multiple suggestions: clarify with user
strings = [suggestion[1] for suggestion in suggestions]
print 'Which of the above did you mean by "{0}"'.format(location) + '?'
self._location_cache[location] = self._clarify(suggestions, strings)
return self._location_cache[location]
def _get_charge_code_suggestions(self, charge_code):
return self._get_ajax_suggestions('editItemForm:allocation_panel:multipleAllocationTable_0:projectChoice',
charge_code)
def _get_charge_code(self, charge_code):
charge_codes = self._get_charge_code_suggestions(charge_code)
if len(charge_codes) == 0:
raise Exception('"{0}" is not a valid charge code'.format(charge_code))
return charge_codes[0]
def _get_exchange_rate(self, date, amount, currency=None, currency_code=None):
"""
Get the exchange rate and amount
:param currency: currency of the payment (optional; either this or currency_code)
:param currency_code: currency code of the payment (optional; this or currency)
:param date: date of the payment
:param amount: amount, in local currency, to convert
:return: (amount_paid, exchange_rate)
"""
if currency:
currency_code = self._translate_code('ALCurrencyFormat', currency)
resp = self._post(urls.AJAX_REQUEST, {
'execute': 'editItemForm:currencyVal-PFAChoice editItemForm:date-PFAField editItemForm:amountVal-PFAField',
'method': 'form.setFXRate',
'render': 'editItemForm:paidAmountPanelGroup editItemForm:fxRatePanelGroup',
'action': 'updateTarget',
'componentId': 'editItemForm:currencyVal-PFAChoice',
'editItemForm:currencyVal-PFAChoice': currency_code,
'editItemForm:date-PFAField': date,
'editItemForm:amountVal-PFAField': amount,
'jsf_viewid': '/app/er_line.jsp'
}, update_state=False)
usd_code = self._translate_code('ALCurrencyFormat', 'USD')
if currency_code == usd_code:
# US$ is the default currency; it won't be present
return amount, '1'
match1 = re.search('editItemForm:paidAmountVal" type="text" value="([\d.]+)',
resp.text)
if match1 is None:
raise Exception('Could not get amount / exchange rate for currency {0} on {1}:\n{2}'.format(
currency_code, date, resp.text))
amount_paid = match1.group(1)
match2 = re.search('editItemForm:fxRateVal-PFAField" type="text" value="([\d.]+)',
resp.text)
if match2 is None:
raise Exception('Could not get exchange rate for currency {0} on {1}:\n{2}'.format(
currency_code, date, resp.text))
exchange_rate = match2.group(1)
return amount_paid, exchange_rate
def _annotate_fx(self, post_request):
"""
Annotate a POST request with the needed currency conversion codes
:param post_request: post_request to annotate: we use the
- editItemForm:date-PFAField
- editItemForm:amountVal-PFAField
- editItemForm:currencyVal-PFAChoice
to build the actual request
:return: None
"""
currency_code = post_request['editItemForm:currencyVal-PFAChoice']
amount = post_request['editItemForm:amountVal-PFAField']
date = post_request['editItemForm:date-PFAField']
usd_code = self._translate_code('ALCurrencyFormat', 'USD')
if currency_code == usd_code and self.prev_currency_code == usd_code:
# Even if we're using USD, if we're switching back from another currency, we HAVE to make the
# _get_exchange_rate request
post_request['editItemForm:fxRateVal-PFAField'] = '1'
post_request['editItemForm:paidAmountVal'] = amount
return post_request
self.prev_currency_code = currency_code
amount_paid, exchange_rate = self._get_exchange_rate(date, amount, currency_code=currency_code)
post_request['editItemForm:fxRateVal-PFAField'] = exchange_rate
post_request['editItemForm:paidAmountVal'] = amount_paid
return post_request
def create_hotel_entry(self, charge_code, location, check_in, check_out, total,
room_rate, tax1=None, tax2=None, tax3=None, tax4=None, tax5=None,
breakfast=None, parking=None, internet=None,
currency='USD', notes='', meals=None):
if meals is None:
meals = [] # Array of (date, meal, amount)
self.create_entry('Hotel')
check_in_str = self._format_date(check_in)
check_out_str = self._format_date(check_out)
currency_code = self._translate_code('ALCurrencyFormat', currency)
(location_code, location_name) = self._get_location(location)
(charge_code_code, charge_code) = self._get_charge_code(charge_code)
# Go to the itemize page
submit_post_request = {
'editItemForm:date-PFAField': check_out_str,
'editItemForm:amountVal-PFAField': total,
'editItemForm:currencyVal-PFAChoice': currency_code,
'editItemForm:ADC_3048906_input': location_name,
'editItemForm:ADC_3048906': location_code,
'editItemForm:receipt-PFAField': 'true',
'editItemForm:allocation_panel:multipleAllocationTable_0:projectChoice_input': charge_code,
'editItemForm:allocation_panel:multipleAllocationTable_0:projectChoice': charge_code_code,
'editItemForm:ADC_-1999999888': 'type:SubExpenseType;id:-1999992755;', # Hotel for Firm Member
'editItemForm:ADC_3048902': '',
'editItemForm:note_panel:noteFld': notes,
'editItemForm_SUBMIT': '1',
'editItemForm:_link_hidden_': ''
}
submit_post_request = self._annotate_fx(submit_post_request)
itemize_post = submit_post_request.copy()
itemize_post['editItemForm:_link_hidden_'] = 'editItemForm:addItemizeBtn'
resp = self._post(urls.SUBMIT_EXPENSE, itemize_post)
resp = self.confirm_line_warnings(resp)
if resp.text.find('Quick Itemize') == -1:
raise Exception('Could not open the itemize screen for hotels: instead got response ' + resp.text)
# Open the Quick Itemize page
# resp = self._post(urls.LINE_ITEMIZE, {
# 'editItemizationsForm:newExpenseTypeVal': '',
# 'editItemizationsForm:quickItemizationBtn': 'Quick Itemize',
# 'editItemizationsForm_SUBMIT': '1',
# 'editItemizationsForm:_link_hidden_': 'editItemForm:addItemizeBtn'
# })
#
# if resp.text.find('Daily Lodging Charges') == -1:
# raise Exception('Could not open the quick itemize screen: instead got response ' + resp.text)
start = datetime.datetime.strptime(check_in_str, '%m/%d/%y')
end = datetime.datetime.strptime(check_out_str, '%m/%d/%y')
delta = end - start
number_days = delta.days
# Submit the itemizations
resp = self._post(urls.QUICK_ITEMIZE_SUBMIT, {
'editItemizationsForm:hotelQISubview:endDateVal': check_out_str,
'editItemizationsForm:hotelQISubview:numberDaysVal': number_days,
'editItemizationsForm:hotelQISubview:lodgingChargesGroup_46021': room_rate, # Taxes
'editItemizationsForm:hotelQISubview:lodgingChargesGroup_46029': tax1 if tax1 else '',
'editItemizationsForm:hotelQISubview:lodgingChargesGroup_46029_1': tax2 if tax2 else '',
'editItemizationsForm:hotelQISubview:lodgingChargesGroup_46029_2': tax3 if tax3 else '',
'editItemizationsForm:hotelQISubview:lodgingChargesGroup_46029_3': tax4 if tax4 else '',
'editItemizationsForm:hotelQISubview:lodgingChargesGroup_46029_4': tax5 if tax5 else '',
'editItemizationsForm:hotelQISubview:otherChargesGroup_-1999586252': breakfast if breakfast else '', # Breakfast
'editItemizationsForm:hotelQISubview:otherChargesGroup_46025': parking if parking else '', # Parking / Toll / Other
'editItemizationsForm:hotelQISubview:otherChargesGroup_-1999898515': internet if internet else '', # Internet - Wifi
'editItemizationsForm:hotelQISubview:continue2Btn': 'Continue',
'editItemizationsForm_SUBMIT': '1',
'editItemizationsForm:_link_hidden_': ''
})
resp = self.confirm_line_warnings(resp)
if resp.text.find('Finish Itemization') == -1 and resp.text.find('Save Itemization') == -1:
raise Exception('Could not do quick itemize: instead got response ' + resp.text)
# Itemize meals individually
for meal in meals:
resp = self._post(urls.LINE_ITEMIZE, {
'editItemizationsForm:_link_hidden_': 'editItemizationsForm:expenseTypesList_2:expenseTypeSelectLink',
'editItemizationsForm_SUBMIT': '1',
'expenseTypeId': self._get_numeric_code('ExpenseType', 'Meals 1 - Travel Dining Alone')
})
if resp.text.find('Meal Type') == -1:
raise Exception('Could not itemize meal as a part of a hotel bill: instead got response ' + resp.text)
itemize_post_fields = {
'editItemForm:date-PFAField': self._format_date(meal['date']),
'editItemForm:amountVal-PFAField': meal['amount'],
'editItemForm:ADC_-1999999878': self._translate_code('MealType', meal['type']),
'editItemForm:allocation_panel:multipleAllocationTable_0:projectChoice_input': charge_code,
'editItemForm:allocation_panel:multipleAllocationTable_0:projectChoice': charge_code_code,
'editItemForm:note_panel:noteFld': '',
'editItemForm:saveBtn2': 'Save',
'editItemForm_SUBMIT': '1',
'editItemForm:_link_hidden_': 'editItemForm:saveBtn'
}
resp = self._post(urls.LINE_ITEMIZE_SUBMIT, itemize_post_fields)
resp = self.confirm_line_warnings(resp)
if resp.text.find('Save Itemization') == -1 and resp.text.find('Finish Itemization') == -1:
raise Exception('Could not itemize meal: instead got response ' + resp.text)
# Close the itemization screen
resp = self._post(urls.LINE_ITEMIZE, {
'editItemizationsForm:saveBtn2': 'Save+Itemization',
'editItemizationsForm_SUBMIT': '1',
'editItemizationsForm:_link_hidden_': ''
})
if resp.text.find('Currency') == -1:
raise Exception('Error while finishing itemization: ' + resp.text)
# Do the final submissions
submit_post = submit_post_request.copy()
submit_post['editItemForm:_link_hidden_'] = 'editItemForm:saveBtn'
resp = self._post(urls.SUBMIT_EXPENSE, submit_post)
resp = self.confirm_line_warnings(resp)
if resp.text.find('My Receipts') == -1:
raise Exception('Error while submitting added hotel expense: ' + resp.text)
def create_meal1_entry(self, charge_code, location, date, amount, meal, currency = 'USD', notes=''):
self.create_entry('Meals 1 - Travel Dining Alone')
date_str = self._format_date(date)
currency_code = self._translate_code('ALCurrencyFormat', currency)
(location_code, location_name) = self._get_location(location)
(charge_code_code, charge_code) = self._get_charge_code(charge_code)
meal_type = self._translate_code('MealType', meal)
resp = self._post(urls.SUBMIT_EXPENSE, self._annotate_fx({
'editItemForm:date-PFAField': date_str,
'editItemForm:amountVal-PFAField': amount,
'editItemForm:currencyVal-PFAChoice': currency_code,
'editItemForm:ADC_-1999999878': meal_type,
'editItemForm:ADC_3048906_input': location_name,
'editItemForm:ADC_3048906': location_code,
'editItemForm:receipt-PFAField': 'true',
'editItemForm:allocation_panel:multipleAllocationTable_0:projectChoice_input': charge_code,
'editItemForm:allocation_panel:multipleAllocationTable_0:projectChoice': charge_code_code,
'editItemForm:ADC_3048902': '',
'editItemForm:note_panel:noteFld': notes,
'editItemForm_SUBMIT': '1',
'editItemForm:_link_hidden_': 'editItemForm:saveBtn'
}))
resp = self.confirm_line_warnings(resp)
if resp.text.find('My Receipts') == -1:
raise Exception('Error while submitting added Meals 1 expense: ' + resp.text)
def create_meal2_entry(self, charge_code, location, date, amount, nature, attendee_count,
currency='USD', notes=''):
self.create_entry('Meals 2 - In Office')
date_str = self._format_date(date)
currency_code = self._translate_code('ALCurrencyFormat', currency)
(location_code, location_name) = self._get_location(location)
(charge_code_code, charge_code) = self._get_charge_code(charge_code)
nature_and_relevance = self._translate_code('SubExpenseType2', nature)
resp = self._post(urls.SUBMIT_EXPENSE, self._annotate_fx({
'editItemForm:date-PFAField': date_str,
'editItemForm:amountVal-PFAField': amount,
'editItemForm:currencyVal-PFAChoice': currency_code,
'editItemForm:ADC_1000080050': nature_and_relevance,
'editItemForm:ADC_3048909': str(int(attendee_count)),
'editItemForm:ADC_3048906_input': location_name,
'editItemForm:ADC_3048906': location_code,
'editItemForm:ADC_-1999999888': 'type:SubExpenseType;id:-1999992755;', # Meal for firm member
'editItemForm:receipt-PFAField': 'true',
'editItemForm:allocation_panel:multipleAllocationTable_0:projectChoice_input': charge_code,
'editItemForm:allocation_panel:multipleAllocationTable_0:projectChoice': charge_code_code,
'editItemForm:ADC_3048902': '', # Vendor
'editItemForm:note_panel:noteFld': notes,
'editItemForm_SUBMIT': '1',
'editItemForm:_link_hidden_': 'editItemForm:saveBtn'
}))
resp = self.confirm_line_warnings(resp)
if resp.text.find('My Receipts') == -1:
raise Exception('Error while submitting added Meals 2 expense: ' + resp.text)
@staticmethod
def _get_existing_vendors(resp_text):
"""
Get a dict of {'vendor': 'code'} based on the HTML on a certain page
@param resp_text: HTML response for a page
@return:
"""
matches = re.findall('(type:Vendor;id:[^"]*)"(?: selected="selected")?>([^<]*)', resp_text)
matches_dict = {vendor: id for id, vendor in matches}
return matches_dict
people_cache = {}
def create_meal3_entry(self, charge_code, location, date, amount, nature, meal,
vendor, attendees, currency = 'USD', notes=''):
"""
By far the most complex meal type: add a Team Dinner style meal
"""
resp = self.create_entry('Meals 3 - Group Outside Office')
existing_vendors = self._get_existing_vendors(resp.text)
date_str = self._format_date(date)
currency_code = self._translate_code('ALCurrencyFormat', currency)
(location_code, location_name) = self._get_location(location)
(charge_code_code, charge_code) = self._get_charge_code(charge_code)
nature_and_relevance = self._translate_code('SubExpenseType3', nature)
meal_type = self._translate_code('MealType', meal)
# Final submission data
post_data = {
'editItemForm:date-PFAField': date_str,
'editItemForm:amountVal-PFAField': amount,
'editItemForm:currencyVal-PFAChoice': currency_code,
'editItemForm:ADC_1000080051': nature_and_relevance,
'editItemForm:ADC_-1999999878': meal_type,
'editItemForm:ADC_3048902': '', # Vendor
'editItemForm:ADC_3048906_input': location_name,
'editItemForm:ADC_3048906': location_code,
'editItemForm:receipt-PFAField': 'true',
'editItemForm:allocation_panel:multipleAllocationTable_0:projectChoice_input': charge_code,
'editItemForm:allocation_panel:multipleAllocationTable_0:projectChoice': charge_code_code,
'editItemForm:note_panel:noteFld': notes,
'editItemForm_SUBMIT': '1',
'editItemForm:_link_hidden_': 'editItemForm:saveBtn'
}
post_data = self._annotate_fx(post_data)
# Add the vendors, if needed
if vendor not in existing_vendors:
add_vendor_post_data = post_data.copy()
add_vendor_post_data['editItemForm:_link_hidden_'] = 'editItemForm:ADC_3048902Btn'
add_vendor_post_data['targetName'] = "form.currentERLineItem.extensions['vendorName']'"
resp = self._post(urls.SUBMIT_EXPENSE, add_vendor_post_data)
if resp.text.find('Add Vendor / Establishment') == -1:
raise Exception('Could not open Add Vendor page; got error: ' + resp.text)
resp = self._post(urls.ADD_VENDOR, {
'addCorpDataScreenForm:addCorpDataVal': vendor,
'addCorpDataScreenForm:saveBtn': 'Save',
'addCorpDataScreenForm_SUBMIT': '1',
'addCorpDataScreenForm:_link_hidden_': ''
})
existing_vendors = self._get_existing_vendors(resp.text)
if vendor not in existing_vendors:
raise Exception('Failed to add Vendor {0}; instead got: '.format(vendor) + resp.text)
vendor_id = existing_vendors[vendor]
post_data['editItemForm:ADC_3048902'] = vendor_id
# Add the attendees
# 1. Open up the screen
add_attendee_post_data = post_data.copy()
add_attendee_post_data['editItemForm:_link_hidden_'] = \
'editItemForm:guest_wrapper_panel:guestQuickAddBtnChooserBtn'
add_attendee_post_data['editItemForm:guest_wrapper_panel:addBtn'] = 'Add'
resp = self._post(urls.SUBMIT_EXPENSE, add_attendee_post_data)
if resp.text.find('Find Guests') == -1:
raise Exception('Could not open Find Guests screen: ' + resp.text)
# 2. Search for attendees
guest_chooser_base = {
'guestForm:guestChooserTabbedPane_indexSubmit': '',
'guestForm:guest_chooser_search_tab:namesVal': '',
'guestForm:guest_chooser_search_tab:guestType-PFAChoice': '',
'guestForm:guest_chooser_search_tab:title-PFAField': '',
'guestForm:guest_chooser_search_tab:company-PFAField': '',
'guestForm:guest_chooser_search_tab:addressChoice': '',
'guestForm:guest_chooser_search_tab:isPersonal-PFAChoice': '',
'guestForm:guest_chooser_search_tab:guestListChoice': '',
'guestForm:guest_chooser_recent_tab:recentRadio': 'true',
'pagingEnabled': 'false',
'guestForm_SUBMIT': '1'
}
search_guests = guest_chooser_base.copy()
attendee_string = ';'.join(attendees) # Search uses colon-separated attendees
search_guests['guestForm:guest_chooser_search_tab:namesVal'] = attendee_string
search_guests['guestForm:guest_chooser_search_tab:searchBtn'] = 'Search'
resp = self._post(urls.GUEST_CHOOSER, search_guests)
if resp.text.find('Guest Details') == -1:
raise Exception('Couldn\'t successfully search for guests: ' + resp.text)
# Extract the attendee data
def extract_people(resp_text):
"""
:rtype : list
"""
html_parts = re.split('name="guestForm:guest_chooser_search_tab:_id[^:]*:searchSelectCheckbox', resp_text)
del html_parts[0]
def extract_info(html_part):
"""
Extracts the ID, name, and position within McKinsey from the attendee search data
:param html_part: HTML for the particular person's entry
:return: a dict with all matching people's information
"""
match = re.search('value="([^"]+)', html_part)
id = match.group(1)
match = re.search('col3143086Val">([^<]*)', html_part)
first_name = match.group(1)
match = re.search('col3143087Val">([^<]*)', html_part)
last_name = match.group(1)
match = re.search('col3143088Val">([^<]*)', html_part)
company = match.group(1)
pattern = 'guestForm:guest_chooser_search_tab:_id\d+_\d+:searchSelectCheckbox'
match = re.search(pattern, html_part)
checkbox_id = match.group(0)
# Whether checkbox is checked; boxes for just-added people
# are automatically checked
checked = html_part.find('checked="checked"') != -1
return {
'id': id,
'first_name': first_name,
'last_name': last_name,
'name': '{0} {1}'.format(first_name, last_name),
'company': company,
'checked': checked,
'checkbox_id': checkbox_id
}
return [extract_info(html) for html in html_parts]
people = extract_people(resp.text)
def key_by_name(people):
"""
Convert the people from extract_text to be keyed by name, with
multiple people with the same name under an array
:param people: people from extract_people
:return: dict { 'First Last': [person1, person2], ...}
:rtype: dict
"""
people_by_name = {}
for person in people:
name = person['name']
if name not in people_by_name:
people_by_name[name] = []
people_by_name[name].append(person)
return people_by_name
existing_attendees = re.findall('guestParentGuestsLabel\d+">([^<]+)', resp.text)
# Remove all attendees who are auto-added (normally, only oneself)
missing_attendees = [attendee for attendee in attendees if attendee not in existing_attendees]
relevant_people = key_by_name(people)
matched_people = [relevant_people[name][0] for name in missing_attendees if\
name in relevant_people and len(relevant_people[name]) == 1]
missing_people = [name for name in missing_attendees if name not in relevant_people]