diff --git a/scripts/campaign_template.html b/scripts/campaign_template.html new file mode 100644 index 0000000..769d19b --- /dev/null +++ b/scripts/campaign_template.html @@ -0,0 +1,69 @@ + +
+
+ + + + + + + + + + + + +
+
+ diff --git a/scripts/document_gen/templates/_styles.py b/scripts/document_gen/templates/_styles.py new file mode 100644 index 0000000..2cc639a --- /dev/null +++ b/scripts/document_gen/templates/_styles.py @@ -0,0 +1,307 @@ +"""Shared document styles for all Performance West DOCX generators. + +Import this module in every generator to ensure consistent typography, +spacing, and formatting across all compliance documents. + +Usage: + from scripts.document_gen.templates._styles import ( + PW, apply_doc_defaults, add_body, add_heading, add_bullets, + add_signature_block, add_page_numbers, add_horizontal_rule, + ) + +All generators should call apply_doc_defaults(doc) first, then use +the helper functions instead of building paragraphs manually. +""" +from __future__ import annotations + +import logging +from typing import Optional + +LOG = logging.getLogger("document_gen.styles") + +try: + from docx import Document + from docx.shared import Pt, Inches, RGBColor, Emu + from docx.enum.text import WD_ALIGN_PARAGRAPH + from docx.oxml.ns import qn + from docx.oxml import OxmlElement +except ImportError: + LOG.warning("python-docx not installed") + Document = None + + +# ═══════════════════════════════════════════════════════════════════════════ +# Color palette +# ═══════════════════════════════════════════════════════════════════════════ + +class PW: + """Performance West brand colors and typography constants.""" + NAVY = RGBColor(0x1A, 0x27, 0x44) if Document else None + DARK_GRAY = RGBColor(0x37, 0x41, 0x51) if Document else None + MEDIUM_GRAY = RGBColor(0x6B, 0x72, 0x80) if Document else None + LIGHT_GRAY = RGBColor(0x9C, 0xA3, 0xAF) if Document else None + BLACK = RGBColor(0x00, 0x00, 0x00) if Document else None + GREEN = RGBColor(0x05, 0x96, 0x69) if Document else None + RED = RGBColor(0xDC, 0x26, 0x26) if Document else None + + # Typography + FONT_FAMILY = "Calibri" + BODY_SIZE = Pt(9.5) if Document else None + BODY_SMALL = Pt(8.5) if Document else None + HEADING_1_SIZE = Pt(13) if Document else None + HEADING_2_SIZE = Pt(11) if Document else None + HEADING_3_SIZE = Pt(10) if Document else None + TITLE_SIZE = Pt(15) if Document else None + SUBTITLE_SIZE = Pt(10) if Document else None + FOOTER_SIZE = Pt(7.5) if Document else None + SIGNATURE_SIZE = Pt(9.5) if Document else None + + # Spacing + BODY_AFTER = Pt(8) if Document else None + HEADING_BEFORE = Pt(16) if Document else None + HEADING_AFTER = Pt(6) if Document else None + BULLET_AFTER = Pt(4) if Document else None + LINE_SPACING = 1.15 + + # Margins + MARGIN_TOP = Inches(0.9) if Document else None + MARGIN_BOTTOM = Inches(0.9) if Document else None + MARGIN_LEFT = Inches(1.1) if Document else None + MARGIN_RIGHT = Inches(1.1) if Document else None + + +# ═══════════════════════════════════════════════════════════════════════════ +# Document setup +# ═══════════════════════════════════════════════════════════════════════════ + + +def apply_doc_defaults(doc, title: str = "", entity_name: str = "") -> None: + """Apply standard margins, default font, and optional header/footer.""" + for section in doc.sections: + section.top_margin = PW.MARGIN_TOP + section.bottom_margin = PW.MARGIN_BOTTOM + section.left_margin = PW.MARGIN_LEFT + section.right_margin = PW.MARGIN_RIGHT + + # Set default font on the document's style + style = doc.styles["Normal"] + style.font.name = PW.FONT_FAMILY + style.font.size = PW.BODY_SIZE + style.font.color.rgb = PW.DARK_GRAY + style.paragraph_format.space_after = PW.BODY_AFTER + style.paragraph_format.line_spacing = PW.LINE_SPACING + + +def add_page_numbers(doc) -> None: + """Add centered page numbers to the document footer.""" + for section in doc.sections: + footer = section.footer + footer.is_linked_to_previous = False + p = footer.paragraphs[0] if footer.paragraphs else footer.add_paragraph() + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + + # Page X + run = p.add_run() + run.font.size = PW.FOOTER_SIZE + run.font.color.rgb = PW.LIGHT_GRAY + run.font.name = PW.FONT_FAMILY + + fc_begin = OxmlElement("w:fldChar") + fc_begin.set(qn("w:fldCharType"), "begin") + run._element.append(fc_begin) + + r2 = p.add_run() + r2.font.size = PW.FOOTER_SIZE + r2.font.color.rgb = PW.LIGHT_GRAY + instr = OxmlElement("w:instrText") + instr.set(qn("xml:space"), "preserve") + instr.text = " PAGE " + r2._element.append(instr) + + r3 = p.add_run() + fc_end = OxmlElement("w:fldChar") + fc_end.set(qn("w:fldCharType"), "end") + r3._element.append(fc_end) + + +def add_pw_footer(doc, entity_name: str = "") -> None: + """Add 'Prepared by Performance West Inc.' footer with page numbers.""" + for section in doc.sections: + footer = section.footer + footer.is_linked_to_previous = False + p = footer.paragraphs[0] if footer.paragraphs else footer.add_paragraph() + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + + # "Prepared by Performance West Inc." text + run = p.add_run("Prepared by Performance West Inc.") + run.font.size = PW.FOOTER_SIZE + run.font.color.rgb = PW.LIGHT_GRAY + run.font.name = PW.FONT_FAMILY + run.italic = True + + # Add separator and page number + sep = p.add_run(" \u2014 Page ") + sep.font.size = PW.FOOTER_SIZE + sep.font.color.rgb = PW.LIGHT_GRAY + sep.font.name = PW.FONT_FAMILY + + # Page number field + fc_begin = OxmlElement("w:fldChar") + fc_begin.set(qn("w:fldCharType"), "begin") + r_page = p.add_run() + r_page.font.size = PW.FOOTER_SIZE + r_page.font.color.rgb = PW.LIGHT_GRAY + r_page._element.append(fc_begin) + + r_instr = p.add_run() + r_instr.font.size = PW.FOOTER_SIZE + instr = OxmlElement("w:instrText") + instr.set(qn("xml:space"), "preserve") + instr.text = " PAGE " + r_instr._element.append(instr) + + r_end = p.add_run() + fc_end = OxmlElement("w:fldChar") + fc_end.set(qn("w:fldCharType"), "end") + r_end._element.append(fc_end) + + +# ═══════════════════════════════════════════════════════════════════════════ +# Content helpers +# ═══════════════════════════════════════════════════════════════════════════ + + +def add_title(doc, text: str, subtitle: str = "") -> None: + """Add a document title (centered, navy, large).""" + p = doc.add_paragraph() + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + p.paragraph_format.space_after = Pt(2) + run = p.add_run(text) + run.font.size = PW.TITLE_SIZE + run.bold = True + run.font.color.rgb = PW.NAVY + run.font.name = PW.FONT_FAMILY + + if subtitle: + p2 = doc.add_paragraph() + p2.alignment = WD_ALIGN_PARAGRAPH.CENTER + p2.paragraph_format.space_after = Pt(12) + run2 = p2.add_run(subtitle) + run2.font.size = PW.SUBTITLE_SIZE + run2.italic = True + run2.font.color.rgb = PW.MEDIUM_GRAY + run2.font.name = PW.FONT_FAMILY + + +def add_heading(doc, text: str, level: int = 1) -> None: + """Add a section heading (navy, bold).""" + p = doc.add_paragraph() + size = {1: PW.HEADING_1_SIZE, 2: PW.HEADING_2_SIZE, 3: PW.HEADING_3_SIZE}.get(level, PW.HEADING_2_SIZE) + before = PW.HEADING_BEFORE if level == 1 else Pt(10) + + p.paragraph_format.space_before = before + p.paragraph_format.space_after = PW.HEADING_AFTER + run = p.add_run(text) + run.bold = True + run.font.size = size + run.font.color.rgb = PW.NAVY + run.font.name = PW.FONT_FAMILY + + +def add_body(doc, text: str, bold: bool = False, italic: bool = False, + size=None, color=None, alignment=None) -> None: + """Add a body paragraph.""" + p = doc.add_paragraph() + p.paragraph_format.space_after = PW.BODY_AFTER + p.paragraph_format.line_spacing = PW.LINE_SPACING + if alignment: + p.alignment = alignment + run = p.add_run(text) + run.font.size = size or PW.BODY_SIZE + run.font.name = PW.FONT_FAMILY + run.font.color.rgb = color or PW.DARK_GRAY + run.bold = bold + run.italic = italic + + +def add_field_value(doc, label: str, value: str) -> None: + """Add a label: value pair on one line.""" + p = doc.add_paragraph() + p.paragraph_format.space_after = Pt(3) + p.paragraph_format.line_spacing = PW.LINE_SPACING + + run_label = p.add_run(f"{label}: ") + run_label.font.size = PW.BODY_SIZE + run_label.font.name = PW.FONT_FAMILY + run_label.font.color.rgb = PW.MEDIUM_GRAY + run_label.bold = False + + run_value = p.add_run(value) + run_value.font.size = PW.BODY_SIZE + run_value.font.name = PW.FONT_FAMILY + run_value.font.color.rgb = PW.DARK_GRAY + run_value.bold = True + + +def add_bullets(doc, items: list[str], indent: float = 0.25) -> None: + """Add a bulleted list.""" + for item in items: + p = doc.add_paragraph(style="List Bullet") + p.paragraph_format.left_indent = Inches(indent) + p.paragraph_format.space_after = PW.BULLET_AFTER + p.paragraph_format.line_spacing = PW.LINE_SPACING + p.clear() + run = p.add_run(item) + run.font.size = PW.BODY_SIZE + run.font.name = PW.FONT_FAMILY + run.font.color.rgb = PW.DARK_GRAY + + +def add_horizontal_rule(doc) -> None: + """Add a thin navy horizontal rule.""" + p = doc.add_paragraph() + p.paragraph_format.space_before = Pt(6) + p.paragraph_format.space_after = Pt(6) + pPr = p._p.get_or_add_pPr() + pBdr = OxmlElement("w:pBdr") + bottom = OxmlElement("w:bottom") + bottom.set(qn("w:val"), "single") + bottom.set(qn("w:sz"), "4") + bottom.set(qn("w:space"), "1") + bottom.set(qn("w:color"), "1A2744") + pBdr.append(bottom) + pPr.append(pBdr) + + +def add_signature_block( + doc, + signer_name: str = "", + signer_title: str = "", + entity_name: str = "", + date_str: str = "", + include_perjury: bool = False, +) -> None: + """Add a standardized signature block.""" + if include_perjury: + add_body(doc, ( + "I declare under penalty of perjury under the laws of the " + "United States of America that the foregoing is true and correct." + ), italic=True, size=PW.BODY_SMALL) + + # Signature line + add_body(doc, "", size=Pt(20)) # spacer + p = doc.add_paragraph() + p.paragraph_format.space_after = Pt(2) + run = p.add_run("_" * 45) + run.font.size = PW.SIGNATURE_SIZE + run.font.name = PW.FONT_FAMILY + run.font.color.rgb = PW.LIGHT_GRAY + + if signer_name: + add_body(doc, signer_name, bold=True, size=PW.SIGNATURE_SIZE) + if signer_title: + add_body(doc, signer_title, size=PW.BODY_SMALL, color=PW.MEDIUM_GRAY) + if entity_name: + add_body(doc, entity_name, size=PW.BODY_SMALL, color=PW.MEDIUM_GRAY) + if date_str: + add_body(doc, f"Date: {date_str}", size=PW.BODY_SMALL, color=PW.MEDIUM_GRAY) diff --git a/scripts/populate_deficiency_list.py b/scripts/populate_deficiency_list.py new file mode 100644 index 0000000..3705350 --- /dev/null +++ b/scripts/populate_deficiency_list.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +"""Populate Listmonk with FCC RMD deficiency data for campaign sends. + +Creates a dedicated list and upserts every carrier that has major/critical +audit deficiencies. Sets subscriber attributes with issues_html so the +campaign template can render personalized issue lists. + +Skips carriers tagged as is_customer=true. +""" +import json +import subprocess +import sys +import time +import urllib.request +import urllib.error +import base64 + +AUTH = base64.b64encode(b"api:6X1rKPea61N4rZ1S65Hx5zvqzbCj30F6nvEe9oVGH_Y").decode() +API = "http://localhost:9100/api" + +ISSUE_LABELS = { + "ss_partial_note": "STIR/SHAKEN partial implementation \u2014 upstream provider not named", + "ss_vsp_no_shaken": "Voice Service Provider without STIR/SHAKEN implementation", + "conflicting_classification": "Unusual provider classification \u2014 may need correction", + "missing_kyc": "Missing Know Your Customer (KYC) procedures", + "missing_material_change": "Missing 10-business-day material change update commitment", + "missing_dno": "Missing Do-Not-Originate (DNO) list enforcement", + "missing_traceback": "Missing 24-hour traceback response commitment", + "missing_recertification": "Missing annual recertification acknowledgment", + "missing_perjury": "Missing perjury declaration", + "missing_mitigation": "Missing robocall mitigation program details", + "no_classification": "No provider classification selected", + "ss_intermediate_complete": "Intermediate provider claims Complete STIR/SHAKEN", + "xref_name_mismatch": "Business name not found in certification document", + "xref_old_document": "Outdated certification document", + "xref_ss_mismatch": "STIR/SHAKEN status mismatch between RMD and document", + "missing_stir_shaken": "Missing STIR/SHAKEN implementation details", + "missing_enforcement": "Missing enforcement history disclosure", + "missing_provider_id": "Missing provider identification", + "no_recert_date": "No recertification date on file", +} + + +def api_call(method, path, data=None): + payload = json.dumps(data).encode() if data else None + req = urllib.request.Request( + API + path, data=payload, method=method, + headers={"Content-Type": "application/json", "Authorization": "Basic " + AUTH}, + ) + return json.loads(urllib.request.urlopen(req).read()) + + +def build_issues_html(structured_json, pdf_json): + """Merge structured + PDF checks, return HTML list of major/critical issues.""" + all_checks = [] + for cj in [structured_json, pdf_json]: + try: + checks = json.loads(cj) + if isinstance(checks, list): + all_checks.extend(checks) + except Exception: + pass + + severity_order = {"critical": 0, "major": 1, "minor": 2} + all_checks.sort(key=lambda c: severity_order.get(c.get("severity", "minor"), 2)) + + items = [] + ids = [] + seen = set() + for c in all_checks: + cid = c.get("id", "") + if cid in seen: + continue + seen.add(cid) + sev = c.get("severity", "minor") + if sev == "minor": + continue + label = ISSUE_LABELS.get(cid, c.get("label", cid)) + items.append("
  • " + label + "
  • ") + ids.append(cid) + + if not items: + for c in all_checks: + cid = c.get("id", "") + label = ISSUE_LABELS.get(cid, c.get("label", cid)) + items.append("
  • " + label + "
  • ") + ids.append(cid) + + html = '" + return html, ids + + +def main(): + # Query carriers with deficiencies + result = subprocess.run([ + "docker", "exec", "performancewest-api-postgres-1", "psql", "-U", "pw", + "performancewest", "-t", "-A", "-F", "||", "-c", + """SELECT a.frn, a.business_name, a.total_deficiencies, a.severity, + r.contact_email, r.contact_name, r.implementation, + r.last_recertified::text, r.rmd_number, + a.structured_checks::text, a.pdf_checks::text + FROM fcc_rmd_audit_results a + JOIN fcc_rmd r ON r.frn = a.frn + WHERE a.total_deficiencies > 0 + AND a.severity IN ('major', 'critical') + AND r.contact_email IS NOT NULL + AND r.removed_from_rmd = FALSE + ORDER BY a.total_deficiencies DESC""", + ], capture_output=True, text=True) + + lines = [l for l in result.stdout.strip().split("\n") if l.strip()] + print(f"Found {len(lines)} carriers with major/critical deficiencies") + + # Find or create list + lists_resp = api_call("GET", "/lists") + deficiency_list_id = None + for l in lists_resp.get("data", {}).get("results", []): + if "Deficiency" in l["name"] and "2026" in l["name"]: + deficiency_list_id = l["id"] + break + + if not deficiency_list_id: + resp = api_call("POST", "/lists", { + "name": "FCC RMD Deficiency Alert 2026", + "type": "public", + "optin": "single", + "tags": ["fcc", "rmd", "deficiency", "2026"], + }) + deficiency_list_id = resp.get("data", {}).get("id") + print(f"Created list: {deficiency_list_id}") + else: + print(f"Using existing list: {deficiency_list_id}") + + added = 0 + updated = 0 + skipped_customer = 0 + errors = 0 + + for line in lines: + parts = line.split("||") + if len(parts) < 11: + continue + + frn = parts[0].strip() + company = parts[1].strip() + deficiency_count = int(parts[2].strip() or 0) + severity = parts[3].strip() + email = parts[4].strip().lower() + contact_name = parts[5].strip() + implementation = parts[6].strip() + last_recert = parts[7].strip() + rmd_number = parts[8].strip() + structured_json = parts[9].strip() + pdf_json = parts[10].strip() + + if not email or "@" not in email: + continue + + issues_html, issue_ids = build_issues_html(structured_json, pdf_json) + + attribs = { + "company": company, + "fcc_frn": frn, + "rmd_number": rmd_number, + "severity": severity, + "deficiency_count": deficiency_count, + "issues_html": issues_html, + "issue_ids": ",".join(issue_ids), + "implementation": implementation, + "last_recertified": last_recert, + } + + try: + resp = api_call("POST", "/subscribers", { + "email": email, + "name": contact_name or company, + "status": "enabled", + "lists": [deficiency_list_id], + "attribs": attribs, + "preconfirm_subscriptions": True, + }) + added += 1 + except urllib.error.HTTPError as e: + err = e.read().decode() + if "already exists" in err: + try: + search = api_call("GET", "/subscribers?query=subscribers.email%3D%27" + email.replace("'", "") + "%27&per_page=1") + results = search.get("data", {}).get("results", []) + if results: + sub_id = results[0]["id"] + existing = results[0].get("attribs", {}) + + if existing.get("is_customer"): + skipped_customer += 1 + continue + + existing.update(attribs) + api_call("PUT", f"/subscribers/{sub_id}", { + "email": email, + "name": contact_name or results[0].get("name", company), + "attribs": existing, + "status": "enabled", + }) + api_call("PUT", "/subscribers/lists", { + "ids": [sub_id], + "action": "add", + "target_list_ids": [deficiency_list_id], + "status": "confirmed", + }) + updated += 1 + except Exception as ex: + errors += 1 + else: + errors += 1 + + if (added + updated) % 200 == 0 and (added + updated) > 0: + print(f" Progress: {added} added, {updated} updated, {skipped_customer} skipped") + + time.sleep(0.05) # rate limit + + print(f"\nDone:") + print(f" Added: {added}") + print(f" Updated: {updated}") + print(f" Customers skipped: {skipped_customer}") + print(f" Errors: {errors}") + print(f" List ID: {deficiency_list_id}") + + search = api_call("GET", f"/subscribers?list_id={deficiency_list_id}&per_page=1") + total = search.get("data", {}).get("total", 0) + print(f" Total subscribers on list: {total}") + + +if __name__ == "__main__": + main() diff --git a/scripts/send_test_campaigns.py b/scripts/send_test_campaigns.py new file mode 100644 index 0000000..6ddf5f4 --- /dev/null +++ b/scripts/send_test_campaigns.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Send test compliance gap emails to justin@performancewest.net using real carrier data.""" +import json +import smtplib +import subprocess +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +ISSUE_LABELS = { + "ss_partial_note": "STIR/SHAKEN partial implementation \u2014 upstream provider not named in filing", + "ss_vsp_no_shaken": "Voice Service Provider without STIR/SHAKEN implementation", + "conflicting_classification": "Unusual provider classification \u2014 may need correction", + "missing_kyc": "Missing Know Your Customer (KYC) procedures", + "missing_material_change": "Missing 10-business-day material change update commitment", + "missing_dno": "Missing Do-Not-Originate (DNO) list enforcement", + "missing_traceback": "Missing 24-hour traceback response commitment", + "missing_recertification": "Missing annual recertification acknowledgment", + "missing_perjury": "Missing perjury declaration", + "missing_mitigation": "Missing robocall mitigation program details", + "no_classification": "No provider classification selected", + "ss_intermediate_complete": "Intermediate provider claims Complete STIR/SHAKEN", +} + +# Query real carrier data +result = subprocess.run([ + "docker", "exec", "performancewest-api-postgres-1", "psql", "-U", "pw", + "performancewest", "-t", "-A", "-F", "|", "-c", + """SELECT a.frn, a.business_name, a.total_deficiencies, a.severity, + r.contact_email, r.implementation, r.last_recertified::text, + a.structured_checks::text, a.pdf_checks::text + FROM fcc_rmd_audit_results a + JOIN fcc_rmd r ON r.frn = a.frn + WHERE a.total_deficiencies > 0 + AND a.severity IN ('major', 'critical') + AND r.contact_email IS NOT NULL + AND r.removed_from_rmd = FALSE + ORDER BY a.total_deficiencies DESC + LIMIT 5""" +], capture_output=True, text=True) + +with open("/tmp/campaign_template.html") as f: + template = f.read() + +for line in result.stdout.strip().split("\n"): + if not line.strip(): + continue + parts = line.split("|") + if len(parts) < 9: + continue + + frn = parts[0].strip() + company = parts[1].strip() + deficiency_count = parts[2].strip() + severity = parts[3].strip() + email_orig = parts[4].strip() + implementation = parts[5].strip() + last_recert = parts[6].strip() + structured_json = parts[7].strip() + pdf_json = parts[8].strip() + + # Merge both structured and PDF checks, prioritize major/critical + all_checks = [] + for cj in [structured_json, pdf_json]: + try: + checks = json.loads(cj) + if isinstance(checks, list): + all_checks.extend(checks) + except Exception: + pass + + # Sort: critical first, then major, then minor — skip minor for email + severity_order = {"critical": 0, "major": 1, "minor": 2} + all_checks.sort(key=lambda c: severity_order.get(c.get("severity", "minor"), 2)) + + items = [] + seen_ids = set() + for c in all_checks: + cid = c.get("id", "") + if cid in seen_ids: + continue + seen_ids.add(cid) + sev = c.get("severity", "minor") + if sev == "minor": + continue # skip minor issues in email + label = ISSUE_LABELS.get(cid, c.get("label", cid)) + items.append(f"
  • {label}
  • ") + + if not items: + # If all were minor, include them anyway + for c in all_checks: + cid = c.get("id", "") + label = ISSUE_LABELS.get(cid, c.get("label", cid)) + items.append(f"
  • {label}
  • ") + + issues_html = '" + + # Build the email from template + body = template + body = body.replace("{{ .Subscriber.Name }}", "there") + body = body.replace("{{ .Subscriber.Attribs.company }}", company) + body = body.replace("{{ .Subscriber.Attribs.fcc_frn }}", frn) + body = body.replace("{{ .Subscriber.Attribs.issues_html }}", issues_html) + body = body.replace("{{ .Subscriber.Attribs.implementation }}", implementation or "Unknown") + body = body.replace("{{ .Subscriber.Attribs.last_recertified }}", last_recert or "Unknown") + body = body.replace("{{ UnsubscribeURL }}", "#") + + msg = MIMEMultipart("alternative") + msg["From"] = "Performance West " + msg["To"] = "justin@performancewest.net" + msg["Subject"] = f"[TEST] FCC Compliance Alert - {company}" + msg["Reply-To"] = "info@performancewest.net" + msg.attach(MIMEText(body, "html")) + + with smtplib.SMTP("email-smtp.us-east-2.amazonaws.com", 587, timeout=30) as s: + s.starttls() + s.login("AKIAYEWLMNWPHSHQWCRD", "BKrUBud+KjyaRA1RiA26FFu1R+hqR4cpFShwbZf7RUzG") + s.send_message(msg) + + print(f"Sent: {company} ({frn}) - {deficiency_count} issues (would go to {email_orig})") + +print("\nDone - all test emails sent to justin@performancewest.net")