# ============================================================ # vtmf_pipeline_v1.6.py # Verze: 1.6 # Datum: 2026-06-15 # Popis: Kompletní workflow V-TMF (J&J Veeva Vault) pro studii # 77242113UCO3001 přes VŠECHNY TŘI ÚROVNĚ dokumentů # (STUDY / COUNTRY / SITE). Jeden běh udělá pro každý # report ze seznamu REPORTS: # 1) login do Vaultu (persistentní session + ruční 2FA), # 2) export reportu do Excelu (Data Only) do WhatToDownload/, # 3) parse + scoped sync do MongoDB (db VTMF, kolekce # documents; klíč _id = "číslo|verze"), # a nakonec jeden průchod stažení všech dosud nestažených # dokumentů PŘÍMO do SeaweedFS (žádný Dropbox/disk). # # ZÁSADNÍ ZMĚNY proti v1.5: # # • Hierarchie dokumentů ve VTMF je STUDY -> COUNTRY -> SITE. # Dokument je do studií/zemí/center jen REFERENCOVANÝ (M:N) — # např. Master Confidentiality Agreement v nemocnici je jeden # dokument referencovaný do všech studií i center té nemocnice. # Proto: jeden dokument = jeden záznam = jeden SeaweedFS objekt; # příslušnost je jen metadatová pole studies[]/countries[]/sites[]. # # • REPORTS = seznam (level, study, country, url). Country i site # report filtrují jen na zemi (CZ), ne na studii -> při ukládání # se row bere jen pokud cílová studie je v jeho Study sloupci # (prakticky no-op, vše vrácené UCO3001 obsahuje). # # • Zobecněný parser: study report má 15 sloupců (+ Document Date), # country/site mají 17 (+ Created By, Study Country, Site; bez # Document Date). Sloupce se hledají podle NÁZVU, datum má # fallback Document Date -> Approval Complete Date -> Version # Creation Date. Study/Study Country/Site se parsují na pole. # # • Scoped sync: mazání už NEkouká na celou kolekci. Každý report # má scope = (level|study|country); dokument nese pole scopes[]. # Když z reportu daného scope zmizí, scope se odebere; teprve # když nemá žádný scope -> deleted=True. # # • Evidence reportů: kolekce report_runs (level, study, country, # url, exported_at, file, row_count, doc_keys). # # • ÚLOŽIŠTĚ = JEN SeaweedFS, klíč číslo dokumentu + verze: # /vtmf-documents//. # Žádné ukládání dokumentů na disk/Dropbox — stahují se přes # dočasný soubor Playwrightu rovnou do Fileru. SHA-256 se počítá # a ukládá do Mongo jen jako kontrolní součet. (Aktuální verzi # čehokoli do Dropboxu zařídí samostatný export skript ze SeaweedFS.) # # Heslo se NIKDY nedává natvrdo do skriptu — čte se z .env # v rootu projektu Janssen (VAULT_USER / VAULT_PASS). # # Migrace stávajících study-level dat na toto schéma: migrate_to_v16.py # Předchůdce: vtmf_pipeline_v1.5 (v TRASH/). # ============================================================ import hashlib import mimetypes import os import re import sys import urllib.error import urllib.request from datetime import datetime from pathlib import Path from playwright.sync_api import sync_playwright, TimeoutError as PWTimeout from pymongo import MongoClient, ASCENDING # --- Konfigurace ------------------------------------------------------- LOGIN_URL = ("https://fedlogin.jnj.com/idp/eyJ2c2lkIjoiam5qX3ZlZXZhIn0/" "startSSO.ping?PartnerSpId=janssenetmf.veevavault.com" "&IdpAdapterId=CompIWALDAPEXTFORM" "&TargetResource=https%3A%2F%2Fvtmf.veevavault.com%2F") # Studie, jejíž TMF stavíme (cíl ořezu country/site reportů). TARGET_STUDY = "77242113UCO3001" # ==================================================================== # SEZNAM REPORTŮ KE ZPRACOVÁNÍ # -------------------------------------------------------------------- # Každý řádek = jeden report. Pole: # enabled = True/False -> přepni na False a report se v dalším běhu # NEnačte (zůstane v seznamu jako dokumentace) # name = popisek do logu (co to je za report) # level = "study" | "country" | "site" (úroveň + scope) # study = kód cílové studie (scope + ořez na tuto studii) # country = země scope (None u study-level) # url = přímý odkaz na report viewer ve Vaultu # # Přidání jiné studie = prostě dopiš další 3 řádky s jejím kódem # a URL; běh je zpracuje vedle stávajících. # ==================================================================== REPORTS = [ {"enabled": True, "name": "UCO3001 — STUDY level", "level": "study", "study": TARGET_STUDY, "country": None, "url": "https://vtmf.veevavault.com/ui/#reporting/viewer/" "0RP000000000182?study__v%2C%2C%2CIN=0ST000000137008"}, {"enabled": True, "name": "UCO3001 — COUNTRY level (Czech Republic)", "level": "country", "study": TARGET_STUDY, "country": "Czech Republic", "url": "https://vtmf.veevavault.com/ui/#reporting/viewer/" "0RP000000000319?study_country__v%2C%2C%2CIN=0SC00000017T056"}, {"enabled": False, "name": "UCO3001 — SITE level (all sites in Czech Republic)", "level": "site", "study": TARGET_STUDY, "country": "Czech Republic", "url": "https://vtmf.veevavault.com/ui/#reporting/viewer/" "0RP000000000762?study_country__v%2C%2C%2CEQ=0SC00000017T056"}, {"enabled": True, "name": "UCO3002 — STUDY level", "level": "study", "study": "77242113UCO3002", "country": None, "url": "https://vtmf.veevavault.com/ui/#reporting/viewer/" "0RP000000000182?study__v%2C%2C%2CIN=0ST00000016Z008"}, ] VAULT_UI_PATTERN = "**vtmf.veevavault.com/ui**" # úspěšný vstup do Vaultu SCRIPT_DIR = Path(__file__).resolve().parent PROFILE_DIR = SCRIPT_DIR / "vault_profile" # perzistentní session ENV_FILE = SCRIPT_DIR.parent / ".env" # root projektu Janssen DEBUG_DIR = SCRIPT_DIR / "debug" # diagnostické výstupy EXCEL_DIR = SCRIPT_DIR / "WhatToDownload" # stažené reporty (jen Excel) PROCESSED_DIR = EXCEL_DIR / "Zpracovano" # archiv zpracovaných MONGO_URI = "mongodb://192.168.1.76:27017" MONGO_DB = "VTMF" MONGO_COLL = "documents" RUNS_COLL = "report_runs" # Kolik dokumentů stáhnout v tomto běhu (None = všechny zbývající) LIMIT = None # Pole, jejichž změny se verzují do history[] TRACKED_FIELDS = ("name", "status", "type", "subtype", "classification", "desc", "date", "url", "studies", "countries", "sites", "level") MAX_ATTEMPTS = 2 # pokusy na jeden dokument RETRY_PAUSE_MS = 5000 # pauza před opakováním BETWEEN_DOCS_MS = 500 # pauza mezi dokumenty SEAWEED_FILER = "http://192.168.1.50:8888" SEAWEED_PREFIX = "/vtmf-documents" class PlaceholderDocument(Exception): """Dokument existuje jen jako placeholder — "This placeholder has no content".""" def log(msg): print(msg, flush=True) def load_env_file(path): """Načte KEY=VALUE řádky z .env do os.environ. Už nastavené env proměnné mají přednost, .env je nepřepisuje.""" if not path.exists(): log(f"[!] .env nenalezen: {path}") return for line in path.read_text(encoding="utf-8").splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, _, value = line.partition("=") key, value = key.strip(), value.strip().strip('"').strip("'") if value and key not in os.environ: os.environ[key] = value ENV_SECTION_HEADER = "# --- Veeva Vault (J&J V-TMF) — VTMFDownloadFiles/download_vault ---" ENV_KEYS = ("VAULT_USER", "VAULT_PASS") def ensure_credentials(): """Načte .env; pokud VAULT_USER/VAULT_PASS chybí, založí/doplní v .env šablonu, vyzve uživatele k doplnění a ukončí skript.""" load_env_file(ENV_FILE) if all(os.environ.get(k) for k in ENV_KEYS): return existing = ENV_FILE.read_text(encoding="utf-8") if ENV_FILE.exists() else "" missing_lines = [f"{k}=" for k in ENV_KEYS if not re.search(rf"^\s*{k}\s*=", existing, re.M)] if not ENV_FILE.exists(): ENV_FILE.write_text( "# .env — lokální přihlašovací údaje (NEVERZOVAT, je v .gitignore)\n\n" + ENV_SECTION_HEADER + "\n" + "\n".join(missing_lines) + "\n", encoding="utf-8") log(f"[i] Založil jsem nový .env: {ENV_FILE}") elif missing_lines: with open(ENV_FILE, "a", encoding="utf-8") as f: f.write("\n" + ENV_SECTION_HEADER + "\n" + "\n".join(missing_lines) + "\n") log(f"[i] Doplnil jsem chybějící řádky do .env: {ENV_FILE}") print("\n" + "=" * 60) print(" CHYBÍ PŘIHLAŠOVACÍ ÚDAJE.") print(f" Doplň VAULT_USER a VAULT_PASS do souboru:") print(f" {ENV_FILE}") print(" a spusť skript znovu.") print("=" * 60) sys.exit(1) # --- Parsování Excelu -------------------------------------------------- HYPERLINK_RE = re.compile(r'HYPERLINK\("([^"]+)"\s*,\s*"([^"]+)"\)') VERSION_RE = re.compile(r"\((v[^)]+)\)\s*$") DATE_RE = re.compile(r"(\d{4}-\d{2}-\d{2})") # nepovolené znaky názvů + řídicí znaky + unicode artefakt � BAD_CHARS_RE = re.compile(r"[<>:\"/\\|?*\x00-\x1f�]") def clean_text(s): """Očistí string na rozumný název (bez nepovolených znaků).""" s = BAD_CHARS_RE.sub("_", str(s)) s = re.sub(r"\s+", " ", s) s = re.sub(r"_{2,}", "_", s) return s.strip(" ._") def display_text(cell): """Zobrazený text buňky — u =HYPERLINK vzorce druhý argument.""" raw = str(cell.value or "").strip() m = HYPERLINK_RE.search(raw) return m.group(2).strip() if m else raw def split_multi(text): """Comma-separated seznam -> list (strip, bez prázdných, dedup pořadí).""" out, seen = [], set() for part in str(text or "").split(","): p = part.strip() if p and p not in seen: seen.add(p) out.append(p) return out def cell_date(cell): """Z buňky vytáhne datum jako 'YYYY-MM-DD' (datetime i string), nebo ''.""" v = cell.value if cell is not None else None if hasattr(v, "strftime"): return v.strftime("%Y-%m-%d") m = DATE_RE.search(str(v or "")) return m.group(1) if m else "" def extract_doc_url(raw): """Z HYPERLINK hodnoty (nebo i rozbité URL) vytáhne čistou doc URL ve tvaru https:///ui/#doc_info///.""" m = re.search(r"(https://[^/\"]+/ui/#doc_info/\d+/\d+/\d+)", str(raw)) if not m: raise ValueError(f"Nenašel jsem doc URL v: {raw!r}") return m.group(1) def read_documents_from_excel(path, level): """Načte dokumenty z .xlsx reportu dané úrovně (study/country/site). Sloupce se hledají podle NÁZVU (study má 15, country/site 17). Document Name/Number jsou =HYPERLINK vzorce -> URL i text regexem. Report má rozbité deklarované rozměry -> přímá iterace řádků.""" from openpyxl import load_workbook log(f"[i] Parsování reportu ({level}): {path.name}") wb = load_workbook(path, data_only=False) # potřebujeme vzorce ws = wb[wb.sheetnames[0]] rows = ws.iter_rows() header = [c.value for c in next(rows)] idx = {h: i for i, h in enumerate(header) if h is not None} required = ("Document Number", "Document Name", "Document Status", "Type", "Subtype", "Description", "Study") missing = [c for c in required if c not in idx] if missing: raise RuntimeError(f"V reportu chybí očekávané sloupce: {missing}") i_num, i_name = idx["Document Number"], idx["Document Name"] i_status, i_type, i_sub = idx["Document Status"], idx["Type"], idx["Subtype"] i_desc, i_study = idx["Description"], idx["Study"] i_class = idx.get("Classification") i_proc = idx.get("Process Name") i_extsys = idx.get("External System Name") i_created = idx.get("Created By") i_modby = idx.get("Last Modified By") i_verby = idx.get("Version Created By") i_country = idx.get("Study Country") i_site = idx.get("Site") i_date_cols = [idx.get(c) for c in ("Document Date", "Approval Complete Date", "Version Creation Date") if idx.get(c) is not None] def g(row, i): return display_text(row[i]) if i is not None else "" docs, bad = [], [] for row in rows: cell = row[i_num] if cell.value is None: continue raw = str(cell.value) m = HYPERLINK_RE.search(raw) if m: url_raw, vtmf = m.group(1), m.group(2) elif cell.hyperlink: url_raw, vtmf = cell.hyperlink.target, raw else: bad.append(raw) continue try: url = extract_doc_url(url_raw) except ValueError: bad.append(raw) continue name = display_text(row[i_name]) vm = VERSION_RE.search(name) version = vm.group(1) if vm else "v?" desc = clean_text(g(row, i_desc)) if not desc: desc = clean_text(VERSION_RE.sub("", name)) date = "" for i_d in i_date_cols: date = cell_date(row[i_d]) if date: break docs.append({ "vtmf": vtmf.strip(), "version": version, "url": url, "level": level, "name": name, "status": g(row, i_status), "type": clean_text(g(row, i_type)), "subtype": clean_text(g(row, i_sub)), "classification": g(row, i_class), "desc": desc, "process_name": g(row, i_proc), "external_system_name": g(row, i_extsys), "created_by": g(row, i_created), "last_modified_by": g(row, i_modby), "version_created_by": g(row, i_verby), "date": date, "studies": split_multi(g(row, i_study)), "countries": split_multi(g(row, i_country)) if i_country is not None else [], "sites": split_multi(g(row, i_site)) if i_site is not None else [], }) log(f"[i] Načteno {len(docs)} dokumentů" + (f", {len(bad)} řádků bez použitelné URL (přeskočeno)" if bad else "")) return docs # --- MongoDB synchronizace --------------------------------------------- def doc_key(vtmf, version): return f"{vtmf}|{version}" def scope_key(report): return f"{report['level']}|{report['study']}|{report.get('country') or ''}" def get_db(): client = MongoClient(MONGO_URI, serverSelectionTimeoutMS=5000) client.admin.command("ping") db = client[MONGO_DB] coll = db[MONGO_COLL] coll.create_index([("vtmf", ASCENDING), ("version", ASCENDING)], unique=True) coll.create_index([("deleted", ASCENDING), ("downloaded", ASCENDING)]) coll.create_index([("scopes", ASCENDING)]) coll.create_index([("studies", ASCENDING)]) coll.create_index([("sites", ASCENDING)]) coll.create_index([("level", ASCENDING)]) runs = db[RUNS_COLL] runs.create_index([("level", ASCENDING), ("study", ASCENDING), ("country", ASCENDING), ("exported_at", ASCENDING)]) return db, coll, runs def sync_report_to_mongo(coll, runs, docs, report, report_file): """Promítne report daného scope do kolekce documents. - nové založí, změny polí promítne (+ history[]), - každému dokumentu přidá scope do scopes[] (a level do levels[]), - dokument, který z TOHOTO scope zmizel, ztratí tento scope; bez jakéhokoli scope -> deleted=True. Scoped mazání = sync jednoho reportu NIKDY neoznačí dokumenty jiného scope (study/country/site) jako smazané. Žádné souborové operace (úložiště je SeaweedFS).""" now = datetime.now() sk = scope_key(report) stats = {"new": 0, "updated": 0, "unchanged": 0, "resurrected": 0, "scope_removed": 0, "marked_deleted": 0} current_keys = set() for d in docs: key = doc_key(d["vtmf"], d["version"]) current_keys.add(key) existing = coll.find_one({"_id": key}) if existing is None: coll.insert_one({ "_id": key, **d, "levels": [d["level"]], "scopes": [sk], "first_seen": now, "last_seen": now, "deleted": False, "downloaded": False, "seaweed_path": None, "history": [], }) stats["new"] += 1 continue changes = {} for fld in TRACKED_FIELDS: if existing.get(fld) != d.get(fld): changes[fld] = {"old": existing.get(fld), "new": d.get(fld)} update = {"$set": {**d, "last_seen": now, "deleted": False}, "$addToSet": {"scopes": sk, "levels": d["level"]}} if changes: update["$push"] = {"history": {"ts": now, "changes": changes}} stats["updated"] += 1 else: stats["unchanged"] += 1 if existing.get("deleted"): stats["resurrected"] += 1 coll.update_one({"_id": key}, update) # dokumenty dříve v TOMTO scope, které v reportu chybí -> odebrat scope for rec in coll.find({"scopes": sk, "_id": {"$nin": list(current_keys)}}): remaining = [s for s in rec.get("scopes", []) if s != sk] upd = {"scopes": remaining} op = {"$set": upd} stats["scope_removed"] += 1 if not remaining: # už nikde -> smazáno upd["deleted"] = True upd["deleted_at"] = now op["$push"] = {"history": {"ts": now, "changes": {"deleted": {"old": False, "new": True}}}} stats["marked_deleted"] += 1 coll.update_one({"_id": rec["_id"]}, op) runs.insert_one({ "level": report["level"], "study": report["study"], "country": report.get("country"), "url": report["url"], "scope": sk, "exported_at": now, "file": str(report_file), "row_count": len(docs), "doc_keys": sorted(current_keys), }) log(f"[ok] Mongo sync [{sk}]: {stats['new']} nových, {stats['updated']} změněných, " f"{stats['unchanged']} beze změny, {stats['resurrected']} obnovených, " f"{stats['scope_removed']} odebrán scope ({stats['marked_deleted']} úplně smazáno).") return stats # --- Přihlášení -------------------------------------------------------- def submit_login_form(page, password_box): """Odešle login formulář. Zkouší postupně tlačítka Sign On / Login / OK / submit input; když žádné nenajde, stiskne Enter v poli hesla.""" candidates = [ page.get_by_role("button", name=re.compile("sign\\s*on", re.I)), page.get_by_role("button", name=re.compile("log\\s*in|sign\\s*in", re.I)), page.locator("input[type='submit']"), page.locator("button[type='submit']"), page.get_by_role("button", name=re.compile("^ok$", re.I)), ] for loc in candidates: try: if loc.count() and loc.first.is_visible(): label = (loc.first.inner_text() or loc.first.get_attribute("value") or "submit").strip() log(f"[i] Odesílám formulář tlačítkem '{label}'...") loc.first.click() return except Exception: continue log("[i] Tlačítko nenalezeno, odesílám Enterem v poli hesla...") password_box.press("Enter") def login_if_needed(page): """Otevře login URL, vyplní jméno+heslo, detekuje 2FA a počká na ruční potvrzení. Pokud perzistentní session žije, login přeskočí.""" log(f"[i] Otevírám přihlašovací URL...") page.goto(LOGIN_URL, wait_until="domcontentloaded") if "vtmf.veevavault.com/ui" in page.url: log("[i] Už přihlášen (perzistentní session).") return user_box = page.locator("input[type='text']").first try: user_box.wait_for(timeout=8000) except PWTimeout: if "vtmf.veevavault.com/ui" in page.url: log("[i] Přihlášen bez formuláře (session redirect).") return raise RuntimeError( f"Nenašel jsem login formulář ani Vault. Aktuální URL: {page.url}") username = os.environ["VAULT_USER"] password = os.environ["VAULT_PASS"] log("[i] Vyplňuji přihlašovací údaje...") user_box.fill(username) password_box = page.locator("input[type='password']").first password_box.fill(password) submit_login_form(page, password_box) log("[i] Odeslán login, čekám na výsledek...") try: page.wait_for_url(VAULT_UI_PATTERN, timeout=15000) log("[ok] Přihlášen rovnou (bez 2FA).") return except PWTimeout: pass # nejsme ve Vaultu -> pravděpodobně 2FA výzva err = page.locator("text=/invalid|incorrect|failed/i") try: if err.count() and err.first.is_visible(): raise RuntimeError(f"Login selhal: {err.first.inner_text().strip()}") except PWTimeout: pass print("\n" + "=" * 60) print(" VYŽADOVÁNO OVĚŘENÍ NA TELEFONU (2FA).") print(" Potvrď přihlášení v mobilní aplikaci.") print("=" * 60) input(" Až to potvrdíš, stiskni ENTER pro pokračování... ") page.wait_for_url(VAULT_UI_PATTERN, timeout=120000) log("[ok] Přihlášení dokončeno.") def verify_inside(page): """Ověří, že jsme uvnitř Vaultu (URL na /ui).""" page.wait_for_url(VAULT_UI_PATTERN, timeout=30000) log(f"[ok] Uvnitř Vaultu: {page.url}") def dialog_visible(page): """True, pokud je na stránce viditelný jQuery UI dialog.""" try: dlg = page.locator(".ui-dialog") return bool(dlg.count() and dlg.first.is_visible()) except Exception: return False def save_page_debug(page, tag): """Uloží diagnostiku stránky: screenshot, HTML všech frames a výpis kandidátů na tlačítka. Vrátí cestu složky.""" out = DEBUG_DIR / datetime.now().strftime(f"%Y-%m-%d_%H-%M-%S_{tag}") out.mkdir(parents=True, exist_ok=True) try: page.screenshot(path=str(out / "screenshot.png"), full_page=False) except Exception as e: (out / "screenshot_error.txt").write_text(str(e), encoding="utf-8") report = [] for i, frame in enumerate(page.frames): report.append(f"=== frame[{i}] url={frame.url}") try: (out / f"frame_{i}.html").write_text(frame.content(), encoding="utf-8") for sel in (".ui-dialog", "a.ok.vv_button", ".ui-dialog-titlebar-close", "button", "input[type='button']", "[title]", "[aria-label]"): n = frame.locator(sel).count() if n: report.append(f" {sel}: {n}x") for attr in ("title", "aria-label"): vals = frame.locator(f"[{attr}]").evaluate_all( f"els => els.map(e => e.getAttribute('{attr}'))") uniq = sorted({v for v in vals if v})[:80] report.append(f" {attr}: {uniq}") except Exception as e: report.append(f" [chyba čtení framu: {e}]") (out / "frames_report.txt").write_text("\n".join(report), encoding="utf-8") log(f"[!] Diagnostika stránky uložena do: {out}") return out # Viditelné OK tlačítko dialogu — je to , ne