z230
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
import pandas as pd
|
||||
from openpyxl import load_workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
INPUT_FILE = "accountability_combined.xlsx"
|
||||
OUTPUT_FILE = "accountability_formatted.xlsx"
|
||||
SHEET_NAME = "CountryMedicationOverview"
|
||||
|
||||
COLUMN_RENAMES = {
|
||||
"Site": "Site",
|
||||
"Medication ID": "Med ID",
|
||||
"Packaged Lot number": "Lot No.",
|
||||
"Original Expiration Date when Packaged Lot was Added": "Orig Exp Date",
|
||||
"Expiration date": "Exp Date",
|
||||
"Received Date": "Rcv Date",
|
||||
"Shipment Receipt User": "Rcpt User",
|
||||
"Subject Identifier": "Subject ID",
|
||||
"Quantity Assigned": "Qty Asgn",
|
||||
"IRT Transaction": "IRT Tx",
|
||||
"Date Assigned": "Date Asgn",
|
||||
"Assignment User": "Asgn User",
|
||||
"Dispensation Status": "Disp Status",
|
||||
"Dispensing Date": "Disp Date",
|
||||
"Quantity Dispensed": "Qty Disp",
|
||||
"Dispensing User": "Disp User",
|
||||
"Quantity Returned": "Qty Ret",
|
||||
"Date Returned": "Date Ret",
|
||||
"Return User": "Ret User",
|
||||
"DestroyedOn": "Destroyed",
|
||||
"Basket number": "Basket No.",
|
||||
}
|
||||
|
||||
DATE_COLUMNS = {
|
||||
"Orig Exp Date", "Exp Date", "Rcv Date",
|
||||
"Date Asgn", "Disp Date", "Date Ret", "Destroyed",
|
||||
}
|
||||
|
||||
COLUMN_WIDTHS = {
|
||||
"Site": 14,
|
||||
"Med ID": 10,
|
||||
"Lot No.": 12,
|
||||
"Orig Exp Date": 16,
|
||||
"Exp Date": 14,
|
||||
"Rcv Date": 14,
|
||||
"Rcpt User": 22,
|
||||
"Subject ID": 14,
|
||||
"Qty Asgn": 9,
|
||||
"IRT Tx": 8,
|
||||
"Date Asgn": 14,
|
||||
"Asgn User": 20,
|
||||
"Disp Status": 16,
|
||||
"Disp Date": 14,
|
||||
"Qty Disp": 9,
|
||||
"Disp User": 20,
|
||||
"Qty Ret": 10,
|
||||
"Date Ret": 14,
|
||||
"Ret User": 18,
|
||||
"Destroyed": 14,
|
||||
"Basket No.": 12,
|
||||
}
|
||||
|
||||
# ── 1. Load with pandas and convert date columns ─────────────────────────────
|
||||
df = pd.read_excel(INPUT_FILE)
|
||||
df.rename(columns=COLUMN_RENAMES, inplace=True)
|
||||
|
||||
for col in DATE_COLUMNS:
|
||||
if col in df.columns:
|
||||
df[col] = pd.to_datetime(df[col], dayfirst=True, errors="coerce")
|
||||
|
||||
df.sort_values(["Site", "Rcv Date", "Med ID"], inplace=True, ignore_index=True)
|
||||
df.to_excel(OUTPUT_FILE, index=False, sheet_name=SHEET_NAME)
|
||||
|
||||
# ── 2. Format with openpyxl ───────────────────────────────────────────────────
|
||||
wb = load_workbook(OUTPUT_FILE)
|
||||
ws = wb[SHEET_NAME]
|
||||
|
||||
header_fill = PatternFill("solid", start_color="1F4E79")
|
||||
header_font = Font(bold=True, color="FFFFFF", name="Arial", size=10)
|
||||
new_col_fill = PatternFill("solid", start_color="E2EFDA")
|
||||
row_font = Font(name="Arial", size=10)
|
||||
|
||||
thin = Side(style="thin", color="000000")
|
||||
border = Border(left=thin, right=thin, top=thin, bottom=thin)
|
||||
|
||||
headers = [cell.value for cell in ws[1]]
|
||||
new_cols = {"Destroyed", "Basket No."}
|
||||
|
||||
# Header row
|
||||
for cell in ws[1]:
|
||||
cell.fill = header_fill
|
||||
cell.font = header_font
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=False)
|
||||
cell.border = border
|
||||
|
||||
# Data rows
|
||||
max_row = ws.max_row
|
||||
for row in ws.iter_rows(min_row=2, max_row=max_row):
|
||||
for cell in row:
|
||||
col_name = headers[cell.column - 1] if cell.column <= len(headers) else None
|
||||
cell.font = row_font
|
||||
cell.border = border
|
||||
cell.alignment = Alignment(horizontal="center")
|
||||
if col_name in DATE_COLUMNS:
|
||||
cell.number_format = "DD-MMM-YYYY"
|
||||
if col_name in new_cols:
|
||||
cell.fill = new_col_fill
|
||||
|
||||
# Column widths
|
||||
for cell in ws[1]:
|
||||
width = COLUMN_WIDTHS.get(cell.value, 14)
|
||||
ws.column_dimensions[get_column_letter(cell.column)].width = width
|
||||
|
||||
ws.auto_filter.ref = ws.dimensions
|
||||
ws.freeze_panes = "A2"
|
||||
|
||||
wb.save(OUTPUT_FILE)
|
||||
print(f"Saved: {OUTPUT_FILE} ({max_row - 1} rows, sheet '{SHEET_NAME}')")
|
||||
@@ -0,0 +1,74 @@
|
||||
from playwright.sync_api import sync_playwright
|
||||
import json
|
||||
|
||||
# ── CONFIG ──────────────────────────────────────────────────────────────────
|
||||
BASE_URL = "https://janssen.4gclinical.com"
|
||||
STUDY = "42847922MDD3003"
|
||||
|
||||
EMAIL = "vbuzalka@its.jnj.com"
|
||||
PASSWORD = "Vlado123++-" # doplň heslo
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def list_reports():
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=False)
|
||||
page = browser.new_page()
|
||||
|
||||
# Přihlášení
|
||||
page.goto(BASE_URL)
|
||||
page.wait_for_load_state("networkidle")
|
||||
|
||||
page.get_by_label("Email *").fill(EMAIL)
|
||||
page.get_by_label("Password *").fill(PASSWORD)
|
||||
page.locator('#login__submit').click()
|
||||
page.wait_for_load_state("networkidle")
|
||||
|
||||
# Výběr studie — klikni na dropdown, vyber studii, klikni SELECT
|
||||
page.get_by_label("Study *").click()
|
||||
page.get_by_role("option", name=STUDY).click()
|
||||
page.get_by_role("button", name="SELECT").click()
|
||||
page.wait_for_load_state("networkidle")
|
||||
|
||||
# Přejdi na seznam reportů
|
||||
page.goto(f"{BASE_URL}/reports")
|
||||
page.wait_for_load_state("networkidle")
|
||||
page.wait_for_selector('[role="gridcell"] a', timeout=15000)
|
||||
|
||||
# Získej názvy reportů
|
||||
names = page.evaluate("""
|
||||
() => Array.from(document.querySelectorAll('[role="gridcell"] a'))
|
||||
.map(a => a.innerText.trim())
|
||||
.filter(n => n)
|
||||
""")
|
||||
print(f"\nNalezeno {len(names)} reportů, zjišťuji URL...\n")
|
||||
|
||||
# Pro každý report klikni, zaznamenej URL a vrať se zpět
|
||||
reports = []
|
||||
for name in names:
|
||||
with page.expect_navigation(timeout=15000):
|
||||
page.locator('[role="gridcell"] a').filter(has_text=name).click()
|
||||
page.wait_for_load_state("networkidle")
|
||||
page.wait_for_timeout(2000)
|
||||
path = page.url.replace(BASE_URL, "")
|
||||
reports.append({"name": name, "href": path})
|
||||
print(f" {name:50s} {path}")
|
||||
# Průběžné uložení po každém reportu
|
||||
with open("reports.json", "w", encoding="utf-8") as f:
|
||||
json.dump(reports, f, ensure_ascii=False, indent=2)
|
||||
if page.url != f"{BASE_URL}/reports":
|
||||
page.goto(f"{BASE_URL}/reports")
|
||||
page.wait_for_load_state("networkidle")
|
||||
page.wait_for_timeout(2000)
|
||||
page.wait_for_selector('[role="gridcell"] a', timeout=30000)
|
||||
|
||||
browser.close()
|
||||
|
||||
with open("reports.json", "w", encoding="utf-8") as f:
|
||||
json.dump(reports, f, ensure_ascii=False, indent=2)
|
||||
print(f"\nUloženo do reports.json")
|
||||
|
||||
return reports
|
||||
|
||||
|
||||
list_reports()
|
||||
@@ -0,0 +1,92 @@
|
||||
import pandas as pd
|
||||
from openpyxl import load_workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
SOURCE_FILE = "accountability_combined.xlsx"
|
||||
OUTPUT_FILE = "sheet_assigned_not_dispensed.xlsx"
|
||||
SHEET_NAME = "Assigned not dispensed"
|
||||
|
||||
DATE_COLUMNS = {
|
||||
"Orig Exp Date", "Exp Date", "Rcv Date",
|
||||
"Date Asgn", "Disp Date", "Date Ret", "Destroyed",
|
||||
}
|
||||
|
||||
COLUMN_WIDTHS = {
|
||||
"Site": 14,
|
||||
"Med ID": 10,
|
||||
"Lot No.": 12,
|
||||
"Orig Exp Date": 16,
|
||||
"Exp Date": 14,
|
||||
"Rcv Date": 14,
|
||||
"Rcpt User": 22,
|
||||
"Subject ID": 14,
|
||||
"Qty Asgn": 9,
|
||||
"IRT Tx": 8,
|
||||
"Date Asgn": 14,
|
||||
"Asgn User": 20,
|
||||
"Disp Status": 16,
|
||||
"Disp Date": 14,
|
||||
"Qty Disp": 9,
|
||||
"Disp User": 20,
|
||||
"Qty Ret": 10,
|
||||
"Date Ret": 14,
|
||||
"Ret User": 18,
|
||||
"Destroyed": 14,
|
||||
"Basket No.": 12,
|
||||
}
|
||||
|
||||
df = pd.read_excel(SOURCE_FILE)
|
||||
|
||||
for col in DATE_COLUMNS:
|
||||
if col in df.columns:
|
||||
df[col] = pd.to_datetime(df[col], errors="coerce")
|
||||
|
||||
# Filter: Subject ID present AND Disp Date missing
|
||||
mask = df["Subject ID"].notna() & df["Disp Date"].isna()
|
||||
filtered = df[mask].copy().reset_index(drop=True)
|
||||
|
||||
print(f"Assigned not dispensed: {len(filtered)}")
|
||||
|
||||
filtered.to_excel(OUTPUT_FILE, index=False, sheet_name=SHEET_NAME)
|
||||
|
||||
# Formatting
|
||||
wb = load_workbook(OUTPUT_FILE)
|
||||
ws = wb[SHEET_NAME]
|
||||
|
||||
header_fill = PatternFill("solid", start_color="833C00") # dark orange
|
||||
header_font = Font(bold=True, color="FFFFFF", name="Arial", size=10)
|
||||
row_font = Font(name="Arial", size=10)
|
||||
subj_fill = PatternFill("solid", start_color="FFF2CC") # light yellow highlight for Subject ID
|
||||
|
||||
thin = Side(style="thin", color="000000")
|
||||
border = Border(left=thin, right=thin, top=thin, bottom=thin)
|
||||
|
||||
headers = [cell.value for cell in ws[1]]
|
||||
|
||||
for cell in ws[1]:
|
||||
cell.fill = header_fill
|
||||
cell.font = header_font
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=False)
|
||||
cell.border = border
|
||||
|
||||
for row in ws.iter_rows(min_row=2, max_row=ws.max_row):
|
||||
for cell in row:
|
||||
col_name = headers[cell.column - 1] if cell.column <= len(headers) else None
|
||||
cell.font = row_font
|
||||
cell.border = border
|
||||
cell.alignment = Alignment(horizontal="center")
|
||||
if col_name in DATE_COLUMNS:
|
||||
cell.number_format = "DD-MMM-YYYY"
|
||||
if col_name == "Subject ID":
|
||||
cell.fill = subj_fill
|
||||
|
||||
for cell in ws[1]:
|
||||
width = COLUMN_WIDTHS.get(cell.value, 14)
|
||||
ws.column_dimensions[get_column_letter(cell.column)].width = width
|
||||
|
||||
ws.auto_filter.ref = ws.dimensions
|
||||
ws.freeze_panes = "A2"
|
||||
|
||||
wb.save(OUTPUT_FILE)
|
||||
print(f"Saved: {OUTPUT_FILE} (sheet: '{SHEET_NAME}')")
|
||||
@@ -0,0 +1,97 @@
|
||||
import pandas as pd
|
||||
from datetime import date
|
||||
from openpyxl import load_workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
SOURCE_FILE = "accountability_combined.xlsx"
|
||||
OUTPUT_FILE = "sheet_expired.xlsx"
|
||||
|
||||
DATE_COLUMNS = {
|
||||
"Orig Exp Date", "Exp Date", "Rcv Date",
|
||||
"Date Asgn", "Disp Date", "Date Ret", "Destroyed",
|
||||
}
|
||||
|
||||
COLUMN_WIDTHS = {
|
||||
"Site": 14,
|
||||
"Med ID": 10,
|
||||
"Lot No.": 12,
|
||||
"Orig Exp Date": 16,
|
||||
"Exp Date": 14,
|
||||
"Rcv Date": 14,
|
||||
"Rcpt User": 22,
|
||||
"Subject ID": 14,
|
||||
"Qty Asgn": 9,
|
||||
"IRT Tx": 8,
|
||||
"Date Asgn": 14,
|
||||
"Asgn User": 20,
|
||||
"Disp Status": 16,
|
||||
"Disp Date": 14,
|
||||
"Qty Disp": 9,
|
||||
"Disp User": 20,
|
||||
"Qty Ret": 10,
|
||||
"Date Ret": 14,
|
||||
"Ret User": 18,
|
||||
"Destroyed": 14,
|
||||
"Basket No.": 12,
|
||||
}
|
||||
|
||||
today = date.today()
|
||||
sheet_name = f"Expired as of {today.strftime('%d-%b-%Y')}"
|
||||
|
||||
# Load source
|
||||
df = pd.read_excel(SOURCE_FILE)
|
||||
|
||||
# Convert date columns
|
||||
for col in DATE_COLUMNS:
|
||||
if col in df.columns:
|
||||
df[col] = pd.to_datetime(df[col], errors="coerce")
|
||||
|
||||
# Filter: not in basket AND not assigned to patient AND Exp Date < today
|
||||
mask = df["Basket No."].isna() & df["Subject ID"].isna() & (df["Exp Date"] < pd.Timestamp(today))
|
||||
filtered = df[mask].copy().reset_index(drop=True)
|
||||
|
||||
print(f"Expired kits not in basket: {len(filtered)}")
|
||||
|
||||
filtered.to_excel(OUTPUT_FILE, index=False, sheet_name=sheet_name)
|
||||
|
||||
# Formatting
|
||||
wb = load_workbook(OUTPUT_FILE)
|
||||
ws = wb[sheet_name]
|
||||
|
||||
header_fill = PatternFill("solid", start_color="C00000") # dark red
|
||||
header_font = Font(bold=True, color="FFFFFF", name="Arial", size=10)
|
||||
row_font = Font(name="Arial", size=10)
|
||||
exp_fill = PatternFill("solid", start_color="FFE0E0") # light red highlight for Exp Date
|
||||
|
||||
thin = Side(style="thin", color="000000")
|
||||
border = Border(left=thin, right=thin, top=thin, bottom=thin)
|
||||
|
||||
headers = [cell.value for cell in ws[1]]
|
||||
|
||||
for cell in ws[1]:
|
||||
cell.fill = header_fill
|
||||
cell.font = header_font
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=False)
|
||||
cell.border = border
|
||||
|
||||
for row in ws.iter_rows(min_row=2, max_row=ws.max_row):
|
||||
for cell in row:
|
||||
col_name = headers[cell.column - 1] if cell.column <= len(headers) else None
|
||||
cell.font = row_font
|
||||
cell.border = border
|
||||
cell.alignment = Alignment(horizontal="center")
|
||||
if col_name in DATE_COLUMNS:
|
||||
cell.number_format = "DD-MMM-YYYY"
|
||||
if col_name == "Exp Date":
|
||||
cell.fill = exp_fill
|
||||
|
||||
for cell in ws[1]:
|
||||
width = COLUMN_WIDTHS.get(cell.value, 14)
|
||||
ws.column_dimensions[get_column_letter(cell.column)].width = width
|
||||
|
||||
ws.auto_filter.ref = ws.dimensions
|
||||
ws.freeze_panes = "A2"
|
||||
|
||||
wb.save(OUTPUT_FILE)
|
||||
print(f"Saved: {OUTPUT_FILE} (sheet: '{sheet_name}')")
|
||||
@@ -0,0 +1,99 @@
|
||||
import pandas as pd
|
||||
from openpyxl import load_workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
SOURCE_FILE = "accountability_combined.xlsx"
|
||||
OUTPUT_FILE = "sheet_kits_for_destruction.xlsx"
|
||||
SHEET_NAME = "Kits for destruction"
|
||||
|
||||
DATE_COLUMNS = {
|
||||
"Orig Exp Date", "Exp Date", "Rcv Date",
|
||||
"Date Asgn", "Disp Date", "Date Ret", "Destroyed",
|
||||
}
|
||||
|
||||
COLUMN_WIDTHS = {
|
||||
"Site": 14,
|
||||
"Med ID": 10,
|
||||
"Lot No.": 12,
|
||||
"Orig Exp Date": 16,
|
||||
"Exp Date": 14,
|
||||
"Rcv Date": 14,
|
||||
"Rcpt User": 22,
|
||||
"Subject ID": 14,
|
||||
"Qty Asgn": 9,
|
||||
"IRT Tx": 8,
|
||||
"Date Asgn": 14,
|
||||
"Asgn User": 20,
|
||||
"Disp Status": 16,
|
||||
"Disp Date": 14,
|
||||
"Qty Disp": 9,
|
||||
"Disp User": 20,
|
||||
"Qty Ret": 10,
|
||||
"Date Ret": 14,
|
||||
"Ret User": 18,
|
||||
"Destroyed": 14,
|
||||
"Basket No.": 12,
|
||||
}
|
||||
|
||||
df = pd.read_excel(SOURCE_FILE)
|
||||
|
||||
for col in DATE_COLUMNS:
|
||||
if col in df.columns:
|
||||
df[col] = pd.to_datetime(df[col], errors="coerce")
|
||||
|
||||
# Filter: no basket AND (Date Ret filled OR Disp Status == NOT DISPENSED)
|
||||
mask = (
|
||||
df["Basket No."].isna() &
|
||||
(
|
||||
df["Date Ret"].notna() |
|
||||
(df["Disp Status"].str.upper() == "NOT DISPENSED")
|
||||
)
|
||||
)
|
||||
filtered = df[mask].copy().sort_values(["Site", "Date Ret"], ascending=[True, True])
|
||||
filtered = filtered.drop(columns=["Destroyed", "Basket No."]).reset_index(drop=True)
|
||||
|
||||
print(f"Kits for destruction: {len(filtered)}")
|
||||
|
||||
filtered.to_excel(OUTPUT_FILE, index=False, sheet_name=SHEET_NAME)
|
||||
|
||||
# Formatting
|
||||
wb = load_workbook(OUTPUT_FILE)
|
||||
ws = wb[SHEET_NAME]
|
||||
|
||||
header_fill = PatternFill("solid", start_color="595959") # dark grey
|
||||
header_font = Font(bold=True, color="FFFFFF", name="Arial", size=10)
|
||||
row_font = Font(name="Arial", size=10)
|
||||
basket_fill = PatternFill("solid", start_color="FFE0E0") # light red for empty Basket No.
|
||||
|
||||
thin = Side(style="thin", color="000000")
|
||||
border = Border(left=thin, right=thin, top=thin, bottom=thin)
|
||||
|
||||
headers = [cell.value for cell in ws[1]]
|
||||
|
||||
for cell in ws[1]:
|
||||
cell.fill = header_fill
|
||||
cell.font = header_font
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=False)
|
||||
cell.border = border
|
||||
|
||||
for row in ws.iter_rows(min_row=2, max_row=ws.max_row):
|
||||
for cell in row:
|
||||
col_name = headers[cell.column - 1] if cell.column <= len(headers) else None
|
||||
cell.font = row_font
|
||||
cell.border = border
|
||||
cell.alignment = Alignment(horizontal="center")
|
||||
if col_name in DATE_COLUMNS:
|
||||
cell.number_format = "DD-MMM-YYYY"
|
||||
if col_name == "Basket No.":
|
||||
cell.fill = basket_fill
|
||||
|
||||
for cell in ws[1]:
|
||||
width = COLUMN_WIDTHS.get(cell.value, 14)
|
||||
ws.column_dimensions[get_column_letter(cell.column)].width = width
|
||||
|
||||
ws.auto_filter.ref = ws.dimensions
|
||||
ws.freeze_panes = "A2"
|
||||
|
||||
wb.save(OUTPUT_FILE)
|
||||
print(f"Saved: {OUTPUT_FILE} (sheet: '{SHEET_NAME}')")
|
||||
@@ -0,0 +1,102 @@
|
||||
import pandas as pd
|
||||
from openpyxl import load_workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
SOURCE_FILE = "accountability_combined.xlsx"
|
||||
OUTPUT_FILE = "sheet_not_returned.xlsx"
|
||||
SHEET_NAME = "Not returned"
|
||||
|
||||
DATE_COLUMNS = {
|
||||
"Orig Exp Date", "Exp Date", "Rcv Date",
|
||||
"Date Asgn", "Disp Date", "Max Visit Date",
|
||||
}
|
||||
|
||||
COLUMN_WIDTHS = {
|
||||
"Site": 14,
|
||||
"Med ID": 10,
|
||||
"Lot No.": 12,
|
||||
"Orig Exp Date": 16,
|
||||
"Exp Date": 14,
|
||||
"Rcv Date": 14,
|
||||
"Rcpt User": 22,
|
||||
"Subject ID": 14,
|
||||
"Qty Asgn": 9,
|
||||
"IRT Tx": 8,
|
||||
"Date Asgn": 14,
|
||||
"Asgn User": 20,
|
||||
"Disp Status": 16,
|
||||
"Disp Date": 14,
|
||||
"Qty Disp": 9,
|
||||
"Disp User": 20,
|
||||
"Max Visit Date": 16,
|
||||
}
|
||||
|
||||
df = pd.read_excel(SOURCE_FILE)
|
||||
|
||||
for col in DATE_COLUMNS:
|
||||
if col in df.columns:
|
||||
df[col] = pd.to_datetime(df[col], errors="coerce")
|
||||
|
||||
# Kits with no return date, assigned to a patient, and not "NOT DISPENSED"
|
||||
no_ret = df[
|
||||
df["Date Ret"].isna() &
|
||||
df["Subject ID"].notna() &
|
||||
(df["Disp Status"].str.upper() != "NOT DISPENSED")
|
||||
].copy()
|
||||
|
||||
# Max Date Asgn per patient (from full dataset)
|
||||
max_asgn = df.groupby("Subject ID")["Date Asgn"].max().rename("Max Visit Date")
|
||||
no_ret = no_ret.join(max_asgn, on="Subject ID")
|
||||
|
||||
# Keep only kits where Date Asgn is NOT the latest for that patient
|
||||
filtered = no_ret[no_ret["Date Asgn"] < no_ret["Max Visit Date"]].copy()
|
||||
|
||||
# Drop columns Q-U and keep Max Visit Date
|
||||
filtered = filtered.drop(columns=["Qty Ret", "Date Ret", "Ret User", "Destroyed", "Basket No."])
|
||||
filtered = filtered.reset_index(drop=True)
|
||||
|
||||
print(f"Not returned kits: {len(filtered)}")
|
||||
|
||||
filtered.to_excel(OUTPUT_FILE, index=False, sheet_name=SHEET_NAME)
|
||||
|
||||
# Formatting
|
||||
wb = load_workbook(OUTPUT_FILE)
|
||||
ws = wb[SHEET_NAME]
|
||||
|
||||
header_fill = PatternFill("solid", start_color="375623") # dark green
|
||||
header_font = Font(bold=True, color="FFFFFF", name="Arial", size=10)
|
||||
row_font = Font(name="Arial", size=10)
|
||||
ret_fill = PatternFill("solid", start_color="E2EFDA") # light green highlight for Date Ret
|
||||
|
||||
thin = Side(style="thin", color="000000")
|
||||
border = Border(left=thin, right=thin, top=thin, bottom=thin)
|
||||
|
||||
headers = [cell.value for cell in ws[1]]
|
||||
|
||||
for cell in ws[1]:
|
||||
cell.fill = header_fill
|
||||
cell.font = header_font
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=False)
|
||||
cell.border = border
|
||||
|
||||
for row in ws.iter_rows(min_row=2, max_row=ws.max_row):
|
||||
for cell in row:
|
||||
col_name = headers[cell.column - 1] if cell.column <= len(headers) else None
|
||||
cell.font = row_font
|
||||
cell.border = border
|
||||
cell.alignment = Alignment(horizontal="center")
|
||||
if col_name in DATE_COLUMNS:
|
||||
cell.number_format = "DD-MMM-YYYY"
|
||||
if col_name == "Max Visit Date":
|
||||
cell.fill = ret_fill
|
||||
|
||||
for cell in ws[1]:
|
||||
width = COLUMN_WIDTHS.get(cell.value, 14)
|
||||
ws.column_dimensions[get_column_letter(cell.column)].width = width
|
||||
|
||||
ws.auto_filter.ref = ws.dimensions
|
||||
ws.freeze_panes = "A2"
|
||||
|
||||
wb.save(OUTPUT_FILE)
|
||||
print(f"Saved: {OUTPUT_FILE} (sheet: '{SHEET_NAME}')")
|
||||
@@ -0,0 +1,257 @@
|
||||
import pandas as pd
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from openpyxl import load_workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
INVENTORY_DIR = Path("xls_reports")
|
||||
DESTRUCTION_DIR = Path("xls_ip_destruction")
|
||||
OUTPUT_DIR = Path("output")
|
||||
OUTPUT_FILE = OUTPUT_DIR / f"{date.today().strftime('%Y-%m-%d')} 42847922MDD3003 CZ IWRS overview.xlsx"
|
||||
|
||||
# ── Shared constants ──────────────────────────────────────────────────────────
|
||||
|
||||
COLUMN_RENAMES = {
|
||||
"Site": "Site",
|
||||
"Medication ID": "Med ID",
|
||||
"Packaged Lot number": "Lot No.",
|
||||
"Original Expiration Date when Packaged Lot was Added": "Orig Exp Date",
|
||||
"Expiration date": "Exp Date",
|
||||
"Received Date": "Rcv Date",
|
||||
"Shipment Receipt User": "Rcpt User",
|
||||
"Subject Identifier": "Subject ID",
|
||||
"Quantity Assigned": "Qty Asgn",
|
||||
"IRT Transaction": "IRT Tx",
|
||||
"Date Assigned": "Date Asgn",
|
||||
"Assignment User": "Asgn User",
|
||||
"Dispensation Status": "Disp Status",
|
||||
"Dispensing Date": "Disp Date",
|
||||
"Quantity Dispensed": "Qty Disp",
|
||||
"Dispensing User": "Disp User",
|
||||
"Quantity Returned": "Qty Ret",
|
||||
"Date Returned": "Date Ret",
|
||||
"Return User": "Ret User",
|
||||
"DestroyedOn": "Destroyed",
|
||||
"Basket number": "Basket No.",
|
||||
}
|
||||
|
||||
DATE_COLUMNS = {
|
||||
"Orig Exp Date", "Exp Date", "Rcv Date",
|
||||
"Date Asgn", "Disp Date", "Date Ret", "Destroyed", "Max Visit Date",
|
||||
}
|
||||
|
||||
COLUMN_WIDTHS = {
|
||||
"Site": 14,
|
||||
"Med ID": 10,
|
||||
"Lot No.": 12,
|
||||
"Orig Exp Date": 16,
|
||||
"Exp Date": 14,
|
||||
"Rcv Date": 14,
|
||||
"Rcpt User": 22,
|
||||
"Subject ID": 14,
|
||||
"Qty Asgn": 9,
|
||||
"IRT Tx": 8,
|
||||
"Date Asgn": 14,
|
||||
"Asgn User": 20,
|
||||
"Disp Status": 16,
|
||||
"Disp Date": 14,
|
||||
"Qty Disp": 9,
|
||||
"Disp User": 20,
|
||||
"Qty Ret": 10,
|
||||
"Date Ret": 14,
|
||||
"Ret User": 18,
|
||||
"Destroyed": 14,
|
||||
"Basket No.": 12,
|
||||
"Max Visit Date": 16,
|
||||
}
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def read_inventory(path):
|
||||
df = pd.read_excel(path, header=None)
|
||||
header_row = df[df[0] == "Medication ID"].index[0]
|
||||
data = pd.read_excel(path, header=header_row)
|
||||
meta = {}
|
||||
for i in range(header_row):
|
||||
val = str(df.iloc[i, 0]) if pd.notna(df.iloc[i, 0]) else ""
|
||||
if val.startswith("Site:"):
|
||||
meta["site"] = val.replace("Site:", "").strip()
|
||||
return data, meta
|
||||
|
||||
|
||||
def read_destruction_lookup():
|
||||
lookup = {}
|
||||
for path in DESTRUCTION_DIR.glob("*.xlsx"):
|
||||
df = pd.read_excel(path, header=None)
|
||||
basket_id = None
|
||||
destroyed_on = None
|
||||
for i in range(15):
|
||||
val = str(df.iloc[i, 0]) if pd.notna(df.iloc[i, 0]) else ""
|
||||
if val.startswith("Basket ID:"):
|
||||
basket_id = val.replace("Basket ID:", "").strip()
|
||||
if val.startswith("Drug Destruction Created Date:"):
|
||||
destroyed_on = val.replace("Drug Destruction Created Date:", "").strip()
|
||||
header_row = df[df[0] == "Medication ID Description"].index[0]
|
||||
data = pd.read_excel(path, header=header_row)
|
||||
for med_id in data["Medication ID"].dropna():
|
||||
lookup[int(med_id)] = (basket_id, destroyed_on)
|
||||
return lookup
|
||||
|
||||
|
||||
def format_sheet(ws, header_color, highlight_col=None, highlight_color=None):
|
||||
thin = Side(style="thin", color="000000")
|
||||
border = Border(left=thin, right=thin, top=thin, bottom=thin)
|
||||
header_fill = PatternFill("solid", start_color=header_color)
|
||||
header_font = Font(bold=True, color="FFFFFF", name="Arial", size=10)
|
||||
row_font = Font(name="Arial", size=10)
|
||||
hi_fill = PatternFill("solid", start_color=highlight_color) if highlight_color else None
|
||||
|
||||
headers = [cell.value for cell in ws[1]]
|
||||
|
||||
for cell in ws[1]:
|
||||
cell.fill = header_fill
|
||||
cell.font = header_font
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=False)
|
||||
cell.border = border
|
||||
|
||||
for row in ws.iter_rows(min_row=2, max_row=ws.max_row):
|
||||
for cell in row:
|
||||
col_name = headers[cell.column - 1] if cell.column <= len(headers) else None
|
||||
cell.font = row_font
|
||||
cell.border = border
|
||||
cell.alignment = Alignment(horizontal="center")
|
||||
if col_name in DATE_COLUMNS:
|
||||
cell.number_format = "DD-MMM-YYYY"
|
||||
if hi_fill and col_name == highlight_col:
|
||||
cell.fill = hi_fill
|
||||
|
||||
for cell in ws[1]:
|
||||
width = COLUMN_WIDTHS.get(cell.value, 14)
|
||||
ws.column_dimensions[get_column_letter(cell.column)].width = width
|
||||
|
||||
ws.auto_filter.ref = ws.dimensions
|
||||
ws.freeze_panes = "A2"
|
||||
|
||||
|
||||
# ── Build DataFrames ──────────────────────────────────────────────────────────
|
||||
|
||||
def build_main(lookup):
|
||||
all_rows = []
|
||||
for path in sorted(INVENTORY_DIR.glob("onsite_inventory_detail_*.xlsx")):
|
||||
df, meta = read_inventory(path)
|
||||
df["DestroyedOn"] = df["Medication ID"].apply(
|
||||
lambda x: lookup.get(int(x), (None, None))[1] if pd.notna(x) else None)
|
||||
df["Basket number"] = df["Medication ID"].apply(
|
||||
lambda x: lookup.get(int(x), (None, None))[0] if pd.notna(x) else None)
|
||||
df.insert(0, "Site", meta.get("site", path.stem))
|
||||
all_rows.append(df)
|
||||
print(f" {path.name}: {len(df)} kits")
|
||||
|
||||
combined = pd.concat(all_rows, ignore_index=True)
|
||||
combined.rename(columns=COLUMN_RENAMES, inplace=True)
|
||||
for col in DATE_COLUMNS:
|
||||
if col in combined.columns:
|
||||
combined[col] = pd.to_datetime(combined[col], dayfirst=True, errors="coerce")
|
||||
combined.sort_values(["Site", "Rcv Date", "Med ID"], inplace=True, ignore_index=True)
|
||||
return combined
|
||||
|
||||
|
||||
def build_expired(df):
|
||||
today = date.today()
|
||||
mask = (
|
||||
df["Basket No."].isna() &
|
||||
df["Subject ID"].isna() &
|
||||
(df["Exp Date"] < pd.Timestamp(today))
|
||||
)
|
||||
filtered = df[mask].copy().reset_index(drop=True)
|
||||
sheet_name = f"Expired as of {today.strftime('%d-%b-%Y')}"
|
||||
print(f" Expired: {len(filtered)}")
|
||||
return filtered, sheet_name
|
||||
|
||||
|
||||
def build_assigned_not_dispensed(df):
|
||||
mask = df["Subject ID"].notna() & df["Disp Date"].isna()
|
||||
filtered = df[mask].copy().reset_index(drop=True)
|
||||
print(f" Assigned not dispensed: {len(filtered)}")
|
||||
return filtered
|
||||
|
||||
|
||||
def build_not_returned(df):
|
||||
no_ret = df[
|
||||
df["Date Ret"].isna() &
|
||||
df["Subject ID"].notna() &
|
||||
(df["Disp Status"].str.upper() != "NOT DISPENSED")
|
||||
].copy()
|
||||
max_asgn = df.groupby("Subject ID")["Date Asgn"].max().rename("Max Visit Date")
|
||||
no_ret = no_ret.join(max_asgn, on="Subject ID")
|
||||
filtered = no_ret[no_ret["Date Asgn"] < no_ret["Max Visit Date"]].copy()
|
||||
filtered = filtered.drop(columns=["Qty Ret", "Date Ret", "Ret User", "Destroyed", "Basket No."])
|
||||
filtered = filtered.reset_index(drop=True)
|
||||
print(f" Not returned: {len(filtered)}")
|
||||
return filtered
|
||||
|
||||
|
||||
def build_kits_for_destruction(df):
|
||||
mask = (
|
||||
df["Basket No."].isna() &
|
||||
(df["Date Ret"].notna() | (df["Disp Status"].str.upper() == "NOT DISPENSED"))
|
||||
)
|
||||
filtered = df[mask].copy().sort_values(["Site", "Date Ret"], ascending=[True, True])
|
||||
filtered = filtered.drop(columns=["Destroyed", "Basket No."]).reset_index(drop=True)
|
||||
print(f" Kits for destruction: {len(filtered)}")
|
||||
return filtered
|
||||
|
||||
|
||||
# ── Main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
# Prepare output dir, remove any previous overview file
|
||||
OUTPUT_DIR.mkdir(exist_ok=True)
|
||||
for old in OUTPUT_DIR.glob("*42847922MDD3003 CZ IWRS overview.xlsx"):
|
||||
old.unlink()
|
||||
print(f"Removed old file: {old.name}")
|
||||
|
||||
lookup = read_destruction_lookup()
|
||||
print(f"Loaded {len(lookup)} kits from destruction reports")
|
||||
|
||||
df = build_main(lookup)
|
||||
|
||||
expired_df, expired_sheet = build_expired(df)
|
||||
assigned_df = build_assigned_not_dispensed(df)
|
||||
not_returned_df = build_not_returned(df)
|
||||
destruction_df = build_kits_for_destruction(df)
|
||||
|
||||
# Write all sheets
|
||||
with pd.ExcelWriter(OUTPUT_FILE, engine="openpyxl") as writer:
|
||||
df.to_excel( writer, index=False, sheet_name="CountryMedicationOverview")
|
||||
expired_df.to_excel( writer, index=False, sheet_name=expired_sheet)
|
||||
assigned_df.to_excel( writer, index=False, sheet_name="Assigned not dispensed")
|
||||
not_returned_df.to_excel(writer, index=False, sheet_name="Not returned")
|
||||
destruction_df.to_excel( writer, index=False, sheet_name="Kits for destruction")
|
||||
|
||||
# Format all sheets
|
||||
wb = load_workbook(OUTPUT_FILE)
|
||||
|
||||
# Main sheet — dark blue, green highlight for Destroyed/Basket No.
|
||||
ws_main = wb["CountryMedicationOverview"]
|
||||
format_sheet(ws_main, header_color="1F4E79")
|
||||
# Extra: green fill for Destroyed and Basket No. columns
|
||||
new_col_fill = PatternFill("solid", start_color="E2EFDA")
|
||||
headers_main = [c.value for c in ws_main[1]]
|
||||
for row in ws_main.iter_rows(min_row=2, max_row=ws_main.max_row):
|
||||
for cell in row:
|
||||
col_name = headers_main[cell.column - 1] if cell.column <= len(headers_main) else None
|
||||
if col_name in ("Destroyed", "Basket No."):
|
||||
cell.fill = new_col_fill
|
||||
|
||||
format_sheet(wb[expired_sheet], header_color="C00000", highlight_col="Exp Date", highlight_color="FFE0E0")
|
||||
format_sheet(wb["Assigned not dispensed"], header_color="833C00", highlight_col="Subject ID", highlight_color="FFF2CC")
|
||||
format_sheet(wb["Not returned"], header_color="375623", highlight_col="Max Visit Date", highlight_color="E2EFDA")
|
||||
format_sheet(wb["Kits for destruction"], header_color="595959")
|
||||
|
||||
wb.save(OUTPUT_FILE)
|
||||
print(f"\nSaved: {OUTPUT_FILE} ({len(df)} rows on main sheet, {wb.sheetnames})")
|
||||
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,81 @@
|
||||
from playwright.sync_api import sync_playwright
|
||||
import os
|
||||
|
||||
# ── CONFIG ──────────────────────────────────────────────────────────────────
|
||||
BASE_URL = "https://janssen.4gclinical.com"
|
||||
|
||||
EMAIL = "vbuzalka@its.jnj.com"
|
||||
PASSWORD = "Vlado123++-"
|
||||
|
||||
OUTPUT_DIR = "xls_ip_destruction"
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def download_ip_destruction():
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=False)
|
||||
context = browser.new_context(accept_downloads=True)
|
||||
page = context.new_page()
|
||||
|
||||
# Přihlášení
|
||||
page.goto(BASE_URL)
|
||||
page.wait_for_load_state("networkidle")
|
||||
page.get_by_label("Email *").fill(EMAIL)
|
||||
page.get_by_label("Password *").fill(PASSWORD)
|
||||
page.locator('#login__submit').click()
|
||||
page.wait_for_load_state("networkidle")
|
||||
|
||||
# Výběr studie
|
||||
page.get_by_label("Study *").click()
|
||||
page.get_by_role("option", name="42847922MDD3003").click()
|
||||
page.get_by_role("button", name="SELECT").click()
|
||||
page.wait_for_load_state("networkidle")
|
||||
|
||||
# Naviguj na report
|
||||
page.goto(f"{BASE_URL}/report/ip_destruction_form")
|
||||
page.wait_for_load_state("networkidle", timeout=15000)
|
||||
|
||||
# Přečti dostupné košíky
|
||||
page.locator('input[placeholder="search"], input[type="text"]').first.click()
|
||||
page.wait_for_timeout(1000)
|
||||
baskets = [b.strip() for b in page.locator('mat-option').all_inner_texts()]
|
||||
print(f"Nalezeno {len(baskets)} košíků: {baskets}")
|
||||
page.keyboard.press("Escape")
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
for basket in baskets:
|
||||
filename = os.path.join(OUTPUT_DIR, f"ip_destruction_basket_{basket}.xlsx")
|
||||
if os.path.exists(filename):
|
||||
print(f"[{basket}] Přeskakuji — soubor již existuje.")
|
||||
continue
|
||||
print(f"[{basket}] Stahuji...")
|
||||
|
||||
# Otevři dropdown a vyber košík přes dispatch_event
|
||||
input_field = page.locator('input[placeholder="search"], input[type="text"]').first
|
||||
input_field.click()
|
||||
input_field.fill(basket)
|
||||
page.wait_for_timeout(500)
|
||||
page.locator('mat-option').first.dispatch_event('click')
|
||||
|
||||
# Počkej na načtení dat
|
||||
page.wait_for_load_state("networkidle", timeout=30000)
|
||||
|
||||
# Stáhni XLS
|
||||
with page.expect_download(timeout=30000) as dl:
|
||||
page.get_by_role("button", name="Download XLS").click()
|
||||
|
||||
download = dl.value
|
||||
download.save_as(filename)
|
||||
print(f"[{basket}] Uloženo → {filename}")
|
||||
|
||||
# Reset pro další košík
|
||||
page.get_by_role("button", name="Clear").click()
|
||||
page.wait_for_load_state("networkidle", timeout=15000)
|
||||
|
||||
browser.close()
|
||||
print("\nHotovo!")
|
||||
|
||||
|
||||
download_ip_destruction()
|
||||
@@ -0,0 +1,76 @@
|
||||
from playwright.sync_api import sync_playwright
|
||||
import os
|
||||
|
||||
# ── CONFIG ──────────────────────────────────────────────────────────────────
|
||||
BASE_URL = "https://janssen.4gclinical.com"
|
||||
|
||||
EMAIL = "vbuzalka@its.jnj.com"
|
||||
PASSWORD = "Vlado123++-"
|
||||
|
||||
SITES = [
|
||||
"S10-CZ10002",
|
||||
"S10-CZ10004",
|
||||
"S10-CZ10005",
|
||||
"S10-CZ10008",
|
||||
"S10-CZ10011",
|
||||
"S10-CZ10012",
|
||||
]
|
||||
|
||||
OUTPUT_DIR = "xls_reports"
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def download_reports():
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=False)
|
||||
context = browser.new_context(accept_downloads=True)
|
||||
page = context.new_page()
|
||||
|
||||
# Přihlášení
|
||||
page.goto(BASE_URL)
|
||||
page.wait_for_load_state("networkidle")
|
||||
page.get_by_label("Email *").fill(EMAIL)
|
||||
page.get_by_label("Password *").fill(PASSWORD)
|
||||
page.locator('#login__submit').click()
|
||||
page.wait_for_load_state("networkidle")
|
||||
|
||||
# Výběr studie
|
||||
page.get_by_label("Study *").click()
|
||||
page.get_by_role("option", name="42847922MDD3003").click()
|
||||
page.get_by_role("button", name="SELECT").click()
|
||||
page.wait_for_load_state("networkidle")
|
||||
|
||||
# Naviguj na report stránku
|
||||
page.goto(f"{BASE_URL}/report/onsite_inventory_detail")
|
||||
page.wait_for_load_state("networkidle", timeout=15000)
|
||||
|
||||
for site_id in SITES:
|
||||
print(f"[{site_id}] Stahuji...")
|
||||
|
||||
# Otevři dropdown a vyber site
|
||||
page.locator('input[placeholder="search"], input[type="text"]').first.click()
|
||||
page.get_by_role("option", name=site_id).click()
|
||||
|
||||
# Počkej na dokončení načítání dat (síť se uklidní)
|
||||
page.wait_for_load_state("networkidle", timeout=30000)
|
||||
|
||||
# Stáhni XLS
|
||||
with page.expect_download(timeout=30000) as dl:
|
||||
page.get_by_role("button", name="Download XLS").click()
|
||||
|
||||
download = dl.value
|
||||
filename = os.path.join(OUTPUT_DIR, f"onsite_inventory_detail_{site_id}.xlsx")
|
||||
download.save_as(filename)
|
||||
print(f"[{site_id}] Uloženo → {filename}")
|
||||
|
||||
# Zruš výběr site pro další iteraci
|
||||
page.get_by_role("button", name="Clear").click()
|
||||
page.wait_for_load_state("networkidle", timeout=15000)
|
||||
|
||||
browser.close()
|
||||
print("\nHotovo! Všechny reporty staženy.")
|
||||
|
||||
|
||||
download_reports()
|
||||
Binary file not shown.
@@ -0,0 +1,23 @@
|
||||
[
|
||||
{"name": "Drug Accountability Form - Multiple Subjects", "href": "/report/drug_accountability_form_multiple_subjects"},
|
||||
{"name": "Drug Accountability Form - Single Subject", "href": "/report/drug_accountability_form_single_subject"},
|
||||
{"name": "Janssen Pharmaceuticals IP Destruction Form", "href": "/report/ip_destruction_form"},
|
||||
{"name": "On-Site Drug Inventory and Accountability Details Form", "href": "/report/onsite_inventory_detail"},
|
||||
{"name": "On-Site Drug Inventory Form", "href": "/report/onsite_drug_inventory_form"},
|
||||
{"name": "Location Summary Report", "href": "/report/country_summary_report"},
|
||||
{"name": "Site Detail Report", "href": "/report/site_detail_report"},
|
||||
{"name": "Study Sites Report", "href": "/report/study_sites_report"},
|
||||
{"name": "Site Inventory Detail Report", "href": "/report/site_inventory_detail"},
|
||||
{"name": "Site Inventory Summary Report", "href": "/report/site_inventory_summary"},
|
||||
{"name": "Subject Data Changes Report", "href": "/report/patient_data_changes_report"},
|
||||
{"name": "Subject Detail Report", "href": "/report/patient_detail_report"},
|
||||
{"name": "Subject Summary Report", "href": "/report/patient_summary_report"},
|
||||
{"name": "Subject Visit Summary Report", "href": "/report/patient_visit_summary"},
|
||||
{"name": "Shipment Details Report", "href": "/report/shipment_details_report"},
|
||||
{"name": "Shipments Report", "href": "/report/shipments_report"},
|
||||
{"name": "Cohort History Report", "href": "/report/cohort_history_report"},
|
||||
{"name": "Cohort Summary Report", "href": "/report/cohort_summary_report"},
|
||||
{"name": "Site Activations Report", "href": "/report/site_activation_pivot"},
|
||||
{"name": "User Login History", "href": "/report/user_logins"},
|
||||
{"name": "Users List", "href": "/report/users"}
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user