Spaces:
Sleeping
Sleeping
Update src/streamlit_app.py
Browse files- src/streamlit_app.py +454 -19
src/streamlit_app.py
CHANGED
|
@@ -18,12 +18,39 @@ st.set_page_config(
|
|
| 18 |
layout="wide",
|
| 19 |
)
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
st.markdown("""
|
| 22 |
<style>
|
| 23 |
@import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:wght@400;500;600&display=swap');
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
.block-container { padding-top: 2rem; max-width: 960px; }
|
|
|
|
| 27 |
.status-card {
|
| 28 |
background: white;
|
| 29 |
border: 1px solid #e5e0d8;
|
|
@@ -33,11 +60,18 @@ st.markdown("""
|
|
| 33 |
box-shadow: 0 1px 4px rgba(0,0,0,0.05);
|
| 34 |
}
|
| 35 |
.step-label {
|
| 36 |
-
font-size: 0.72rem;
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
}
|
| 40 |
-
.step-title { font-size: 1.1rem; font-weight: 600; color: #1a1612; }
|
| 41 |
.badge-success {
|
| 42 |
background: #d4edda; color: #155724;
|
| 43 |
padding: 2px 10px; border-radius: 20px;
|
|
@@ -49,26 +83,34 @@ st.markdown("""
|
|
| 49 |
font-size: 0.78rem; font-weight: 600;
|
| 50 |
}
|
| 51 |
.stButton > button {
|
| 52 |
-
background: #1a1612 !important;
|
| 53 |
-
|
|
|
|
|
|
|
| 54 |
font-family: 'DM Sans', sans-serif !important;
|
| 55 |
font-weight: 600 !important;
|
| 56 |
padding: 0.55rem 1.4rem !important;
|
| 57 |
font-size: 0.9rem !important;
|
| 58 |
}
|
| 59 |
-
.stButton > button:hover {
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
border-radius: 8px;
|
| 67 |
font-weight: 600;
|
| 68 |
-
font-size: 0.
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
}
|
| 71 |
-
.login-btn a:hover { background: #106ebe; }
|
| 72 |
</style>
|
| 73 |
""", unsafe_allow_html=True)
|
| 74 |
|
|
@@ -463,3 +505,396 @@ if st.session_state.rj_emails:
|
|
| 463 |
file_name=f"rj_emails_{datetime.now().strftime('%Y%m%d_%H%M')}.xlsx",
|
| 464 |
mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
| 465 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
layout="wide",
|
| 19 |
)
|
| 20 |
|
| 21 |
+
import os
|
| 22 |
+
import json
|
| 23 |
+
import urllib.parse
|
| 24 |
+
import secrets
|
| 25 |
+
|
| 26 |
+
import requests
|
| 27 |
+
import streamlit as st
|
| 28 |
+
import pandas as pd
|
| 29 |
+
from bs4 import BeautifulSoup
|
| 30 |
+
from io import BytesIO, StringIO
|
| 31 |
+
import re
|
| 32 |
+
from datetime import datetime, timedelta
|
| 33 |
+
|
| 34 |
+
# ββ Page config ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 35 |
+
st.set_page_config(
|
| 36 |
+
page_title="RJ Email Processor",
|
| 37 |
+
page_icon="π¬",
|
| 38 |
+
layout="wide",
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
st.markdown("""
|
| 42 |
<style>
|
| 43 |
@import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:wght@400;500;600&display=swap');
|
| 44 |
+
|
| 45 |
+
html, body, [class*="css"] {
|
| 46 |
+
font-family: 'DM Sans', sans-serif;
|
| 47 |
+
}
|
| 48 |
+
h1, h2, h3 {
|
| 49 |
+
font-family: 'DM Serif Display', serif;
|
| 50 |
+
}
|
| 51 |
+
.main { background: #f7f5f0; }
|
| 52 |
.block-container { padding-top: 2rem; max-width: 960px; }
|
| 53 |
+
|
| 54 |
.status-card {
|
| 55 |
background: white;
|
| 56 |
border: 1px solid #e5e0d8;
|
|
|
|
| 60 |
box-shadow: 0 1px 4px rgba(0,0,0,0.05);
|
| 61 |
}
|
| 62 |
.step-label {
|
| 63 |
+
font-size: 0.72rem;
|
| 64 |
+
font-weight: 600;
|
| 65 |
+
letter-spacing: 0.1em;
|
| 66 |
+
text-transform: uppercase;
|
| 67 |
+
color: #9a8f82;
|
| 68 |
+
margin-bottom: 0.25rem;
|
| 69 |
+
}
|
| 70 |
+
.step-title {
|
| 71 |
+
font-size: 1.1rem;
|
| 72 |
+
font-weight: 600;
|
| 73 |
+
color: #1a1612;
|
| 74 |
}
|
|
|
|
| 75 |
.badge-success {
|
| 76 |
background: #d4edda; color: #155724;
|
| 77 |
padding: 2px 10px; border-radius: 20px;
|
|
|
|
| 83 |
font-size: 0.78rem; font-weight: 600;
|
| 84 |
}
|
| 85 |
.stButton > button {
|
| 86 |
+
background: #1a1612 !important;
|
| 87 |
+
color: white !important;
|
| 88 |
+
border: none !important;
|
| 89 |
+
border-radius: 8px !important;
|
| 90 |
font-family: 'DM Sans', sans-serif !important;
|
| 91 |
font-weight: 600 !important;
|
| 92 |
padding: 0.55rem 1.4rem !important;
|
| 93 |
font-size: 0.9rem !important;
|
| 94 |
}
|
| 95 |
+
.stButton > button:hover {
|
| 96 |
+
background: #3d3530 !important;
|
| 97 |
+
transform: translateY(-1px);
|
| 98 |
+
transition: all 0.15s ease;
|
| 99 |
+
}
|
| 100 |
+
div[data-testid="stNumberInput"] label,
|
| 101 |
+
div[data-testid="stTextInput"] label {
|
|
|
|
| 102 |
font-weight: 600;
|
| 103 |
+
font-size: 0.85rem;
|
| 104 |
+
color: #3d3530;
|
| 105 |
+
}
|
| 106 |
+
.result-header {
|
| 107 |
+
font-size: 1rem;
|
| 108 |
+
font-weight: 700;
|
| 109 |
+
color: #1a1612;
|
| 110 |
+
margin-bottom: 0.4rem;
|
| 111 |
+
padding-bottom: 0.4rem;
|
| 112 |
+
border-bottom: 2px solid #e5e0d8;
|
| 113 |
}
|
|
|
|
| 114 |
</style>
|
| 115 |
""", unsafe_allow_html=True)
|
| 116 |
|
|
|
|
| 505 |
file_name=f"rj_emails_{datetime.now().strftime('%Y%m%d_%H%M')}.xlsx",
|
| 506 |
mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
| 507 |
)
|
| 508 |
+
|
| 509 |
+
|
| 510 |
+
# ββ Config (from HF Secrets) βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 511 |
+
CLIENT_ID = os.environ.get("AZURE_CLIENT_ID", "bfcbb298-4cc1-496e-9d9b-ff8c2d967a3a")
|
| 512 |
+
CLIENT_SECRET = os.environ.get("AZURE_CLIENT_SECRET", "") # set in HF Secrets
|
| 513 |
+
TENANT_ID = os.environ.get("AZURE_TENANT_ID", "5dac2bf2-8842-4788-ae07-33fb103b55d6")
|
| 514 |
+
REDIRECT_URI = os.environ.get("REDIRECT_URI", "") # e.g. https://yourspace.hf.space/
|
| 515 |
+
|
| 516 |
+
AUTHORITY = f"https://login.microsoftonline.com/{TENANT_ID}"
|
| 517 |
+
AUTH_ENDPOINT = f"{AUTHORITY}/oauth2/v2.0/authorize"
|
| 518 |
+
TOKEN_ENDPOINT= f"{AUTHORITY}/oauth2/v2.0/token"
|
| 519 |
+
SCOPES = "Mail.Read offline_access"
|
| 520 |
+
|
| 521 |
+
DEFAULT_SENDER = "agabrielse@newfrontieradvisors.com"
|
| 522 |
+
DEFAULT_TARGET = "cfeng@newfrontieradvisors.com"
|
| 523 |
+
|
| 524 |
+
# ββ Session defaults βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 525 |
+
for k, v in {
|
| 526 |
+
"token": None,
|
| 527 |
+
"oauth_state": None,
|
| 528 |
+
"rj_emails": [],
|
| 529 |
+
"cash_df": None,
|
| 530 |
+
"withdrawals_df": None,
|
| 531 |
+
"deposits_df": None,
|
| 532 |
+
"notice_df": None,
|
| 533 |
+
}.items():
|
| 534 |
+
if k not in st.session_state:
|
| 535 |
+
st.session_state[k] = v
|
| 536 |
+
|
| 537 |
+
# ββ OAuth helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 538 |
+
|
| 539 |
+
def build_auth_url():
|
| 540 |
+
state = secrets.token_urlsafe(16)
|
| 541 |
+
st.session_state.oauth_state = state
|
| 542 |
+
params = {
|
| 543 |
+
"client_id": CLIENT_ID,
|
| 544 |
+
"response_type": "code",
|
| 545 |
+
"redirect_uri": REDIRECT_URI,
|
| 546 |
+
"response_mode": "query",
|
| 547 |
+
"scope": SCOPES,
|
| 548 |
+
"state": state,
|
| 549 |
+
}
|
| 550 |
+
return AUTH_ENDPOINT + "?" + urllib.parse.urlencode(params)
|
| 551 |
+
|
| 552 |
+
|
| 553 |
+
def exchange_code_for_token(code: str) -> str:
|
| 554 |
+
resp = requests.post(TOKEN_ENDPOINT, data={
|
| 555 |
+
"client_id": CLIENT_ID,
|
| 556 |
+
"client_secret": CLIENT_SECRET,
|
| 557 |
+
"code": code,
|
| 558 |
+
"redirect_uri": REDIRECT_URI,
|
| 559 |
+
"grant_type": "authorization_code",
|
| 560 |
+
}, timeout=30)
|
| 561 |
+
resp.raise_for_status()
|
| 562 |
+
return resp.json()["access_token"]
|
| 563 |
+
|
| 564 |
+
|
| 565 |
+
# ββ Check for OAuth callback (code in URL query params) βββββββββββββββββββββββ
|
| 566 |
+
query_params = st.query_params
|
| 567 |
+
if not st.session_state.token and "code" in query_params:
|
| 568 |
+
code = query_params["code"]
|
| 569 |
+
state = query_params.get("state", "")
|
| 570 |
+
if state == st.session_state.oauth_state or not st.session_state.oauth_state:
|
| 571 |
+
try:
|
| 572 |
+
with st.spinner("Completing sign-in..."):
|
| 573 |
+
st.session_state.token = exchange_code_for_token(code)
|
| 574 |
+
# Clear the code from the URL
|
| 575 |
+
st.query_params.clear()
|
| 576 |
+
st.rerun()
|
| 577 |
+
except Exception as e:
|
| 578 |
+
st.error(f"Sign-in failed: {e}")
|
| 579 |
+
|
| 580 |
+
# ββ Email helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 581 |
+
|
| 582 |
+
def get_emails(token, sender, top=50):
|
| 583 |
+
url = "https://graph.microsoft.com/v1.0/me/mailFolders/inbox/messages"
|
| 584 |
+
headers = {
|
| 585 |
+
"Authorization": f"Bearer {token}",
|
| 586 |
+
"Prefer": 'outlook.body-content-type="html"',
|
| 587 |
+
}
|
| 588 |
+
params = {
|
| 589 |
+
"$top": top,
|
| 590 |
+
"$filter": f"from/emailAddress/address eq '{sender}'",
|
| 591 |
+
"$select": "id,subject,from,toRecipients,ccRecipients,receivedDateTime,body",
|
| 592 |
+
}
|
| 593 |
+
r = requests.get(url, headers=headers, params=params, timeout=60)
|
| 594 |
+
r.raise_for_status()
|
| 595 |
+
return r.json().get("value", [])
|
| 596 |
+
|
| 597 |
+
|
| 598 |
+
def extract_info(msg):
|
| 599 |
+
def addrs(lst):
|
| 600 |
+
return [(r.get("emailAddress") or {}).get("address") for r in (lst or [])
|
| 601 |
+
if (r.get("emailAddress") or {}).get("address")]
|
| 602 |
+
body_html = (msg.get("body") or {}).get("content", "")
|
| 603 |
+
return {
|
| 604 |
+
"id": msg.get("id"),
|
| 605 |
+
"subject": msg.get("subject"),
|
| 606 |
+
"from": ((msg.get("from") or {}).get("emailAddress") or {}).get("address"),
|
| 607 |
+
"to": addrs(msg.get("toRecipients")),
|
| 608 |
+
"cc": addrs(msg.get("ccRecipients")),
|
| 609 |
+
"received_time": msg.get("receivedDateTime"),
|
| 610 |
+
"body_html": body_html,
|
| 611 |
+
}
|
| 612 |
+
|
| 613 |
+
|
| 614 |
+
def html_to_text(html):
|
| 615 |
+
return BeautifulSoup(html, "html.parser").get_text("\n", strip=True) if html else ""
|
| 616 |
+
|
| 617 |
+
|
| 618 |
+
def reply_marker_html(body_html):
|
| 619 |
+
if not body_html:
|
| 620 |
+
return ""
|
| 621 |
+
soup = BeautifulSoup(body_html, "html.parser")
|
| 622 |
+
marker = soup.find(id="divRplyFwdMsg")
|
| 623 |
+
if not marker:
|
| 624 |
+
return ""
|
| 625 |
+
return "".join(str(n) for n in marker.next_siblings).strip()
|
| 626 |
+
|
| 627 |
+
|
| 628 |
+
def fwd_headers(body_html):
|
| 629 |
+
text = html_to_text(body_html)
|
| 630 |
+
def grab(label):
|
| 631 |
+
m = re.search(rf"{label}:\s*(.*)", text)
|
| 632 |
+
return m.group(1).strip() if m else ""
|
| 633 |
+
return {k: grab(v) for k, v in {
|
| 634 |
+
"forwarded_from": "From",
|
| 635 |
+
"forwarded_sent_time": "Sent",
|
| 636 |
+
"forwarded_to": "To",
|
| 637 |
+
"forwarded_cc": "Cc",
|
| 638 |
+
"forwarded_subject": "Subject",
|
| 639 |
+
}.items()}
|
| 640 |
+
|
| 641 |
+
|
| 642 |
+
def is_rj(info):
|
| 643 |
+
subj = (info.get("subject") or "").lower()
|
| 644 |
+
html = info.get("body_html") or ""
|
| 645 |
+
text = html_to_text(html).lower()
|
| 646 |
+
if "rj emails--overview" in subj:
|
| 647 |
+
return False
|
| 648 |
+
has_fwd = 'id="divRplyFwdMsg"' in html or ("from:" in text and "sent:" in text)
|
| 649 |
+
if not has_fwd:
|
| 650 |
+
return False
|
| 651 |
+
return any([
|
| 652 |
+
"raymond james" in subj, "raymond james" in text,
|
| 653 |
+
"amstradingadmin@raymondjames.com" in text,
|
| 654 |
+
"ams managed operations" in subj, "ams managed operations" in text,
|
| 655 |
+
"direct asset transfers" in subj,
|
| 656 |
+
"distribution & cash balance check" in subj,
|
| 657 |
+
])
|
| 658 |
+
|
| 659 |
+
|
| 660 |
+
def build_rj(messages, target):
|
| 661 |
+
rows = []
|
| 662 |
+
for i, msg in enumerate(messages, 1):
|
| 663 |
+
info = extract_info(msg)
|
| 664 |
+
recips = [a.lower() for a in info.get("to", []) + info.get("cc", [])]
|
| 665 |
+
if target.lower() not in recips:
|
| 666 |
+
continue
|
| 667 |
+
if not is_rj(info):
|
| 668 |
+
continue
|
| 669 |
+
fwd = fwd_headers(info.get("body_html", ""))
|
| 670 |
+
rows.append({
|
| 671 |
+
"email_id": info.get("id") or f"msg_{i}",
|
| 672 |
+
"fw_subject": info.get("subject"),
|
| 673 |
+
"fw_from": info.get("from"),
|
| 674 |
+
"fw_to": ", ".join(info.get("to", [])),
|
| 675 |
+
"fw_cc": ", ".join(info.get("cc", [])),
|
| 676 |
+
"fw_received_time": info.get("received_time"),
|
| 677 |
+
"fw_body_html": info.get("body_html", ""),
|
| 678 |
+
**{k: fwd.get(k, "") for k in fwd},
|
| 679 |
+
"original_body_html": reply_marker_html(info.get("body_html", "")),
|
| 680 |
+
})
|
| 681 |
+
return rows
|
| 682 |
+
|
| 683 |
+
|
| 684 |
+
def extract_tables(body_html):
|
| 685 |
+
if not body_html:
|
| 686 |
+
return []
|
| 687 |
+
soup = BeautifulSoup(body_html, "html.parser")
|
| 688 |
+
out = []
|
| 689 |
+
for node in soup.find_all(string=True):
|
| 690 |
+
label = node.strip()
|
| 691 |
+
if label not in {"Cash Only Transactions", "Withdrawals", "Deposits"}:
|
| 692 |
+
continue
|
| 693 |
+
table = node.find_next("table")
|
| 694 |
+
if not table:
|
| 695 |
+
continue
|
| 696 |
+
try:
|
| 697 |
+
dfs = pd.read_html(StringIO(str(table)))
|
| 698 |
+
except ValueError:
|
| 699 |
+
continue
|
| 700 |
+
for df in dfs:
|
| 701 |
+
df.columns = [str(c).strip() for c in df.columns]
|
| 702 |
+
df = df.dropna(how="all").reset_index(drop=True)
|
| 703 |
+
out.append((label, df))
|
| 704 |
+
return out
|
| 705 |
+
|
| 706 |
+
|
| 707 |
+
def parse_date(v):
|
| 708 |
+
if not v:
|
| 709 |
+
return None
|
| 710 |
+
for fmt in ("%m/%d/%Y", "%m/%d/%y", "%A, %B %d, %Y %I:%M %p"):
|
| 711 |
+
try:
|
| 712 |
+
return datetime.strptime(v.strip(), fmt)
|
| 713 |
+
except ValueError:
|
| 714 |
+
pass
|
| 715 |
+
return None
|
| 716 |
+
|
| 717 |
+
|
| 718 |
+
def build_notice(row):
|
| 719 |
+
section = row.get("source_section")
|
| 720 |
+
account = row.get("Account") or row.get("customer_account")
|
| 721 |
+
amount = row.get("Cash Amount") or row.get("Amount")
|
| 722 |
+
if section == "Withdrawals":
|
| 723 |
+
input_date = row.get("Input date")
|
| 724 |
+
deadline = parse_date(input_date)
|
| 725 |
+
response_time = (
|
| 726 |
+
(deadline + timedelta(days=8)).strftime("%Y-%m-%d")
|
| 727 |
+
if deadline else f"8 calendar days after {input_date}"
|
| 728 |
+
)
|
| 729 |
+
action = (
|
| 730 |
+
f"Enter withdrawal in portal for account {account} for {amount}. "
|
| 731 |
+
f"Schedule 8 calendar days after input date {input_date}."
|
| 732 |
+
)
|
| 733 |
+
elif section == "Deposits" or (row.get("Type") or "").lower() == "deposit":
|
| 734 |
+
response_time = "As soon as practical after email receipt"
|
| 735 |
+
action = (
|
| 736 |
+
f"Message dutytrader in Teams: contribution/deposit for account {account} "
|
| 737 |
+
f"({amount}) approved to invest in model."
|
| 738 |
+
)
|
| 739 |
+
else:
|
| 740 |
+
response_time = "ASAP"
|
| 741 |
+
action = (
|
| 742 |
+
f"Halt account and advise of pending trades on {account}. "
|
| 743 |
+
f"Immediate cash movement: {amount}."
|
| 744 |
+
)
|
| 745 |
+
return {
|
| 746 |
+
"email_id": row.get("email_id"),
|
| 747 |
+
"sent_time": row.get("fw_received_time"),
|
| 748 |
+
"response_time_needed": response_time,
|
| 749 |
+
"account": account,
|
| 750 |
+
"action": action,
|
| 751 |
+
"source_section": section,
|
| 752 |
+
}
|
| 753 |
+
|
| 754 |
+
|
| 755 |
+
def to_excel(dfs):
|
| 756 |
+
buf = BytesIO()
|
| 757 |
+
with pd.ExcelWriter(buf, engine="openpyxl") as w:
|
| 758 |
+
for name, df in dfs.items():
|
| 759 |
+
if df is not None and not df.empty:
|
| 760 |
+
df.to_excel(w, sheet_name=name, index=False)
|
| 761 |
+
return buf.getvalue()
|
| 762 |
+
|
| 763 |
+
|
| 764 |
+
# ββ UI βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 765 |
+
|
| 766 |
+
st.markdown("## π¬ Raymond James Email Processor")
|
| 767 |
+
st.markdown("Pulls forwarded RJ emails, extracts cash movement tables, and generates action notices.")
|
| 768 |
+
st.divider()
|
| 769 |
+
|
| 770 |
+
# Step 1 β Sign in
|
| 771 |
+
st.markdown('<div class="status-card">', unsafe_allow_html=True)
|
| 772 |
+
st.markdown('<div class="step-label">Step 1</div>', unsafe_allow_html=True)
|
| 773 |
+
col1, col2 = st.columns([5, 1])
|
| 774 |
+
with col1:
|
| 775 |
+
st.markdown('<div class="step-title">Sign in to Microsoft</div>', unsafe_allow_html=True)
|
| 776 |
+
with col2:
|
| 777 |
+
if st.session_state.token:
|
| 778 |
+
st.markdown('<span class="badge-success">β Signed in</span>', unsafe_allow_html=True)
|
| 779 |
+
else:
|
| 780 |
+
st.markdown('<span class="badge-pending">Not signed in</span>', unsafe_allow_html=True)
|
| 781 |
+
|
| 782 |
+
if not st.session_state.token:
|
| 783 |
+
auth_url = build_auth_url()
|
| 784 |
+
st.markdown(
|
| 785 |
+
f'<div class="login-btn"><a href="{auth_url}" target="_self">π Sign in with Microsoft</a></div>',
|
| 786 |
+
unsafe_allow_html=True,
|
| 787 |
+
)
|
| 788 |
+
st.caption("You'll be redirected to Microsoft's login page and back automatically.")
|
| 789 |
+
else:
|
| 790 |
+
st.markdown("You are signed in. β")
|
| 791 |
+
if st.button("Sign out"):
|
| 792 |
+
st.session_state.token = None
|
| 793 |
+
st.rerun()
|
| 794 |
+
|
| 795 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 796 |
+
|
| 797 |
+
# Step 2 β Config
|
| 798 |
+
st.markdown('<div class="status-card">', unsafe_allow_html=True)
|
| 799 |
+
st.markdown('<div class="step-label">Step 2</div>', unsafe_allow_html=True)
|
| 800 |
+
st.markdown('<div class="step-title">Configure</div>', unsafe_allow_html=True)
|
| 801 |
+
st.markdown("")
|
| 802 |
+
col_a, col_b, col_c = st.columns(3)
|
| 803 |
+
with col_a:
|
| 804 |
+
sender_email = st.text_input("Sender email (who forwards RJ emails)", value=DEFAULT_SENDER)
|
| 805 |
+
with col_b:
|
| 806 |
+
target_email = st.text_input("Recipient email to filter for", value=DEFAULT_TARGET)
|
| 807 |
+
with col_c:
|
| 808 |
+
top_n = st.number_input("Max emails to fetch", min_value=5, max_value=200, value=50, step=5)
|
| 809 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 810 |
+
|
| 811 |
+
# Step 3 β Run
|
| 812 |
+
st.markdown('<div class="status-card">', unsafe_allow_html=True)
|
| 813 |
+
st.markdown('<div class="step-label">Step 3</div>', unsafe_allow_html=True)
|
| 814 |
+
st.markdown('<div class="step-title">Fetch & Process</div>', unsafe_allow_html=True)
|
| 815 |
+
st.markdown("")
|
| 816 |
+
|
| 817 |
+
if st.button("βΆ Run", disabled=not st.session_state.token):
|
| 818 |
+
with st.spinner("Fetching emails from Outlook..."):
|
| 819 |
+
try:
|
| 820 |
+
raw = get_emails(st.session_state.token, sender_email, int(top_n))
|
| 821 |
+
except requests.HTTPError as e:
|
| 822 |
+
if e.response.status_code == 401:
|
| 823 |
+
st.error("Session expired. Please sign in again.")
|
| 824 |
+
st.session_state.token = None
|
| 825 |
+
else:
|
| 826 |
+
st.error(f"API error: {e}")
|
| 827 |
+
st.stop()
|
| 828 |
+
|
| 829 |
+
with st.spinner("Filtering Raymond James emails..."):
|
| 830 |
+
st.session_state.rj_emails = build_rj(raw, target_email)
|
| 831 |
+
|
| 832 |
+
with st.spinner("Extracting cash tables..."):
|
| 833 |
+
cash_rows, wd_rows, dep_rows = [], [], []
|
| 834 |
+
for email in st.session_state.rj_emails:
|
| 835 |
+
for section, df in extract_tables(email.get("original_body_html", "")):
|
| 836 |
+
rows = df.to_dict("records")
|
| 837 |
+
for r in rows:
|
| 838 |
+
r.update({
|
| 839 |
+
"email_id": email.get("email_id"),
|
| 840 |
+
"fw_subject": email.get("fw_subject"),
|
| 841 |
+
"fw_received_time": email.get("fw_received_time"),
|
| 842 |
+
"original_subject": email.get("forwarded_subject", ""),
|
| 843 |
+
"original_from": email.get("forwarded_from", ""),
|
| 844 |
+
"original_sent_time": email.get("forwarded_sent_time", ""),
|
| 845 |
+
"source_section": section,
|
| 846 |
+
})
|
| 847 |
+
if section == "Cash Only Transactions": cash_rows.extend(rows)
|
| 848 |
+
elif section == "Withdrawals": wd_rows.extend(rows)
|
| 849 |
+
elif section == "Deposits": dep_rows.extend(rows)
|
| 850 |
+
|
| 851 |
+
st.session_state.cash_df = pd.DataFrame(cash_rows) if cash_rows else pd.DataFrame()
|
| 852 |
+
st.session_state.withdrawals_df = pd.DataFrame(wd_rows) if wd_rows else pd.DataFrame()
|
| 853 |
+
st.session_state.deposits_df = pd.DataFrame(dep_rows) if dep_rows else pd.DataFrame()
|
| 854 |
+
|
| 855 |
+
with st.spinner("Building action notices..."):
|
| 856 |
+
all_rows = cash_rows + wd_rows + dep_rows
|
| 857 |
+
st.session_state.notice_df = (
|
| 858 |
+
pd.DataFrame([build_notice(r) for r in all_rows])
|
| 859 |
+
if all_rows else pd.DataFrame()
|
| 860 |
+
)
|
| 861 |
+
|
| 862 |
+
st.success(f"Done! Found **{len(st.session_state.rj_emails)}** RJ forwarded emails.")
|
| 863 |
+
|
| 864 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 865 |
+
|
| 866 |
+
# ββ Results ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 867 |
+
if st.session_state.rj_emails:
|
| 868 |
+
st.divider()
|
| 869 |
+
st.markdown("### Results")
|
| 870 |
+
tab1, tab2, tab3, tab4 = st.tabs([
|
| 871 |
+
"π Action Notices", "π΅ Cash Transactions", "β¬ Withdrawals", "β¬ Deposits"
|
| 872 |
+
])
|
| 873 |
+
for tab, key, label in [
|
| 874 |
+
(tab1, "notice_df", "No action notices generated."),
|
| 875 |
+
(tab2, "cash_df", "No cash transactions found."),
|
| 876 |
+
(tab3, "withdrawals_df", "No withdrawals found."),
|
| 877 |
+
(tab4, "deposits_df", "No deposits found."),
|
| 878 |
+
]:
|
| 879 |
+
with tab:
|
| 880 |
+
df = st.session_state[key]
|
| 881 |
+
if df is not None and not df.empty:
|
| 882 |
+
# Hide HTML columns
|
| 883 |
+
display_df = df.drop(columns=[c for c in df.columns if "html" in c.lower()], errors="ignore")
|
| 884 |
+
st.dataframe(display_df, use_container_width=True, hide_index=True)
|
| 885 |
+
else:
|
| 886 |
+
st.info(label)
|
| 887 |
+
|
| 888 |
+
st.divider()
|
| 889 |
+
excel_bytes = to_excel({
|
| 890 |
+
"Action Notices": st.session_state.notice_df,
|
| 891 |
+
"Cash Transactions": st.session_state.cash_df,
|
| 892 |
+
"Withdrawals": st.session_state.withdrawals_df,
|
| 893 |
+
"Deposits": st.session_state.deposits_df,
|
| 894 |
+
})
|
| 895 |
+
st.download_button(
|
| 896 |
+
label="β¬ Download Excel Report",
|
| 897 |
+
data=excel_bytes,
|
| 898 |
+
file_name=f"rj_emails_{datetime.now().strftime('%Y%m%d_%H%M')}.xlsx",
|
| 899 |
+
mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
| 900 |
+
)
|