Skip to content

Instantly share code, notes, and snippets.

@haircut
Created December 10, 2025 02:15
Show Gist options
  • Select an option

  • Save haircut/e376d2cd902ca306d1789dd0b5b6fe8c to your computer and use it in GitHub Desktop.

Select an option

Save haircut/e376d2cd902ca306d1789dd0b5b6fe8c to your computer and use it in GitHub Desktop.
AxM Serial Number Deadline Checker - This script automates checking serial numbers in Apple Business/School Manager to determine if they have a device management enrollment deadline set.
#!/usr/bin/env python3
"""
AxM Serial Number Deadline Checker
This script automates checking serial numbers in Apple Business/School Manager
to determine if they have a device management enrollment deadline set.
Requirements:
pip install playwright pandas
playwright install chromium
Usage:
python3 check_axm_deadlines.py <csv_file>
Example:
python3 check_axm_deadlines.py not_migrated.csv
CSV Input:
- Must contain a 'hardware_serial' column with device serial numbers
CSV Output:
- Adds a 'deadline' column with one of:
- The deadline date (e.g., "December 12, 2025 at 10:00 AM")
- "FALSE" if no deadline is set
- "ERROR" if an error occurred
Notes:
- The script will pause after opening the browser for you to log in manually
- Progress is saved every 50 serials, so you can safely interrupt and resume
- If an error dialog appears, the script will dismiss it and log "ERROR"
- Defaults to Apple Business Manager (business.apple.com)
To use Apple School Manager, change AXM_URL to use "school.apple.com"
"""
import pandas as pd
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout
import sys
import time
import re
import argparse
# Configuration
# Change "business" to "school" for Apple School Manager
AXM_URL = "https://business.apple.com/#/main/devices"
SAVE_INTERVAL = 50 # Save progress every N serials
# Regex to extract deadline date from the message
# Example: "with an enrollment deadline of December 12, 2025 at 10:00 AM."
DEADLINE_PATTERN = re.compile(
r'enrollment deadline of ([A-Za-z]+ \d{1,2}, \d{4} at \d{1,2}:\d{2} [AP]M)',
re.IGNORECASE
)
def load_csv(filepath: str) -> pd.DataFrame:
"""Load the CSV file with serial numbers."""
df = pd.read_csv(filepath)
if "hardware_serial" not in df.columns:
print(f"Error: CSV must contain 'hardware_serial' column")
print(f"Found columns: {list(df.columns)}")
sys.exit(1)
# Add deadline column if it doesn't exist, or convert to object dtype
# This column will contain: the deadline date string, "FALSE", or "ERROR"
if "deadline" not in df.columns:
df["deadline"] = pd.Series([None] * len(df), dtype=object)
else:
# Convert existing column to object dtype to avoid warnings
df["deadline"] = df["deadline"].astype(object)
return df
def save_csv(df: pd.DataFrame, filepath: str):
"""Save the DataFrame back to CSV."""
df.to_csv(filepath, index=False)
print(f"Progress saved to {filepath}")
def check_and_dismiss_error_dialog(frame, debug: bool = False) -> bool:
"""
Check for AxM error dialog and dismiss it if present.
Returns True if an error dialog was found and dismissed, False otherwise.
"""
try:
# Look for error dialog text
error_text = frame.locator('text=/Apple (Business|School) Manager has encountered an error/i')
if error_text.is_visible(timeout=500):
if debug:
print(f"\n Detected error dialog, clicking Close...")
# Find and click the Close button
close_button = frame.locator('button:has-text("Close")')
if close_button.is_visible(timeout=1000):
close_button.click()
time.sleep(0.5)
return True
except:
pass
return False
def find_search_frame(page, debug: bool = False):
"""
Find the frame containing the search input.
AxM uses iframes, so we need to find the right frame context.
"""
# First check the main page
try:
locator = page.locator('input[placeholder="Search"]').first
if locator.is_visible(timeout=1000):
if debug:
print("\n Found search in main page")
return page
except:
pass
# Check all frames
for frame in page.frames:
try:
locator = frame.locator('input[placeholder="Search"]').first
if locator.is_visible(timeout=1000):
if debug:
print(f"\n Found search in frame: {frame.url[:80]}...")
return frame
except:
continue
return None
def check_serial_deadline(page, serial: str, debug: bool = False) -> str:
"""
Search for a serial number and check if it has an enrollment deadline.
Returns:
- The deadline date string (e.g., "December 12, 2025 at 10:00 AM") if found
- "FALSE" if no deadline is set
- "ERROR" if an error occurred
"""
try:
# Find the frame containing the search input
frame = find_search_frame(page, debug)
if frame is None:
print(f"\n ERROR: Could not find search input in any frame. Taking debug screenshot...")
page.screenshot(path="debug_no_search.png")
print(f" Screenshot saved to debug_no_search.png")
print(f" Available frames: {[f.url for f in page.frames]}")
return "ERROR"
# Check for and dismiss any error dialog first
if check_and_dismiss_error_dialog(frame, debug):
time.sleep(0.5)
# Find the search input in the frame
search_input = frame.locator('input[placeholder="Search"]').first
# Click to focus
search_input.click()
time.sleep(0.3)
# Clear existing text - use the clear button if available, otherwise select all + delete
try:
clear_button = frame.locator('button[aria-label="Clear search"]')
if clear_button.is_visible(timeout=500):
clear_button.click()
time.sleep(0.2)
except:
# Fallback: select all and delete
search_input.press("Meta+a")
search_input.press("Backspace")
time.sleep(0.2)
# Type the serial number
search_input.fill(serial)
time.sleep(0.3)
# Press Enter to search
search_input.press("Enter")
# Wait for search results to appear
time.sleep(1.5)
# Check for error dialog after search
if check_and_dismiss_error_dialog(frame, debug):
return "ERROR"
# Click on the first search result in the list
try:
# Look for the first list item in the search results
first_result = frame.locator('li[role="option"]').first
# Wait for it to be visible
first_result.wait_for(state="visible", timeout=5000)
if debug:
print(f"\n Found search result, clicking...")
# Click to load the device details
first_result.click()
except PlaywrightTimeout:
# Check if there was an error dialog
if check_and_dismiss_error_dialog(frame, debug):
return "ERROR"
if debug:
print(f"\n No search results found for serial {serial}")
# No results found - this serial might not exist in AxM
return "FALSE"
except Exception as e:
if debug:
print(f"\n Error clicking search result: {e}")
return "ERROR"
# Wait for the details panel to load
time.sleep(2)
# Try to wait for network to settle
try:
frame.wait_for_load_state("networkidle", timeout=10000)
except:
pass
# Give a bit more time for dynamic content to render
time.sleep(1)
# Check for error dialog after loading details
if check_and_dismiss_error_dialog(frame, debug):
return "ERROR"
# Get the frame content
frame_content = frame.content()
# Check if the deadline text is present
if "Device Management Service Assigned" in frame_content and "enrollment deadline" in frame_content:
# Extract the actual deadline date
match = DEADLINE_PATTERN.search(frame_content)
if match:
deadline_date = match.group(1)
if debug:
print(f"\n Found deadline: {deadline_date}")
return deadline_date
else:
if debug:
print(f"\n Found deadline text but couldn't extract date")
# Deadline exists but couldn't parse the date - return a generic indicator
return "DEADLINE_SET"
else:
if debug:
print(f"\n No deadline text found.")
return "FALSE"
except PlaywrightTimeout as e:
print(f"\n Timeout while checking serial {serial}: {e}")
# Check for error dialog on timeout
try:
frame = find_search_frame(page, debug=False)
if frame and check_and_dismiss_error_dialog(frame, debug):
return "ERROR"
except:
pass
page.screenshot(path=f"debug_timeout_{serial}.png")
return "ERROR"
except Exception as e:
print(f"\n Error checking serial {serial}: {e}")
page.screenshot(path=f"debug_error_{serial}.png")
return "ERROR"
def parse_args():
"""Parse command line arguments."""
parser = argparse.ArgumentParser(
description="Check serial numbers in Apple Business/School Manager for enrollment deadlines."
)
parser.add_argument(
"csv_file",
help="Path to CSV file containing serial numbers (must have 'hardware_serial' column)"
)
return parser.parse_args()
def main():
args = parse_args()
csv_file = args.csv_file
print("=" * 60)
print("AxM Serial Number Deadline Checker")
print("=" * 60)
# Load the CSV
print(f"\nLoading {csv_file}...")
df = load_csv(csv_file)
total_serials = len(df)
print(f"Found {total_serials} serial numbers")
# Count how many are already checked (not null/empty)
already_checked = df["deadline"].notna().sum()
# Also count empty strings as unchecked
already_checked = (df["deadline"].notna() & (df["deadline"] != "")).sum()
if already_checked > 0:
print(f"Resuming: {already_checked} already checked, {total_serials - already_checked} remaining")
# Start Playwright
with sync_playwright() as p:
print("\nLaunching browser...")
browser = p.chromium.launch(headless=False) # Visible window
context = browser.new_context()
page = context.new_page()
# Navigate to AxM
print(f"Navigating to {AXM_URL}")
page.goto(AXM_URL)
# Wait for user to log in
print("\n" + "=" * 60)
print("MANUAL LOGIN REQUIRED")
print("=" * 60)
print("Please log in to Apple Business/School Manager in the browser window.")
print("Once you're logged in and can see the Devices page,")
input("press ENTER here to continue...")
print("=" * 60 + "\n")
# Process each serial
processed = 0
for idx, row in df.iterrows():
serial = row["hardware_serial"]
# Skip if already checked (not null and not empty string)
if pd.notna(row["deadline"]) and row["deadline"] != "":
continue
processed += 1
remaining = total_serials - already_checked - processed + 1
print(f"[{processed}/{total_serials - already_checked}] Checking {serial}... ", end="", flush=True)
# Enable debug mode for the first few serials to help troubleshoot
debug_mode = processed <= 3
deadline_result = check_serial_deadline(page, serial, debug=debug_mode)
df.at[idx, "deadline"] = deadline_result
# Display status
if deadline_result == "FALSE":
status = "✗ No deadline"
elif deadline_result == "ERROR":
status = "⚠ ERROR"
else:
status = f"✓ {deadline_result}"
print(status)
# Save progress periodically
if processed % SAVE_INTERVAL == 0:
print(f"\n--- Saving progress ({processed} checked) ---\n")
save_csv(df, csv_file)
# Close browser
browser.close()
# Final save
save_csv(df, csv_file)
# Print summary
print("\n" + "=" * 60)
print("SUMMARY")
print("=" * 60)
with_deadline = ((df["deadline"] != "FALSE") & (df["deadline"] != "ERROR") & df["deadline"].notna() & (df["deadline"] != "")).sum()
without_deadline = (df["deadline"] == "FALSE").sum()
errors = (df["deadline"] == "ERROR").sum()
print(f"Total serials checked: {total_serials}")
print(f"With enrollment deadline: {with_deadline}")
print(f"Without enrollment deadline: {without_deadline}")
print(f"Errors: {errors}")
print(f"\nResults saved to {csv_file}")
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment