Created
December 10, 2025 02:15
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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