Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
.env

# Cached report / employee data
data/

# External tools downloaded at runtime
repositories/

Expand Down
312 changes: 254 additions & 58 deletions simple_org_chart/app_main.py

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions simple_org_chart/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
LAST_LOGIN_FILE = DATA_DIR / "last_login_records.json"
RECENTLY_DISABLED_FILE = DATA_DIR / "recently_disabled_employees.json"
RECENTLY_HIRED_FILE = DATA_DIR / "recently_hired_employees.json"
MISSING_PHOTO_FILE = DATA_DIR / "missing_photo_records.json"


def ensure_directories() -> None:
Expand Down Expand Up @@ -61,6 +62,7 @@ def as_posix_env(mapping: Dict[str, Path]) -> Dict[str, str]:
"LAST_LOGIN_FILE",
"RECENTLY_DISABLED_FILE",
"RECENTLY_HIRED_FILE",
"MISSING_PHOTO_FILE",
"ensure_directories",
"as_posix_env",
]
58 changes: 49 additions & 9 deletions simple_org_chart/data_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import simple_org_chart.config as app_config
from simple_org_chart.msgraph import (
batch_check_photos,
calculate_days_since,
collect_disabled_users,
collect_last_login_records,
Expand Down Expand Up @@ -45,6 +46,7 @@
LAST_LOGIN_FILE = str(app_config.LAST_LOGIN_FILE)
FILTERED_LICENSE_FILE = str(app_config.FILTERED_LICENSE_FILE)
FILTERED_USERS_FILE = str(app_config.FILTERED_USERS_FILE)
MISSING_PHOTO_FILE = str(app_config.MISSING_PHOTO_FILE)
DATA_UPDATE_STATUS_FILE = os.path.join(DATA_DIR, 'data_update_status.json')

_DATA_UPDATE_STATUS_LOCK = threading.Lock()
Expand Down Expand Up @@ -231,6 +233,7 @@ def collect_recently_hired_employees(
'phone': employee.get('phone') or '',
'businessPhone': employee.get('businessPhone') or '',
'location': employee.get('location') or employee.get('officeLocation') or '',
'country': employee.get('country') or '',
'hireDate': datetime_to_iso(hire_date),
'daysSinceHire': calculate_days_since(hire_date),
'managerName': '',
Expand Down Expand Up @@ -323,9 +326,55 @@ def update_employee_data(source: str = 'unknown') -> None:
)

if employees:
# Check photos on ALL users (employees + filtered_users) so the
# "All" scope can show every user without a photo, regardless of
# org-chart visibility filters applied by fetch_all_employees.
try:
all_users = list(employees) + (filtered_users or [])
all_ids = [emp.get('id') for emp in all_users if emp.get('id')]
has_photo_ids = batch_check_photos(all_ids, token)
missing_photo_records = []
for emp in all_users:
uid = emp.get('id')
if uid and uid not in has_photo_ids:
missing_photo_records.append({
'id': uid,
'name': emp.get('name') or '',
'title': emp.get('title') or '',
'department': emp.get('department') or '',
'email': emp.get('email') or '',
'country': emp.get('country') or '',
'accountEnabled': emp.get('accountEnabled', True),
'userType': emp.get('userType') or '',
'licenseCount': emp.get('licenseCount', 0),
'licenseSkus': emp.get('licenseSkus', []),
'licenseSkuIds': emp.get('licenseSkuIds', []),
'mailboxType': emp.get('mailboxType'),
'isSharedMailbox': emp.get('isSharedMailbox'),
})
with open(MISSING_PHOTO_FILE, 'w') as report_file:
json.dump(missing_photo_records, report_file, indent=2)
logger.info(
f"Updated missing photo report cache with {len(missing_photo_records)} records"
)
except Exception as report_error:
logger.error(f"Failed to write missing photo report cache: {report_error}")

ignored_employee_set = parse_ignored_employees(settings)
ignored_department_set = parse_ignored_departments(settings)

# Collect recently hired from ALL users (before ignore filtering)
try:
all_users_for_hired = list(employees) + (filtered_users or [])
recently_hired_records = collect_recently_hired_employees(all_users_for_hired, days=365)
with open(RECENTLY_HIRED_FILE, 'w') as report_file:
json.dump(recently_hired_records, report_file, indent=2)
logger.info(
f"Updated recently hired employees report cache with {len(recently_hired_records)} records"
)
except Exception as report_error:
logger.error(f"Failed to write recently hired employees report cache: {report_error}")

if ignored_employee_set:
before = len(employees)
employees = [
Expand Down Expand Up @@ -421,15 +470,6 @@ def update_new_status(node):
else:
logger.error("Could not build hierarchy from employee data")

try:
recently_hired_records = collect_recently_hired_employees(employees, days=365)
with open(RECENTLY_HIRED_FILE, 'w') as report_file:
json.dump(recently_hired_records, report_file, indent=2)
logger.info(
f"Updated recently hired employees report cache with {len(recently_hired_records)} records"
)
except Exception as report_error:
logger.error(f"Failed to write recently hired employees report cache: {report_error}")
else:
logger.error("No employees fetched from Graph API")

Expand Down
1 change: 1 addition & 0 deletions simple_org_chart/hierarchy.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ def traverse(node):
'phone': emp.get('phone'),
'businessPhone': emp.get('businessPhone'),
'location': emp.get('location') or emp.get('officeLocation') or '',
'country': emp.get('country') or '',
'managerName': manager_name,
'reason': effective_reason,
'missingReason': reason,
Expand Down
63 changes: 62 additions & 1 deletion simple_org_chart/msgraph.py
Original file line number Diff line number Diff line change
Expand Up @@ -561,7 +561,7 @@ def collect_last_login_records(*, token: Optional[str] = None) -> list[dict]:

base_fields = (
"id,displayName,jobTitle,department,mail,userPrincipalName,"
"signInActivity,accountEnabled,userType,assignedLicenses"
"signInActivity,accountEnabled,userType,assignedLicenses,country"
)

def build_users_url(select_fields: str) -> str:
Expand Down Expand Up @@ -663,6 +663,7 @@ def _map_licenses(license_entries: Optional[Iterable[dict]]) -> Tuple[list[str],
"title": user.get("jobTitle") or "No Title",
"department": user.get("department") or "No Department",
"email": user.get("mail") or user.get("userPrincipalName") or "",
"country": user.get("country") or "",
"accountEnabled": user.get("accountEnabled", True),
"userType": (user.get("userType") or "").lower(),
"licenseCount": len(sku_ids),
Expand Down Expand Up @@ -914,7 +915,67 @@ def fetch_presence_by_user_ids(
return result


def batch_check_photos(user_ids: list[str], token: str) -> set[str]:
"""Check which users have profile photos using the Graph $batch API.

Returns a set of user IDs that DO have a photo. Uses batches of 20
(the Graph limit per $batch request).
"""
has_photo: set[str] = set()
if not user_ids or not token:
return has_photo

headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
batch_url = f"{GRAPH_API_ENDPOINT}/$batch"
chunk_size = 20

for start in range(0, len(user_ids), chunk_size):
chunk = user_ids[start : start + chunk_size]
batch_requests = []
for idx, uid in enumerate(chunk):
batch_requests.append({
"id": str(idx),
"method": "GET",
"url": f"/users/{uid}/photo",
})
resp = requests.post(
batch_url,
headers=headers,
json={"requests": batch_requests},
timeout=30,
)
if not resp.ok:
raise RuntimeError(
f"Batch photo check failed with status {resp.status_code}"
)
data = resp.json()
for item in data.get("responses", []):
raw_id = item.get("id")
try:
idx = int(raw_id)
except (TypeError, ValueError):
logger.warning("Batch photo check returned non-numeric response id: %s", raw_id)
continue
if not (0 <= idx < len(chunk)):
logger.warning("Batch photo check returned invalid response id: %s", raw_id)
continue
status = item.get("status")
if status == 200:
has_photo.add(chunk[idx])
elif status != 404:
raise RuntimeError(
f"Batch photo check returned unexpected status {status} "
f"for user {chunk[idx]}"
)

return has_photo


__all__ = [
"batch_check_photos",
"calculate_days_since",
"collect_disabled_licensed_users",
"collect_disabled_users",
Expand Down
93 changes: 93 additions & 0 deletions simple_org_chart/reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
LAST_LOGIN_FILE = str(app_config.LAST_LOGIN_FILE)
FILTERED_LICENSE_FILE = str(app_config.FILTERED_LICENSE_FILE)
FILTERED_USERS_FILE = str(app_config.FILTERED_USERS_FILE)
MISSING_PHOTO_FILE = str(app_config.MISSING_PHOTO_FILE)


class ReportCacheManager:
Expand Down Expand Up @@ -83,6 +84,14 @@ def load_missing_manager_data(cache: ReportCacheManager, *, force_refresh: bool
)


def load_missing_photo_data(cache: ReportCacheManager, *, force_refresh: bool = False):
return cache.load_json(
MISSING_PHOTO_FILE,
refresh=force_refresh,
description="missing photo report cache",
)


def load_disabled_license_data(cache: ReportCacheManager, *, force_refresh: bool = False):
return cache.load_json(
DISABLED_LICENSE_FILE,
Expand Down Expand Up @@ -386,19 +395,103 @@ def apply_missing_manager_filters(
)


def apply_missing_photo_filters(
records: Optional[Sequence[dict]],
*,
include_user_mailboxes: bool = True,
include_shared_mailboxes: bool = True,
include_room_equipment_mailboxes: bool = True,
include_enabled: bool = True,
include_disabled: bool = True,
include_licensed: bool = True,
include_unlicensed: bool = True,
include_members: bool = True,
include_guests: bool = True,
):
return apply_filtered_user_filters(
records,
include_user_mailboxes=include_user_mailboxes,
include_shared_mailboxes=include_shared_mailboxes,
include_room_equipment_mailboxes=include_room_equipment_mailboxes,
include_enabled=include_enabled,
include_disabled=include_disabled,
include_licensed=include_licensed,
include_unlicensed=include_unlicensed,
include_members=include_members,
include_guests=include_guests,
)


def apply_tagpicker_filters(
records: Sequence[dict],
*,
filter_titles: Optional[List[str]] = None,
filter_titles_mode: str = "exclude",
filter_departments: Optional[List[str]] = None,
filter_departments_mode: str = "exclude",
filter_countries: Optional[List[str]] = None,
filter_countries_mode: str = "exclude",
) -> List[dict]:
"""Apply optional title/department/country include/exclude filters."""
if not records:
return []

title_set = {v.strip().lower() for v in (filter_titles or []) if v and v.strip()}
dept_set = {v.strip().lower() for v in (filter_departments or []) if v and v.strip()}
country_set = {v.strip().lower() for v in (filter_countries or []) if v and v.strip()}

# Nothing to filter
if not title_set and not dept_set and not country_set:
return list(records)

filtered: List[dict] = []
for record in records:
title_val = (record.get("title") or "").strip().lower()
dept_val = (record.get("department") or "").strip().lower()
country_val = (record.get("country") or "").strip().lower()

if title_set:
matched = title_val in title_set
if filter_titles_mode == "include" and not matched:
continue
if filter_titles_mode == "exclude" and matched:
continue

if dept_set:
matched = dept_val in dept_set
if filter_departments_mode == "include" and not matched:
continue
if filter_departments_mode == "exclude" and matched:
continue

if country_set:
matched = country_val in country_set
if filter_countries_mode == "include" and not matched:
continue
if filter_countries_mode == "exclude" and matched:
continue

filtered.append(record)

return filtered


__all__ = [
"ReportCacheManager",
"apply_disabled_filters",
"apply_filtered_user_filters",
"apply_last_login_filters",
"apply_missing_manager_filters",
"apply_missing_photo_filters",
"apply_tagpicker_filters",
"calculate_license_totals",
"load_disabled_license_data",
"load_disabled_users_data",
"load_filtered_license_data",
"load_filtered_user_data",
"load_last_login_data",
"load_missing_manager_data",
"load_missing_photo_data",
"load_recently_disabled_data",
"load_recently_hired_data",
]
Loading
Loading