diff --git a/.gitignore b/.gitignore index d68e292..3ba8185 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,41 @@ html_test/ # Playwright MCP screenshots .playwright-mcp/ + +# CDL Onboarding Bot +scripts/onboarding/.env +scripts/onboarding/output/ +scripts/onboarding/*.json +*.credentials.json +service-account*.json + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +.pytest_cache/ +.coverage +htmlcov/ + +# Virtual environments +venv/ +ENV/ +env/ +.venv/ diff --git a/lab_manual.pdf b/lab_manual.pdf index 95b13ce..8ba3bca 100644 Binary files a/lab_manual.pdf and b/lab_manual.pdf differ diff --git a/lab_manual.tex b/lab_manual.tex index 2ad2e4f..be0f47f 100644 --- a/lab_manual.tex +++ b/lab_manual.tex @@ -828,7 +828,7 @@ \subsection{Authorship guidelines} an interface for asking questions, storing notes, and sharing ideas. If you have a~\ourschool~NetID you should be able to join the workspace by clicking the link. If you do \textit{not} have a NetID, you'll need to \href{https://services.dartmouth.edu/TDClient/1806/Portal/Requests/ServiceDet?ID=30581}{request one here}. - \marginnote{\texttt{TASK:} When you join our Slack workspace, initiate our onboarding process using the ``Join the lab!'' workflow. You can access this workflow in the \texttt{\#general} channel + \marginnote{\texttt{TASK:} When you join our Slack workspace, initiate our onboarding process using the \href{https://slack.com/shortcuts/Ft07ESC6CP3J/b1531b6fe100db8522fbd05c6a49ef63}{``Join the lab!'' workflow}. You can also access this workflow in the \texttt{\#general} channel through the Workflows menu (usually near the top left of the window). Once you initiate the workflow, you'll be guided through the onboarding process.} \item \href{https://www.github.com}{\textbf{GitHub.}} diff --git a/scripts/onboarding/.env.example b/scripts/onboarding/.env.example new file mode 100644 index 0000000..ce629c8 --- /dev/null +++ b/scripts/onboarding/.env.example @@ -0,0 +1,59 @@ +# CDL Onboarding Bot Configuration +# Copy this file to .env and fill in the values +# NEVER commit the actual .env file to the repository + +# ============================================================================= +# REQUIRED: Slack Configuration +# ============================================================================= + +# Slack Bot OAuth Token (starts with xoxb-) +# Get from: https://api.slack.com/apps > Your App > OAuth & Permissions +SLACK_BOT_TOKEN=xoxb-your-bot-token + +# Slack App-Level Token for Socket Mode (starts with xapp-) +# Get from: https://api.slack.com/apps > Your App > Basic Information > App-Level Tokens +SLACK_APP_TOKEN=xapp-your-app-token + +# Slack User ID of the admin (lab director) +# Find by clicking on profile > More > Copy member ID +SLACK_ADMIN_USER_ID=U12345678 + +# ============================================================================= +# REQUIRED: GitHub Configuration +# ============================================================================= + +# GitHub Personal Access Token with admin:org scope +# Create at: https://github.com/settings/tokens +# Required scopes: admin:org, repo (for team management) +GITHUB_TOKEN=ghp_your_token_here + +# GitHub Organization name (default: ContextLab) +GITHUB_ORG_NAME=ContextLab + +# Default team to add new members to +GITHUB_DEFAULT_TEAM=Lab default + +# ============================================================================= +# OPTIONAL: Google Calendar Configuration +# ============================================================================= + +# Path to Google service account credentials JSON file +# Create at: https://console.cloud.google.com/iam-admin/serviceaccounts +# The service account needs to be granted access to each calendar +GOOGLE_CREDENTIALS_FILE=/path/to/service-account.json + +# Calendar IDs (found in calendar settings > Integrate calendar) +GOOGLE_CALENDAR_CONTEXTUAL_DYNAMICS_LAB=primary@group.calendar.google.com +GOOGLE_CALENDAR_OUT_OF_LAB=outoflab@group.calendar.google.com +GOOGLE_CALENDAR_CDL_RESOURCES=resources@group.calendar.google.com + +# ============================================================================= +# OPTIONAL: Anthropic Configuration (for bio editing) +# ============================================================================= + +# Anthropic API key for Claude +# Get from: https://console.anthropic.com/account/keys +ANTHROPIC_API_KEY=sk-ant-your-key-here + +# Model to use (default: claude-sonnet-4-20250514) +ANTHROPIC_MODEL=claude-sonnet-4-20250514 diff --git a/scripts/onboarding/README.md b/scripts/onboarding/README.md new file mode 100644 index 0000000..e926df2 --- /dev/null +++ b/scripts/onboarding/README.md @@ -0,0 +1,226 @@ +# CDL Onboarding Bot + +A Slack bot for automating the onboarding and offboarding process for CDL lab members. + +## Features + +### Onboarding (Two Methods) + +**Method 1: Workflow Builder Integration (Recommended)** + +Integrates with existing "Join the lab!" and "Leave the lab" Slack Workflow Builder workflows. New members initiate their own onboarding by clicking a workflow link. + +- New member runs the "Join the lab!" workflow +- Bot receives form data and validates GitHub username +- Admin gets an interactive approval message +- On approval: GitHub invite sent, calendars shared, photo processed + +**Method 2: Admin-Initiated (`/cdl-onboard @user`)** + +Admin can also start onboarding manually: +- Sends welcome message to new member +- Collects: GitHub username, bio, photo, website URL +- Validates GitHub username via API +- Edits bio to CDL style (third person, 3-4 sentences) using Claude +- Adds hand-drawn green border to profile photo +- Sends GitHub organization invitation +- Shares Google Calendar access +- All actions require admin approval + +### Offboarding (`/cdl-offboard` or Workflow) +- Can be initiated by member or admin +- Works with "Leave the lab" Workflow Builder workflow +- Admin selects what access to revoke (GitHub, calendars) +- Does NOT automatically remove anyone +- Generates checklist for manual steps + +## Setup + +### 1. Create Slack App + +1. Go to [api.slack.com/apps](https://api.slack.com/apps) +2. Create new app from manifest (use `manifest.json` in this directory) + - Or create from scratch and configure manually: +3. Enable Socket Mode in "Socket Mode" settings +4. Create an app-level token with `connections:write` scope +5. Add Bot Token Scopes in "OAuth & Permissions": + - `chat:write` + - `commands` + - `users:read` + - `users:read.email` + - `im:write` + - `im:history` + - `files:read` + - `files:write` + - `workflow.steps:execute` +6. Add Event Subscriptions: + - `file_shared` + - `function_executed` +7. Create slash commands in "Slash Commands": + - `/cdl-onboard` - Start onboarding a new member + - `/cdl-offboard` - Start offboarding process + - `/cdl-ping` - Health check + - `/cdl-help` - Show help +8. Enable "Interactivity & Shortcuts" +9. Enable "Org Level Apps" (for Workflow Builder custom steps) +10. Install app to workspace + +### 2. Create GitHub Token + +1. Go to [github.com/settings/tokens](https://github.com/settings/tokens) +2. Create new token (classic) with scopes: + - `admin:org` (for team and invitation management) + - `repo` (for team repository access) + +### 3. Set Up Google Calendar (Optional) + +1. Create a Google Cloud project +2. Enable the Google Calendar API +3. Create a service account +4. Download the credentials JSON file +5. Share each calendar with the service account email + +### 4. Get Anthropic API Key (Optional) + +1. Go to [console.anthropic.com](https://console.anthropic.com) +2. Create an API key for bio editing + +### 5. Configure Environment + +```bash +# Copy the example config +cp .env.example .env + +# Edit with your credentials +nano .env +``` + +Required variables: +- `SLACK_BOT_TOKEN` - Bot OAuth token (xoxb-...) +- `SLACK_APP_TOKEN` - App-level token (xapp-...) +- `SLACK_ADMIN_USER_ID` - Your Slack user ID +- `GITHUB_TOKEN` - GitHub PAT with admin:org scope + +Optional variables: +- `GOOGLE_CREDENTIALS_FILE` - Path to service account JSON +- `GOOGLE_CALENDAR_*` - Calendar IDs +- `ANTHROPIC_API_KEY` - For bio editing + +### 6. Install Dependencies + +```bash +pip install -r requirements.txt +``` + +### 7. Run the Bot + +```bash +python -m scripts.onboarding.bot +``` + +## Usage + +### Starting Onboarding + +As admin: +``` +/cdl-onboard @newmember +``` + +This will: +1. Open a form for the new member to fill out +2. Send approval request to admin +3. Admin reviews and approves/rejects +4. On approval: GitHub invite sent, calendars shared, photo processed + +### Starting Offboarding + +As member leaving: +``` +/cdl-offboard +``` + +As admin for specific member: +``` +/cdl-offboard @member +``` + +Admin selects what to revoke and receives checklist. + +## Testing + +Run tests (model and image tests don't require API keys): +```bash +# Models only +pytest tests/test_onboarding/test_models.py -v + +# Image processing only +pytest tests/test_onboarding/test_image_service.py -v + +# GitHub service (requires GITHUB_TOKEN) +pytest tests/test_onboarding/test_github_service.py -v + +# Bio service (requires ANTHROPIC_API_KEY) +pytest tests/test_onboarding/test_bio_service.py -v + +# All tests +pytest tests/test_onboarding/ -v +``` + +## Workflow Builder Integration + +The bot provides custom steps that can be added to Slack Workflow Builder workflows. + +### Adding Custom Steps to Workflows + +1. Ensure the Slack app has `workflow.steps:execute` scope and `function_executed` event +2. In Workflow Builder, create or edit a workflow +3. Add a step and search for "CDL Onboarding" to find the custom steps: + - **Process CDL Onboarding**: Receives form data, validates GitHub, sends approval to admin + - **Process CDL Offboarding**: Notifies admin and generates checklist + +### Connecting to Existing "Join the Lab!" Workflow + +1. Edit your existing "Join the lab!" workflow in Workflow Builder +2. After the form collection step, add the "Process CDL Onboarding" step +3. Map the form fields to the step inputs: + - `submitter_id` -> Person who started the workflow + - `github_username` -> GitHub username field from form + - `bio` -> Bio field from form + - `website_url` -> Website field from form + - `photo_url` -> Photo URL if collected + +### Connecting to "Leave the Lab" Workflow + +1. Create a new "Leave the lab" workflow or add to existing +2. Add the "Process CDL Offboarding" step +3. Map: + - `submitter_id` -> Person leaving the lab + +## Architecture + +``` +scripts/onboarding/ +├── bot.py # Main entry point +├── config.py # Configuration management +├── manifest.json # Slack app manifest (with functions) +├── handlers/ +│ ├── onboard.py # /cdl-onboard command handling +│ ├── approval.py # Admin approval workflow +│ ├── offboard.py # /cdl-offboard command handling +│ └── workflow_step.py # Workflow Builder custom steps +├── models/ +│ └── onboarding_request.py # Data models +└── services/ + ├── github_service.py # GitHub API integration + ├── calendar_service.py # Google Calendar API + ├── image_service.py # Photo border processing + └── bio_service.py # Claude API bio editing +``` + +## Security Notes + +- Credentials stored in `.env` (gitignored) +- All onboarding actions require admin approval +- Offboarding does NOT auto-remove (generates checklist) +- Private info detection in bios (phone, email, SSN) diff --git a/scripts/onboarding/__init__.py b/scripts/onboarding/__init__.py new file mode 100644 index 0000000..b5a8e06 --- /dev/null +++ b/scripts/onboarding/__init__.py @@ -0,0 +1,8 @@ +""" +CDL Onboarding Bot +Contextual Dynamics Laboratory, Dartmouth College + +Automates the lab member onboarding process via Slack. +""" + +__version__ = "0.1.0" diff --git a/scripts/onboarding/bot.py b/scripts/onboarding/bot.py new file mode 100644 index 0000000..16adca3 --- /dev/null +++ b/scripts/onboarding/bot.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +""" +CDL Onboarding Bot - Main Entry Point + +A Slack bot for automating the onboarding process for new CDL lab members. + +Features: +- /cdl-onboard @user - Start onboarding a new member (admin only) +- /cdl-offboard - Start offboarding process (self or admin) +- Interactive forms for collecting member information +- Admin approval workflow for all actions +- GitHub organization invitations +- Google Calendar sharing +- Photo processing (hand-drawn green borders) +- Bio editing via Claude API + +Usage: + python -m scripts.onboarding.bot + +Environment Variables Required: + SLACK_BOT_TOKEN - Slack bot OAuth token (xoxb-...) + SLACK_APP_TOKEN - Slack app-level token (xapp-...) + SLACK_ADMIN_USER_ID - Slack user ID of the admin + GITHUB_TOKEN - GitHub personal access token with admin:org scope + +Optional Environment Variables: + GOOGLE_CREDENTIALS_FILE - Path to Google service account JSON + GOOGLE_CALENDAR_* - Calendar IDs for each calendar + ANTHROPIC_API_KEY - For bio editing feature +""" + +import logging +import sys +from pathlib import Path + +from slack_bolt import App +from slack_bolt.adapter.socket_mode import SocketModeHandler + +from .config import get_config, Config +from .handlers.onboard import register_onboard_handlers +from .handlers.approval import register_approval_handlers +from .handlers.offboard import register_offboard_handlers +from .handlers.workflow_step import register_workflow_step_handlers +from .handlers.workflow_listener import register_workflow_listener_handlers + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + handlers=[ + logging.StreamHandler(sys.stdout), + ], +) +logger = logging.getLogger(__name__) + + +def create_app(config: Config) -> App: + """ + Create and configure the Slack Bolt app. + + Args: + config: Application configuration + + Returns: + Configured Slack Bolt App instance + """ + app = App(token=config.slack.bot_token) + + # Register all handlers + register_onboard_handlers(app, config) + register_approval_handlers(app, config) + register_offboard_handlers(app, config) + register_workflow_step_handlers(app, config) + register_workflow_listener_handlers(app, config) + + # Add a health check command + @app.command("/cdl-ping") + def handle_ping(ack, respond): + """Simple health check command.""" + ack() + respond("Pong! CDL Onboarding Bot is running.") + + # Add help command + @app.command("/cdl-help") + def handle_help(ack, respond, command): + """Show help information.""" + ack() + + user_id = command["user_id"] + is_admin = user_id == config.slack.admin_user_id + + help_text = """*CDL Onboarding Bot Help* + +*Available Commands:* + +`/cdl-onboard @user` - Start onboarding a new lab member + • Opens a form for the member to fill out their info + • Collects GitHub username, bio, photo, and website URL + • Sends request to admin for approval + +`/cdl-offboard` - Start the offboarding process + • Can be initiated by the member leaving or by admin + • Admin selects which access to revoke + • Generates a checklist for manual steps + +`/cdl-ping` - Check if the bot is running + +`/cdl-help` - Show this help message +""" + + if is_admin: + help_text += """ +*Admin-Only Features:* +• Approve/reject onboarding requests +• Select GitHub teams for new members +• Initiate offboarding for any member +• Request changes to submitted information +""" + + respond(help_text) + + # Log errors + @app.error + def handle_error(error, body, logger): + """Handle errors in Slack event processing.""" + logger.exception(f"Error processing Slack event: {error}") + logger.debug(f"Request body: {body}") + + logger.info("Slack app created and handlers registered") + return app + + +def main(): + """Main entry point for the bot.""" + logger.info("Starting CDL Onboarding Bot...") + + try: + config = get_config() + logger.info("Configuration loaded successfully") + + # Log which optional services are available + if config.google_calendar: + logger.info("Google Calendar integration enabled") + else: + logger.warning("Google Calendar integration not configured") + + if config.anthropic: + logger.info("Anthropic bio editing enabled") + else: + logger.warning("Anthropic bio editing not configured") + + # Create the app + app = create_app(config) + + # Start the bot in Socket Mode + handler = SocketModeHandler(app, config.slack.app_token) + logger.info("Bot started in Socket Mode. Press Ctrl+C to stop.") + handler.start() + + except ValueError as e: + logger.error(f"Configuration error: {e}") + logger.error("Please check your environment variables and .env file") + sys.exit(1) + except KeyboardInterrupt: + logger.info("Bot stopped by user") + sys.exit(0) + except Exception as e: + logger.exception(f"Unexpected error: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/onboarding/config.py b/scripts/onboarding/config.py new file mode 100644 index 0000000..5da5d18 --- /dev/null +++ b/scripts/onboarding/config.py @@ -0,0 +1,185 @@ +""" +Configuration management for the CDL Onboarding Bot. + +Loads credentials from environment variables or .env file. +Never commit credentials to the repository. +""" + +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +# Try to load dotenv if available +try: + from dotenv import load_dotenv + # Look for .env in the onboarding directory or repo root + env_paths = [ + Path(__file__).parent / ".env", + Path(__file__).parent.parent.parent / ".env", + ] + for env_path in env_paths: + if env_path.exists(): + load_dotenv(env_path) + break +except ImportError: + pass # dotenv not installed, rely on environment variables + + +@dataclass +class SlackConfig: + """Slack bot configuration.""" + bot_token: str # xoxb-... + app_token: str # xapp-... + admin_user_id: str # Slack user ID of the admin (Jeremy) + + @classmethod + def from_env(cls) -> "SlackConfig": + """Load Slack configuration from environment variables.""" + bot_token = os.environ.get("SLACK_BOT_TOKEN") + app_token = os.environ.get("SLACK_APP_TOKEN") + admin_user_id = os.environ.get("SLACK_ADMIN_USER_ID") + + if not bot_token: + raise ValueError("SLACK_BOT_TOKEN environment variable is required") + if not app_token: + raise ValueError("SLACK_APP_TOKEN environment variable is required") + if not admin_user_id: + raise ValueError("SLACK_ADMIN_USER_ID environment variable is required") + + return cls( + bot_token=bot_token, + app_token=app_token, + admin_user_id=admin_user_id, + ) + + +@dataclass +class GitHubConfig: + """GitHub configuration.""" + token: str # Personal access token with admin:org scope + org_name: str = "ContextLab" + default_team: str = "Lab default" + + @classmethod + def from_env(cls) -> "GitHubConfig": + """Load GitHub configuration from environment variables.""" + token = os.environ.get("GITHUB_TOKEN") + + if not token: + raise ValueError("GITHUB_TOKEN environment variable is required") + + return cls( + token=token, + org_name=os.environ.get("GITHUB_ORG_NAME", "ContextLab"), + default_team=os.environ.get("GITHUB_DEFAULT_TEAM", "Lab default"), + ) + + +@dataclass +class GoogleCalendarConfig: + """Google Calendar configuration.""" + credentials_file: str # Path to service account JSON file + calendars: dict # Calendar names to IDs mapping + + # Default calendar permissions + DEFAULT_PERMISSIONS = { + "Contextual Dynamics Lab": "reader", # Read-only + "Out of lab": "writer", # Edit + "CDL Resources": "writer", # Edit + } + + @classmethod + def from_env(cls) -> "GoogleCalendarConfig": + """Load Google Calendar configuration from environment variables.""" + credentials_file = os.environ.get("GOOGLE_CREDENTIALS_FILE") + + if not credentials_file: + raise ValueError("GOOGLE_CREDENTIALS_FILE environment variable is required") + + if not Path(credentials_file).exists(): + raise ValueError(f"Google credentials file not found: {credentials_file}") + + # Calendar IDs should be set as environment variables + calendars = {} + for name in ["Contextual Dynamics Lab", "Out of lab", "CDL Resources"]: + env_key = f"GOOGLE_CALENDAR_{name.upper().replace(' ', '_')}" + calendar_id = os.environ.get(env_key) + if calendar_id: + calendars[name] = calendar_id + + return cls( + credentials_file=credentials_file, + calendars=calendars, + ) + + +@dataclass +class AnthropicConfig: + """Anthropic API configuration for bio editing.""" + api_key: str + model: str = "claude-sonnet-4-20250514" + + @classmethod + def from_env(cls) -> "AnthropicConfig": + """Load Anthropic configuration from environment variables.""" + api_key = os.environ.get("ANTHROPIC_API_KEY") + + if not api_key: + raise ValueError("ANTHROPIC_API_KEY environment variable is required") + + return cls( + api_key=api_key, + model=os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-4-20250514"), + ) + + +@dataclass +class Config: + """Main configuration container.""" + slack: SlackConfig + github: GitHubConfig + google_calendar: Optional[GoogleCalendarConfig] + anthropic: Optional[AnthropicConfig] + + # Image processing settings + border_color: tuple = (0, 105, 62) # Dartmouth green RGB + border_width: int = 8 + + # Local storage for processed files + output_dir: Path = Path(__file__).parent / "output" + + @classmethod + def from_env(cls) -> "Config": + """Load all configuration from environment variables.""" + # Required configs + slack = SlackConfig.from_env() + github = GitHubConfig.from_env() + + # Optional configs (gracefully handle missing) + try: + google_calendar = GoogleCalendarConfig.from_env() + except ValueError: + google_calendar = None + + try: + anthropic = AnthropicConfig.from_env() + except ValueError: + anthropic = None + + config = cls( + slack=slack, + github=github, + google_calendar=google_calendar, + anthropic=anthropic, + ) + + # Ensure output directory exists + config.output_dir.mkdir(parents=True, exist_ok=True) + + return config + + +def get_config() -> Config: + """Get the current configuration.""" + return Config.from_env() diff --git a/scripts/onboarding/handlers/__init__.py b/scripts/onboarding/handlers/__init__.py new file mode 100644 index 0000000..9cc6c0a --- /dev/null +++ b/scripts/onboarding/handlers/__init__.py @@ -0,0 +1 @@ +"""Slack event and command handlers.""" diff --git a/scripts/onboarding/handlers/approval.py b/scripts/onboarding/handlers/approval.py new file mode 100644 index 0000000..a8a6115 --- /dev/null +++ b/scripts/onboarding/handlers/approval.py @@ -0,0 +1,691 @@ +""" +Approval workflow handlers for Slack. + +Handles admin approval/rejection of onboarding requests. +""" + +import logging +import re +from typing import Optional + +from slack_bolt import App +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError + +from ..config import Config, GoogleCalendarConfig +from ..models.onboarding_request import OnboardingRequest, OnboardingStatus +from ..services.github_service import GitHubService +from ..services.calendar_service import CalendarService +from .onboard import get_request, save_request, delete_request + +logger = logging.getLogger(__name__) + + +def register_approval_handlers(app: App, config: Config): + """Register all approval-related handlers with the Slack app.""" + + github_service = GitHubService(config.github.token, config.github.org_name) + + calendar_service = None + if config.google_calendar: + calendar_service = CalendarService( + config.google_calendar.credentials_file, + config.google_calendar.calendars, + ) + + @app.action("approve_onboarding") + def handle_approve(ack, body, client: WebClient, action): + """Handle approval of an onboarding request.""" + ack() + + user_id = action["value"] + admin_id = body["user"]["id"] + + # Verify admin + if admin_id != config.slack.admin_user_id: + return + + request = get_request(user_id) + if not request: + logger.error(f"No request found for user {user_id}") + return + + # Get selected teams from the message + # Parse from the state or use defaults + selected_team_ids = _get_selected_teams(body) + + request.github_teams = selected_team_ids + request.approved_by = admin_id + request.update_status(OnboardingStatus.GITHUB_PENDING) + save_request(request) + + # Update the approval message + _update_approval_message(client, body, "Approved", request) + + # Process the approval + _process_approval(client, config, request, github_service, calendar_service) + + @app.action("reject_onboarding") + def handle_reject(ack, body, client: WebClient, action): + """Handle rejection of an onboarding request.""" + ack() + + user_id = action["value"] + admin_id = body["user"]["id"] + + # Verify admin + if admin_id != config.slack.admin_user_id: + return + + request = get_request(user_id) + if not request: + return + + request.update_status(OnboardingStatus.REJECTED) + save_request(request) + + # Update the approval message + _update_approval_message(client, body, "Rejected", request) + + # Notify the user + try: + client.chat_postMessage( + channel=request.slack_channel_id, + text="Your onboarding request was not approved. Please contact the lab admin for more information.", + ) + except SlackApiError as e: + logger.error(f"Error notifying user of rejection: {e}") + + # Clean up + delete_request(user_id) + + @app.action("request_changes_onboarding") + def handle_request_changes(ack, body, client: WebClient, action): + """Handle request for changes to an onboarding request.""" + ack() + + user_id = action["value"] + admin_id = body["user"]["id"] + + # Verify admin + if admin_id != config.slack.admin_user_id: + return + + request = get_request(user_id) + if not request: + return + + # Open a modal for the admin to specify what changes are needed + try: + client.views_open( + trigger_id=body["trigger_id"], + view={ + "type": "modal", + "callback_id": f"request_changes_modal_{user_id}", + "private_metadata": user_id, + "title": {"type": "plain_text", "text": "Request Changes"}, + "submit": {"type": "plain_text", "text": "Send"}, + "close": {"type": "plain_text", "text": "Cancel"}, + "blocks": [ + { + "type": "input", + "block_id": "changes_block", + "element": { + "type": "plain_text_input", + "action_id": "changes_input", + "multiline": True, + "placeholder": { + "type": "plain_text", + "text": "Describe the changes needed...", + }, + }, + "label": { + "type": "plain_text", + "text": "What changes are needed?", + }, + }, + ], + }, + ) + except SlackApiError as e: + logger.error(f"Error opening changes modal: {e}") + + @app.view(re.compile(r"request_changes_modal_.*")) + def handle_changes_modal(ack, body, client: WebClient, view): + """Handle submission of the request changes modal.""" + ack() + + user_id = view["private_metadata"] + request = get_request(user_id) + if not request: + return + + changes_text = view["state"]["values"]["changes_block"]["changes_input"]["value"] + + request.update_status(OnboardingStatus.PENDING_INFO) + save_request(request) + + # Notify the user + try: + client.chat_postMessage( + channel=request.slack_channel_id, + text="The admin has requested some changes to your onboarding information.", + blocks=[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":memo: *Changes Requested*\n\nThe lab admin has requested the following changes:", + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f">{changes_text}", + }, + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": {"type": "plain_text", "text": "Update Information"}, + "action_id": "open_onboarding_form", + }, + ], + }, + ], + ) + except SlackApiError as e: + logger.error(f"Error notifying user of changes: {e}") + + @app.action("github_teams_select") + def handle_teams_select(ack, body): + """Handle GitHub team selection (just acknowledge, we'll read the value on approval).""" + ack() + + # ========== Workflow-initiated onboarding approval handlers ========== + # These handlers are for approving onboarding requests that came through + # Workflow Builder workflows (member-initiated) rather than slash commands. + + @app.action("approve_workflow_onboarding") + def handle_workflow_approve(ack, body, client: WebClient, action, complete, fail): + """Handle approval of a workflow-initiated onboarding request.""" + ack() + + user_id = action["value"] + admin_id = body["user"]["id"] + + # Verify admin + if admin_id != config.slack.admin_user_id: + return + + request = get_request(user_id) + if not request: + logger.error(f"No request found for user {user_id}") + return + + # Get selected teams from the message + selected_team_ids = _get_selected_teams(body) + + request.github_teams = selected_team_ids + request.approved_by = admin_id + request.update_status(OnboardingStatus.GITHUB_PENDING) + save_request(request) + + # Update the approval message + _update_approval_message(client, body, "Approved", request) + + # Process the approval + _process_approval(client, config, request, github_service, calendar_service) + + # Complete the workflow step (if it came from a workflow) + try: + from .workflow_step import get_workflow_execution, delete_workflow_execution + execution = get_workflow_execution(user_id) + if execution and complete: + complete({ + "status": "approved", + "github_username": request.github_username, + "name": request.name, + }) + delete_workflow_execution(user_id) + except Exception as e: + logger.warning(f"Could not complete workflow step: {e}") + + @app.action("reject_workflow_onboarding") + def handle_workflow_reject(ack, body, client: WebClient, action, complete, fail): + """Handle rejection of a workflow-initiated onboarding request.""" + ack() + + user_id = action["value"] + admin_id = body["user"]["id"] + + # Verify admin + if admin_id != config.slack.admin_user_id: + return + + request = get_request(user_id) + if not request: + return + + request.update_status(OnboardingStatus.REJECTED) + save_request(request) + + # Update the approval message + _update_approval_message(client, body, "Rejected", request) + + # Notify the user + try: + client.chat_postMessage( + channel=request.slack_channel_id, + text="Your onboarding request was not approved. Please contact the lab admin for more information.", + ) + except SlackApiError as e: + logger.error(f"Error notifying user of rejection: {e}") + + # Fail the workflow step (if it came from a workflow) + try: + from .workflow_step import get_workflow_execution, delete_workflow_execution + execution = get_workflow_execution(user_id) + if execution and fail: + fail("Onboarding request was rejected by admin") + delete_workflow_execution(user_id) + except Exception as e: + logger.warning(f"Could not fail workflow step: {e}") + + # Clean up + delete_request(user_id) + + @app.action("request_changes_workflow_onboarding") + def handle_workflow_request_changes(ack, body, client: WebClient, action): + """Handle request for changes to a workflow-initiated onboarding request.""" + ack() + + user_id = action["value"] + admin_id = body["user"]["id"] + + # Verify admin + if admin_id != config.slack.admin_user_id: + return + + request = get_request(user_id) + if not request: + return + + # Open a modal for the admin to specify what changes are needed + try: + client.views_open( + trigger_id=body["trigger_id"], + view={ + "type": "modal", + "callback_id": f"workflow_changes_modal_{user_id}", + "private_metadata": user_id, + "title": {"type": "plain_text", "text": "Request Changes"}, + "submit": {"type": "plain_text", "text": "Send"}, + "close": {"type": "plain_text", "text": "Cancel"}, + "blocks": [ + { + "type": "input", + "block_id": "changes_block", + "element": { + "type": "plain_text_input", + "action_id": "changes_input", + "multiline": True, + "placeholder": { + "type": "plain_text", + "text": "Describe the changes needed...", + }, + }, + "label": { + "type": "plain_text", + "text": "What changes are needed?", + }, + }, + ], + }, + ) + except SlackApiError as e: + logger.error(f"Error opening changes modal: {e}") + + @app.view(re.compile(r"workflow_changes_modal_.*")) + def handle_workflow_changes_modal(ack, body, client: WebClient, view): + """Handle submission of the workflow changes request modal.""" + ack() + + user_id = view["private_metadata"] + request = get_request(user_id) + if not request: + return + + changes_text = view["state"]["values"]["changes_block"]["changes_input"]["value"] + + request.update_status(OnboardingStatus.PENDING_INFO) + save_request(request) + + # Notify the user + try: + client.chat_postMessage( + channel=request.slack_channel_id, + text="The admin has requested some changes to your onboarding information.", + blocks=[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":memo: *Changes Requested*\n\nThe lab admin has requested the following changes:", + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f">{changes_text}", + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Please reply to this message with the updated information, or re-run the \"Join the lab\" workflow.", + }, + }, + ], + ) + except SlackApiError as e: + logger.error(f"Error notifying user of changes: {e}") + + @app.action("start_offboarding_workflow") + def handle_start_offboarding(ack, body, client: WebClient, action): + """Handle the start offboarding button from workflow notification.""" + ack() + + user_id = action["value"] + admin_id = body["user"]["id"] + + # Verify admin + if admin_id != config.slack.admin_user_id: + return + + # Open modal to select what to revoke + try: + client.views_open( + trigger_id=body["trigger_id"], + view={ + "type": "modal", + "callback_id": f"offboarding_modal_{user_id}", + "private_metadata": user_id, + "title": {"type": "plain_text", "text": "Offboarding"}, + "submit": {"type": "plain_text", "text": "Process"}, + "close": {"type": "plain_text", "text": "Cancel"}, + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"Select what access to revoke for <@{user_id}>:", + }, + }, + { + "type": "input", + "block_id": "revoke_options", + "element": { + "type": "checkboxes", + "action_id": "revoke_checkboxes", + "options": [ + { + "text": {"type": "plain_text", "text": "Remove from GitHub org"}, + "value": "github", + }, + { + "text": {"type": "plain_text", "text": "Remove calendar access"}, + "value": "calendar", + }, + { + "text": {"type": "plain_text", "text": "Move to alumni on website"}, + "value": "website_alumni", + }, + ], + "initial_options": [ + { + "text": {"type": "plain_text", "text": "Remove from GitHub org"}, + "value": "github", + }, + { + "text": {"type": "plain_text", "text": "Remove calendar access"}, + "value": "calendar", + }, + { + "text": {"type": "plain_text", "text": "Move to alumni on website"}, + "value": "website_alumni", + }, + ], + }, + "label": {"type": "plain_text", "text": "Access to revoke"}, + }, + ], + }, + ) + except SlackApiError as e: + logger.error(f"Error opening offboarding modal: {e}") + + +def _get_selected_teams(body: dict) -> list[int]: + """Extract selected team IDs from the message state.""" + try: + state = body.get("state", {}).get("values", {}) + for block_id, block_data in state.items(): + if "github_teams_select" in block_data: + selected = block_data["github_teams_select"].get("selected_options", []) + return [int(opt["value"]) for opt in selected] + except (KeyError, ValueError) as e: + logger.warning(f"Error parsing team selection: {e}") + + return [] + + +def _update_approval_message(client: WebClient, body: dict, status: str, request: OnboardingRequest): + """Update the approval message to show the result.""" + channel = body["channel"]["id"] + ts = body["message"]["ts"] + + try: + client.chat_update( + channel=channel, + ts=ts, + text=f"Onboarding request from {request.name} - {status}", + blocks=[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f":white_check_mark: *Onboarding Request - {status}*\n\n" + f"*Member:* {request.name} (<@{request.slack_user_id}>)\n" + f"*GitHub:* `{request.github_username}`", + }, + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": f"Status: {request.status.value}", + }, + ], + }, + ], + ) + except SlackApiError as e: + logger.error(f"Error updating approval message: {e}") + + +def _process_approval( + client: WebClient, + config: Config, + request: OnboardingRequest, + github_service: GitHubService, + calendar_service: Optional[CalendarService], +): + """Process an approved onboarding request.""" + results = [] + errors = [] + + # 1. Send GitHub invitation + success, error = github_service.invite_user( + request.github_username, + request.github_teams, + ) + if success: + results.append(f":white_check_mark: GitHub invitation sent to `{request.github_username}`") + request.github_invitation_sent = True + else: + errors.append(f":x: GitHub invitation failed: {error}") + save_request(request) + + # 2. Send calendar invitations + if calendar_service and request.email: + request.update_status(OnboardingStatus.CALENDAR_PENDING) + save_request(request) + + # Use default permissions + permissions = GoogleCalendarConfig.DEFAULT_PERMISSIONS.copy() + request.calendar_permissions = permissions + + calendar_results = calendar_service.share_multiple_calendars( + email=request.email, + calendar_permissions=permissions, + ) + + for calendar_name, (cal_success, cal_error) in calendar_results.items(): + if cal_success: + results.append(f":white_check_mark: Calendar '{calendar_name}' shared") + else: + errors.append(f":x: Calendar '{calendar_name}' failed: {cal_error}") + + request.calendar_invites_sent = True + save_request(request) + else: + if not calendar_service: + errors.append(":warning: Calendar service not configured") + if not request.email: + errors.append(":warning: No email address for calendar invitations") + + # 3. Prepare website content + request.update_status(OnboardingStatus.READY_FOR_WEBSITE) + save_request(request) + + website_ready = bool(request.bio_edited and request.photo_processed_path) + if website_ready: + results.append(":white_check_mark: Photo and bio ready for website") + else: + missing = [] + if not request.bio_edited: + missing.append("edited bio") + if not request.photo_processed_path: + missing.append("processed photo") + errors.append(f":warning: Website content incomplete: missing {', '.join(missing)}") + + # Notify admin of results + summary_blocks = [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": f"Onboarding Progress: {request.name}", + }, + }, + ] + + if results: + summary_blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Completed:*\n" + "\n".join(results), + }, + }) + + if errors: + summary_blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Issues:*\n" + "\n".join(errors), + }, + }) + + # Add website update instructions if ready + if website_ready: + summary_blocks.append({"type": "divider"}) + summary_blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Website Update:*\n" + f"The processed photo has been saved to: `{request.photo_processed_path}`\n\n" + f"*Edited bio:*\n>{request.bio_edited}", + }, + }) + + try: + client.chat_postMessage( + channel=config.slack.admin_user_id, + text=f"Onboarding progress for {request.name}", + blocks=summary_blocks, + ) + except SlackApiError as e: + logger.error(f"Error sending progress update: {e}") + + # Notify the new member + member_blocks = [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":tada: *Your onboarding has been approved!*", + }, + }, + ] + + if request.github_invitation_sent: + member_blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":octocat: *GitHub:* Check your email for an invitation to join the ContextLab organization. " + "Once you accept, you'll have access to our repositories.", + }, + }) + + if request.calendar_invites_sent: + member_blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":calendar: *Calendars:* You should receive invitations to the lab calendars shortly. " + "Add them to your Google Calendar to stay up to date.", + }, + }) + + member_blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":globe_with_meridians: *Website:* Your profile will be added to context-lab.com soon!", + }, + }) + + try: + client.chat_postMessage( + channel=request.slack_channel_id, + text="Your onboarding has been approved!", + blocks=member_blocks, + ) + except SlackApiError as e: + logger.error(f"Error notifying member: {e}") + + # Mark as completed + if not errors: + request.update_status(OnboardingStatus.COMPLETED) + save_request(request) diff --git a/scripts/onboarding/handlers/offboard.py b/scripts/onboarding/handlers/offboard.py new file mode 100644 index 0000000..e75fc30 --- /dev/null +++ b/scripts/onboarding/handlers/offboard.py @@ -0,0 +1,416 @@ +""" +Offboarding workflow handlers for Slack. + +Handles the process of removing lab members: +- Prompts admin for what access to revoke +- Does NOT automatically remove anyone (per requirements) +- Provides guidance for manual steps (website removal) +""" + +import logging +from dataclasses import dataclass, field +from datetime import datetime +from typing import Optional + +from slack_bolt import App +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError + +from ..config import Config + +logger = logging.getLogger(__name__) + + +@dataclass +class OffboardingRequest: + """Tracks an offboarding request.""" + slack_user_id: str + name: str + initiated_by: str + github_username: str = "" + email: str = "" + remove_github: bool = False + remove_calendars: bool = False + created_at: datetime = field(default_factory=datetime.now) + + +# In-memory storage for offboarding requests +_offboarding_requests: dict[str, OffboardingRequest] = {} + + +def register_offboard_handlers(app: App, config: Config): + """Register all offboarding-related handlers with the Slack app.""" + + @app.command("/cdl-offboard") + def handle_offboard_command(ack, command, client: WebClient, respond): + """Handle the /cdl-offboard slash command.""" + ack() + + user_id = command["user_id"] + text = command.get("text", "").strip() + + # This command can be initiated by the member themselves or the admin + is_admin = user_id == config.slack.admin_user_id + + # If text contains a user mention and caller is admin, offboard that user + target_user_id = user_id # Default to self + if text.startswith("<@") and ">" in text and is_admin: + target_user_id = text.split("<@")[1].split("|")[0].split(">")[0] + + # Get user info + try: + user_info = client.users_info(user=target_user_id) + user_name = user_info["user"]["real_name"] or user_info["user"]["name"] + user_email = user_info["user"].get("profile", {}).get("email", "") + except SlackApiError as e: + respond(f"Error getting user info: {e}") + return + + # If self-initiated, send request to admin + if target_user_id == user_id and not is_admin: + _send_offboarding_request_to_admin(client, config, target_user_id, user_name, user_email) + respond( + "Your offboarding request has been sent to the lab admin. " + "They will confirm what access should be revoked or retained." + ) + return + + # If admin-initiated, show the confirmation dialog + _send_offboarding_confirmation(client, config, target_user_id, user_name, user_email, command["trigger_id"]) + respond(f"Opening offboarding options for {user_name}...") + + @app.action("confirm_offboarding") + def handle_confirm_offboarding(ack, body, client: WebClient, action): + """Handle confirmation of offboarding actions.""" + ack() + + admin_id = body["user"]["id"] + if admin_id != config.slack.admin_user_id: + return + + user_id = action["value"] + request = _offboarding_requests.get(user_id) + + if not request: + logger.error(f"No offboarding request found for {user_id}") + return + + # Get checkbox selections from the state + state = body.get("state", {}).get("values", {}) + + remove_github = False + remove_calendars = False + + for block_id, block_data in state.items(): + if "offboard_options" in block_data: + selected = block_data["offboard_options"].get("selected_options", []) + for opt in selected: + if opt["value"] == "github": + remove_github = True + elif opt["value"] == "calendars": + remove_calendars = True + + request.remove_github = remove_github + request.remove_calendars = remove_calendars + + # Process the offboarding + _process_offboarding(client, config, request) + + # Update the message + _update_offboarding_message(client, body, request) + + @app.action("cancel_offboarding") + def handle_cancel_offboarding(ack, body, client: WebClient, action): + """Handle cancellation of offboarding.""" + ack() + + user_id = action["value"] + _offboarding_requests.pop(user_id, None) + + # Update the message + channel = body["channel"]["id"] + ts = body["message"]["ts"] + + try: + client.chat_update( + channel=channel, + ts=ts, + text="Offboarding cancelled", + blocks=[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":x: Offboarding cancelled. No changes were made.", + }, + }, + ], + ) + except SlackApiError as e: + logger.error(f"Error updating message: {e}") + + @app.action("offboard_options") + def handle_offboard_options(ack): + """Handle checkbox selection (just acknowledge).""" + ack() + + +def _send_offboarding_request_to_admin( + client: WebClient, + config: Config, + user_id: str, + user_name: str, + user_email: str, +): + """Send an offboarding request to the admin for confirmation.""" + request = OffboardingRequest( + slack_user_id=user_id, + name=user_name, + initiated_by=user_id, + email=user_email, + ) + _offboarding_requests[user_id] = request + + blocks = [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": ":wave: Offboarding Request", + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*{user_name}* (<@{user_id}>) has initiated the offboarding process.", + }, + }, + {"type": "divider"}, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Select what access to revoke:*\n\n" + "_Note: Some lab members may continue to collaborate on projects after leaving. " + "Only revoke access that is no longer needed._", + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Select options:", + }, + "accessory": { + "type": "checkboxes", + "action_id": "offboard_options", + "options": [ + { + "text": {"type": "plain_text", "text": "Remove from GitHub organization"}, + "value": "github", + "description": {"type": "plain_text", "text": "Revoke access to ContextLab repos"}, + }, + { + "text": {"type": "plain_text", "text": "Remove calendar access"}, + "value": "calendars", + "description": {"type": "plain_text", "text": "Revoke access to lab calendars"}, + }, + ], + }, + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": ":information_source: Website profile removal must be done manually in Squarespace " + "(or will be automated once the GitHub Pages migration is complete).", + }, + ], + }, + {"type": "divider"}, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": {"type": "plain_text", "text": "Confirm Offboarding"}, + "style": "danger", + "action_id": "confirm_offboarding", + "value": user_id, + "confirm": { + "title": {"type": "plain_text", "text": "Confirm Offboarding"}, + "text": { + "type": "mrkdwn", + "text": f"Are you sure you want to proceed with offboarding {user_name}?", + }, + "confirm": {"type": "plain_text", "text": "Yes, proceed"}, + "deny": {"type": "plain_text", "text": "Cancel"}, + }, + }, + { + "type": "button", + "text": {"type": "plain_text", "text": "Cancel"}, + "action_id": "cancel_offboarding", + "value": user_id, + }, + ], + }, + ] + + try: + client.chat_postMessage( + channel=config.slack.admin_user_id, + text=f"Offboarding request from {user_name}", + blocks=blocks, + ) + except SlackApiError as e: + logger.error(f"Error sending offboarding request: {e}") + + +def _send_offboarding_confirmation( + client: WebClient, + config: Config, + user_id: str, + user_name: str, + user_email: str, + trigger_id: str, +): + """Show offboarding confirmation dialog (admin-initiated).""" + # This is the same as the member-initiated flow but sent directly + _send_offboarding_request_to_admin(client, config, user_id, user_name, user_email) + + +def _process_offboarding(client: WebClient, config: Config, request: OffboardingRequest): + """Process the offboarding actions.""" + results = [] + errors = [] + + # Note: We intentionally do NOT automatically remove users + # We just prepare instructions for the admin + + if request.remove_github: + results.append( + f":octocat: *GitHub:* Please manually remove `{request.github_username or request.name}` " + f"from the ContextLab organization at:\n" + f"https://github.com/orgs/ContextLab/people" + ) + + if request.remove_calendars: + results.append( + f":calendar: *Calendars:* Please remove `{request.email}` from the following calendars:\n" + f"• Contextual Dynamics Lab\n" + f"• Out of lab\n" + f"• CDL Resources" + ) + + # Always include website instructions + results.append( + f":globe_with_meridians: *Website:* Please remove {request.name}'s profile from:\n" + f"https://www.context-lab.com/people\n" + f"(Or from the GitHub Pages people-site repo once migrated)" + ) + + # Send summary to admin + summary_blocks = [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": f"Offboarding Checklist: {request.name}", + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Please complete the following manual steps:", + }, + }, + ] + + for item in results: + summary_blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": item, + }, + }) + + try: + client.chat_postMessage( + channel=config.slack.admin_user_id, + text=f"Offboarding checklist for {request.name}", + blocks=summary_blocks, + ) + except SlackApiError as e: + logger.error(f"Error sending offboarding checklist: {e}") + + # Notify the departing member + try: + dm_response = client.conversations_open(users=[request.slack_user_id]) + dm_channel = dm_response["channel"]["id"] + + client.chat_postMessage( + channel=dm_channel, + text="Your offboarding has been processed", + blocks=[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":wave: *Offboarding Confirmed*\n\n" + "The lab admin has been notified and will process your offboarding. " + "Thank you for your contributions to the CDL!", + }, + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": "If you have any questions or need continued access for ongoing collaborations, " + "please contact the lab admin.", + }, + ], + }, + ], + ) + except SlackApiError as e: + logger.error(f"Error notifying departing member: {e}") + + +def _update_offboarding_message(client: WebClient, body: dict, request: OffboardingRequest): + """Update the offboarding message to show completion.""" + channel = body["channel"]["id"] + ts = body["message"]["ts"] + + actions = [] + if request.remove_github: + actions.append("GitHub access") + if request.remove_calendars: + actions.append("Calendar access") + + actions_text = ", ".join(actions) if actions else "No access revoked" + + try: + client.chat_update( + channel=channel, + ts=ts, + text=f"Offboarding processed for {request.name}", + blocks=[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f":white_check_mark: *Offboarding Processed: {request.name}*\n\n" + f"Actions to take: {actions_text}\n" + f"A checklist has been sent with manual steps to complete.", + }, + }, + ], + ) + except SlackApiError as e: + logger.error(f"Error updating offboarding message: {e}") diff --git a/scripts/onboarding/handlers/onboard.py b/scripts/onboarding/handlers/onboard.py new file mode 100644 index 0000000..831dc37 --- /dev/null +++ b/scripts/onboarding/handlers/onboard.py @@ -0,0 +1,621 @@ +""" +Onboarding workflow handlers for Slack. + +Manages the multi-step onboarding process: +1. Collect member information +2. Validate GitHub username +3. Request admin approval +4. Process invitations and bio/photo +""" + +import json +import logging +import os +import tempfile +from pathlib import Path +from typing import Optional + +from slack_bolt import App +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError + +from ..config import Config +from ..models.onboarding_request import OnboardingRequest, OnboardingStatus +from ..services.github_service import GitHubService +from ..services.image_service import ImageService +from ..services.bio_service import BioService + +logger = logging.getLogger(__name__) + +# In-memory storage for active onboarding requests +# In production, this should be persisted to a database +_active_requests: dict[str, OnboardingRequest] = {} + + +def get_request(user_id: str) -> Optional[OnboardingRequest]: + """Get an active onboarding request for a user.""" + return _active_requests.get(user_id) + + +def save_request(request: OnboardingRequest): + """Save an onboarding request.""" + _active_requests[request.slack_user_id] = request + + +def delete_request(user_id: str): + """Delete an onboarding request.""" + _active_requests.pop(user_id, None) + + +def register_onboard_handlers(app: App, config: Config): + """Register all onboarding-related handlers with the Slack app.""" + + github_service = GitHubService(config.github.token, config.github.org_name) + image_service = ImageService(config.border_color, config.border_width) + bio_service = None + if config.anthropic: + bio_service = BioService(config.anthropic.api_key, config.anthropic.model) + + # Slash command to start onboarding + @app.command("/cdl-onboard") + def handle_onboard_command(ack, command, client: WebClient, respond): + """Handle the /cdl-onboard slash command.""" + ack() + + user_id = command["user_id"] + text = command.get("text", "").strip() + + # Check if user is admin + if user_id != config.slack.admin_user_id: + respond("Only the lab admin can initiate onboarding.") + return + + # Parse mentioned user if provided + target_user_id = None + if text.startswith("<@") and ">" in text: + # Extract user ID from mention like <@U12345|username> + target_user_id = text.split("<@")[1].split("|")[0].split(">")[0] + + if not target_user_id: + respond( + "Please specify the Slack user to onboard. " + "Usage: `/cdl-onboard @username`" + ) + return + + # Get user info + try: + user_info = client.users_info(user=target_user_id) + user_name = user_info["user"]["real_name"] or user_info["user"]["name"] + user_email = user_info["user"].get("profile", {}).get("email", "") + except SlackApiError as e: + respond(f"Error getting user info: {e}") + return + + # Check if user already has an active request + existing_request = get_request(target_user_id) + if existing_request: + respond( + f"User <@{target_user_id}> already has an active onboarding request " + f"(status: {existing_request.status.value})" + ) + return + + # Open DM with the new member + try: + dm_response = client.conversations_open(users=[target_user_id]) + dm_channel = dm_response["channel"]["id"] + except SlackApiError as e: + respond(f"Error opening DM with user: {e}") + return + + # Create onboarding request + request = OnboardingRequest( + slack_user_id=target_user_id, + slack_channel_id=dm_channel, + name=user_name, + email=user_email, + ) + save_request(request) + + # Send welcome message to the new member + welcome_blocks = _build_welcome_message(user_name) + try: + client.chat_postMessage( + channel=dm_channel, + text=f"Welcome to the CDL, {user_name}!", + blocks=welcome_blocks, + ) + except SlackApiError as e: + logger.error(f"Error sending welcome message: {e}") + + respond(f"Started onboarding for <@{target_user_id}>. They've been sent the welcome message.") + + # Handle the onboarding form submission + @app.view("onboarding_form") + def handle_onboarding_form(ack, body, client: WebClient, view): + """Handle submission of the onboarding information form.""" + ack() + + user_id = body["user"]["id"] + request = get_request(user_id) + + if not request: + logger.error(f"No onboarding request found for user {user_id}") + return + + # Extract form values + values = view["state"]["values"] + + # Get GitHub username + github_username = values.get("github_block", {}).get("github_input", {}).get("value", "") + + # Get bio + bio_raw = values.get("bio_block", {}).get("bio_input", {}).get("value", "") + + # Get website URL (optional) + website_url = values.get("website_block", {}).get("website_input", {}).get("value", "") + + # Validate GitHub username + is_valid, error_msg = github_service.validate_username(github_username) + + if not is_valid: + # Send error message and re-prompt + try: + client.chat_postMessage( + channel=request.slack_channel_id, + text=f"The GitHub username '{github_username}' was not found. Please check and try again.", + blocks=[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f":warning: *GitHub username not found*\n\nThe username `{github_username}` doesn't exist on GitHub. Please double-check the spelling and try again.", + }, + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": {"type": "plain_text", "text": "Try Again"}, + "action_id": "retry_github_username", + } + ], + }, + ], + ) + except SlackApiError as e: + logger.error(f"Error sending validation error: {e}") + return + + # Update the request + request.github_username = github_username + request.bio_raw = bio_raw + request.website_url = website_url + request.update_status(OnboardingStatus.PENDING_APPROVAL) + save_request(request) + + # Process the bio if we have the service + if bio_service and bio_raw: + edited_bio, bio_error = bio_service.edit_bio(bio_raw, request.name) + if edited_bio: + request.bio_edited = edited_bio + save_request(request) + + # Send confirmation to the new member + try: + client.chat_postMessage( + channel=request.slack_channel_id, + text="Thanks! Your information has been submitted for approval.", + blocks=[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":white_check_mark: *Information Received*\n\nYour onboarding information has been submitted. The lab admin will review it shortly.", + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*What's next:*\n• GitHub: Invitation to ContextLab organization\n• Calendar: Access to lab calendars\n• Website: Your photo and bio will be added", + }, + }, + ], + ) + except SlackApiError as e: + logger.error(f"Error sending confirmation: {e}") + + # Send approval request to admin + _send_approval_request(client, config, request, github_service) + + # Handle retry GitHub username button + @app.action("retry_github_username") + def handle_retry_github(ack, body, client: WebClient): + """Handle the retry GitHub username button.""" + ack() + user_id = body["user"]["id"] + request = get_request(user_id) + + if request: + # Re-open the form modal + _open_onboarding_form(client, body["trigger_id"], request) + + # Handle file uploads (for photo) + @app.event("file_shared") + def handle_file_shared(event, client: WebClient, say): + """Handle when a file is shared in a DM with the bot.""" + file_id = event.get("file_id") + channel_id = event.get("channel_id") + user_id = event.get("user_id") + + request = get_request(user_id) + if not request or request.slack_channel_id != channel_id: + return # Not an onboarding conversation + + # Get file info + try: + file_info = client.files_info(file=file_id) + file_data = file_info["file"] + except SlackApiError as e: + logger.error(f"Error getting file info: {e}") + return + + # Check if it's an image + mimetype = file_data.get("mimetype", "") + if not mimetype.startswith("image/"): + say( + channel=channel_id, + text="Please upload an image file (JPEG, PNG, etc.) for your profile photo.", + ) + return + + # Download the file + file_url = file_data.get("url_private_download") + if not file_url: + logger.error("No download URL for file") + return + + try: + # Download using Slack token + import requests + + headers = {"Authorization": f"Bearer {config.slack.bot_token}"} + response = requests.get(file_url, headers=headers) + response.raise_for_status() + + # Save to temp file + suffix = Path(file_data.get("name", "photo.jpg")).suffix + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: + tmp.write(response.content) + tmp_path = Path(tmp.name) + + # Validate the image + is_valid, error_msg = image_service.validate_image(tmp_path) + if not is_valid: + say(channel=channel_id, text=f"Image validation failed: {error_msg}") + tmp_path.unlink() + return + + # Save original path + request.photo_original_path = tmp_path + save_request(request) + + # Process the photo + output_path = config.output_dir / f"{request.slack_user_id}_photo.png" + processed_path = image_service.add_hand_drawn_border( + tmp_path, output_path, seed=hash(request.slack_user_id) + ) + request.photo_processed_path = processed_path + save_request(request) + + # Upload the processed photo back to show the user + try: + client.files_upload_v2( + channel=channel_id, + file=str(processed_path), + title="Your processed profile photo", + initial_comment=":camera: Here's how your photo will look on the website with the CDL border!", + ) + except SlackApiError as e: + logger.error(f"Error uploading processed photo: {e}") + + say( + channel=channel_id, + text="Photo received and processed! If you're happy with it, we'll use this for the website.", + ) + + except Exception as e: + logger.error(f"Error processing photo: {e}") + say(channel=channel_id, text=f"Error processing photo: {e}") + + # Button to open the onboarding form + @app.action("open_onboarding_form") + def handle_open_form(ack, body, client: WebClient): + """Handle the button to open the onboarding form.""" + ack() + user_id = body["user"]["id"] + request = get_request(user_id) + + if request: + _open_onboarding_form(client, body["trigger_id"], request) + + +def _build_welcome_message(user_name: str) -> list: + """Build the welcome message blocks.""" + return [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f":wave: *Welcome to the Contextual Dynamics Lab, {user_name}!*", + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "I'm the CDL onboarding bot. I'll help you get set up with:\n\n" + "• *GitHub:* Access to the ContextLab organization\n" + "• *Calendars:* Access to lab calendars\n" + "• *Website:* Adding your profile to context-lab.com", + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "To get started, I'll need some information from you. Click the button below to fill out the form.", + }, + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": {"type": "plain_text", "text": "Start Onboarding"}, + "style": "primary", + "action_id": "open_onboarding_form", + } + ], + }, + ] + + +def _open_onboarding_form(client: WebClient, trigger_id: str, request: OnboardingRequest): + """Open the onboarding information form modal.""" + try: + client.views_open( + trigger_id=trigger_id, + view={ + "type": "modal", + "callback_id": "onboarding_form", + "title": {"type": "plain_text", "text": "CDL Onboarding"}, + "submit": {"type": "plain_text", "text": "Submit"}, + "close": {"type": "plain_text", "text": "Cancel"}, + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Please provide the following information for your CDL profile.", + }, + }, + { + "type": "input", + "block_id": "github_block", + "element": { + "type": "plain_text_input", + "action_id": "github_input", + "placeholder": { + "type": "plain_text", + "text": "e.g., octocat", + }, + }, + "label": { + "type": "plain_text", + "text": "GitHub Username", + }, + "hint": { + "type": "plain_text", + "text": "Your GitHub username (not email). We'll invite you to the ContextLab organization.", + }, + }, + { + "type": "input", + "block_id": "bio_block", + "element": { + "type": "plain_text_input", + "action_id": "bio_input", + "multiline": True, + "placeholder": { + "type": "plain_text", + "text": "Tell us about yourself and your research interests...", + }, + }, + "label": { + "type": "plain_text", + "text": "Short Bio", + }, + "hint": { + "type": "plain_text", + "text": "3-4 sentences about you. We'll edit it for style consistency.", + }, + }, + { + "type": "input", + "block_id": "website_block", + "optional": True, + "element": { + "type": "plain_text_input", + "action_id": "website_input", + "placeholder": { + "type": "plain_text", + "text": "https://your-website.com", + }, + }, + "label": { + "type": "plain_text", + "text": "Personal Website (optional)", + }, + "hint": { + "type": "plain_text", + "text": "If you have a personal website, we'll link to it from your profile.", + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":camera: *Photo:* After submitting this form, please upload a profile photo by sending it as a message in this conversation.", + }, + }, + ], + }, + ) + except SlackApiError as e: + logger.error(f"Error opening modal: {e}") + + +def _send_approval_request( + client: WebClient, + config: Config, + request: OnboardingRequest, + github_service: GitHubService, +): + """Send an approval request to the admin.""" + # Get GitHub teams for the checkboxes + teams = github_service.get_teams() + + # Build team options + team_options = [] + initial_options = [] + for team in teams: + option = { + "text": {"type": "plain_text", "text": team["name"]}, + "value": str(team["id"]), + } + team_options.append(option) + if team["name"] == config.github.default_team: + initial_options.append(option) + + # Build the approval message + blocks = [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": ":clipboard: New Onboarding Request", + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*{request.name}* (<@{request.slack_user_id}>) has submitted their onboarding information.", + }, + }, + {"type": "divider"}, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*GitHub Username:* `{request.github_username}`\n" + f"*Email:* {request.email or 'Not provided'}\n" + f"*Website:* {request.website_url or 'None'}", + }, + }, + ] + + # Add bio section + if request.bio_raw: + bio_preview = request.bio_raw[:300] + "..." if len(request.bio_raw) > 300 else request.bio_raw + blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*Original Bio:*\n>{bio_preview}", + }, + }) + + if request.bio_edited: + blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*Edited Bio (for website):*\n>{request.bio_edited}", + }, + }) + + blocks.append({"type": "divider"}) + + # GitHub team selection + blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Select GitHub teams to add this member to:*", + }, + "accessory": { + "type": "checkboxes", + "action_id": "github_teams_select", + "options": team_options[:10], # Slack limits to 10 options + "initial_options": initial_options, + }, + }) + + # Calendar permissions (using default values) + blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Calendar Permissions (defaults):*\n" + "• Contextual Dynamics Lab: Read-only\n" + "• Out of lab: Edit\n" + "• CDL Resources: Edit", + }, + }) + + blocks.append({"type": "divider"}) + + # Action buttons + blocks.append({ + "type": "actions", + "block_id": f"approval_actions_{request.slack_user_id}", + "elements": [ + { + "type": "button", + "text": {"type": "plain_text", "text": "Approve"}, + "style": "primary", + "action_id": "approve_onboarding", + "value": request.slack_user_id, + }, + { + "type": "button", + "text": {"type": "plain_text", "text": "Reject"}, + "style": "danger", + "action_id": "reject_onboarding", + "value": request.slack_user_id, + }, + { + "type": "button", + "text": {"type": "plain_text", "text": "Request Changes"}, + "action_id": "request_changes_onboarding", + "value": request.slack_user_id, + }, + ], + }) + + # Send to admin + try: + result = client.chat_postMessage( + channel=config.slack.admin_user_id, + text=f"New onboarding request from {request.name}", + blocks=blocks, + ) + request.admin_approval_message_ts = result["ts"] + save_request(request) + except SlackApiError as e: + logger.error(f"Error sending approval request: {e}") diff --git a/scripts/onboarding/handlers/workflow_listener.py b/scripts/onboarding/handlers/workflow_listener.py new file mode 100644 index 0000000..26b49f9 --- /dev/null +++ b/scripts/onboarding/handlers/workflow_listener.py @@ -0,0 +1,504 @@ +""" +Workflow Builder message listener. + +Listens for messages from the existing "Join the lab!" Workflow Builder workflow +and processes them to create onboarding requests. + +The workflow sends two messages to the admin: +1. Step 4: GitHub username and Gmail address +2. Step 7: Name, bio, and personal website + +This handler listens for both messages, combines the data, and sends +an interactive approval form to the admin. +""" + +import logging +import re +from typing import Optional + +from slack_bolt import App +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError + +from ..config import Config +from ..models.onboarding_request import OnboardingRequest, OnboardingStatus +from ..services.github_service import GitHubService +from ..services.bio_service import BioService +from .onboard import get_request, save_request, delete_request + +logger = logging.getLogger(__name__) + +# Temporary storage for partial onboarding data (keyed by Slack user ID) +# This holds the first form submission until we receive the second +_partial_requests: dict[str, dict] = {} + + +def get_partial_request(user_id: str) -> Optional[dict]: + """Get partial onboarding data for a user.""" + return _partial_requests.get(user_id) + + +def save_partial_request(user_id: str, data: dict): + """Save partial onboarding data.""" + _partial_requests[user_id] = data + + +def delete_partial_request(user_id: str): + """Delete partial onboarding data.""" + _partial_requests.pop(user_id, None) + + +def register_workflow_listener_handlers(app: App, config: Config): + """Register handlers that listen for Workflow Builder output messages.""" + + github_service = GitHubService(config.github.token, config.github.org_name) + bio_service = None + if config.anthropic: + bio_service = BioService(config.anthropic.api_key, config.anthropic.model) + + @app.event("message") + def handle_workflow_message(event, client: WebClient, say): + """ + Listen for messages from Workflow Builder. + + We're looking for messages sent to the admin that match the pattern: + "CDL Onboarding submission from [Person]" + """ + # Only process messages sent to the admin + channel = event.get("channel") + channel_type = event.get("channel_type") + + # Check if this is a DM to the admin (im = direct message) + if channel_type != "im": + return + + # Get the message text + text = event.get("text", "") + + # Check if this is a workflow message + if "CDL Onboarding" not in text and "submission from" not in text: + return + + # Check for bot_id to identify workflow messages (workflows post as bots) + bot_id = event.get("bot_id") + if not bot_id: + return + + logger.info(f"Detected potential workflow message: {text[:100]}...") + + # Try to parse the message + # The workflow sends messages with the person who submitted as a link + # Format: "CDL Onboarding submission from <@U12345|username>" + + # Extract the user ID from the message + user_match = re.search(r"submission from\s+<@([A-Z0-9]+)", text) + if not user_match: + logger.debug("Could not extract user ID from workflow message") + return + + submitter_id = user_match.group(1) + logger.info(f"Workflow submission from user: {submitter_id}") + + # Parse the form fields from the message + # Messages have format like: + # "What's your GitHub username?\nAnswer to: What's your GitHub username?" + # or similar patterns + + parsed_data = _parse_workflow_message(text) + + if not parsed_data: + logger.warning(f"Could not parse workflow message fields") + return + + logger.info(f"Parsed workflow data: {parsed_data}") + + # Determine which form this is (first or second) + has_github = "github_username" in parsed_data + has_bio = "bio" in parsed_data or "name" in parsed_data + + if has_github and not has_bio: + # This is the first form (Step 4) - GitHub and email + logger.info(f"Received first workflow form for {submitter_id}") + + # Store partial data + partial = get_partial_request(submitter_id) or {} + partial.update(parsed_data) + partial["submitter_id"] = submitter_id + save_partial_request(submitter_id, partial) + + # Acknowledge receipt but wait for second form + try: + client.chat_postMessage( + channel=channel, + text=f":white_check_mark: Received GitHub info for <@{submitter_id}>. Waiting for website info...", + thread_ts=event.get("ts"), # Reply in thread + ) + except SlackApiError as e: + logger.error(f"Error sending acknowledgment: {e}") + + elif has_bio: + # This is the second form (Step 7) - name, bio, website + logger.info(f"Received second workflow form for {submitter_id}") + + # Get partial data from first form + partial = get_partial_request(submitter_id) or {} + partial.update(parsed_data) + partial["submitter_id"] = submitter_id + + # Now we have all the data - process it + _process_complete_workflow_submission( + client, config, submitter_id, partial, + github_service, bio_service, channel + ) + + # Clean up partial data + delete_partial_request(submitter_id) + + else: + # Unknown form type - store what we have + logger.warning(f"Unknown workflow form type, storing data") + partial = get_partial_request(submitter_id) or {} + partial.update(parsed_data) + partial["submitter_id"] = submitter_id + save_partial_request(submitter_id, partial) + + +def _parse_workflow_message(text: str) -> dict: + """ + Parse form fields from a Workflow Builder message. + + The message format is typically: + "CDL Onboarding submission from <@U123|name> + + What's your GitHub username? + Answer to: What's your GitHub username? + + What's your GMail address (include @gmail.com or @dartmouth.edu)? + Answer to: What's your GMail address..." + + Returns a dict with parsed field names and values. + """ + result = {} + + # Split by lines and look for question/answer pairs + lines = text.split("\n") + + current_question = None + for i, line in enumerate(lines): + line = line.strip() + + # Look for GitHub username + if "github username" in line.lower(): + # Next line starting with "Answer to:" contains the value + for j in range(i + 1, min(i + 3, len(lines))): + next_line = lines[j].strip() + if next_line.startswith("Answer to:"): + # The answer is after "Answer to: [question]?" + # But actually the answer IS the repeated text - extract it + # Format: "Answer to: What's your GitHub username?" + # The actual answer comes after this in the Slack message rendering + pass + elif next_line and not next_line.startswith("What") and "?" not in next_line: + # This might be the actual answer + result["github_username"] = next_line + break + + # Look for email/Gmail + if "gmail" in line.lower() or "email" in line.lower(): + for j in range(i + 1, min(i + 3, len(lines))): + next_line = lines[j].strip() + if next_line and "@" in next_line and not next_line.startswith("Answer"): + result["email"] = next_line + break + elif next_line.startswith("Answer to:") and "@" in next_line: + # Extract email from answer line + email_match = re.search(r'[\w\.-]+@[\w\.-]+\.\w+', next_line) + if email_match: + result["email"] = email_match.group(0) + break + + # Look for name + if "name listed on the lab website" in line.lower() or "how do you want your name" in line.lower(): + for j in range(i + 1, min(i + 3, len(lines))): + next_line = lines[j].strip() + if next_line and not next_line.startswith("Answer") and not next_line.startswith("What") and not next_line.startswith("Please") and not next_line.startswith("Do you"): + result["name"] = next_line + break + + # Look for bio + if "bio" in line.lower() and "sentence" in line.lower(): + for j in range(i + 1, min(i + 3, len(lines))): + next_line = lines[j].strip() + if next_line and not next_line.startswith("Answer") and not next_line.startswith("What") and not next_line.startswith("Do you") and len(next_line) > 20: + result["bio"] = next_line + break + + # Look for website + if "personal website" in line.lower(): + for j in range(i + 1, min(i + 3, len(lines))): + next_line = lines[j].strip() + if next_line and ("http" in next_line or "www" in next_line or next_line == "blank" or not next_line.startswith("Answer")): + if next_line.lower() != "blank" and next_line: + result["website_url"] = next_line + break + + # Alternative parsing: look for the cyan/blue "Answer to:" formatted text + # In Slack's rendering, the answers appear as linked text + # Pattern: field label followed by cyan text with the answer + + # Try regex patterns for common formats + github_patterns = [ + r"GitHub username[?\s]*\n*(?:Answer to:[^\n]*)?\n*([A-Za-z0-9_-]+)", + r"GitHub username[?\s]*:?\s*([A-Za-z0-9_-]+)", + ] + for pattern in github_patterns: + match = re.search(pattern, text, re.IGNORECASE) + if match and "github_username" not in result: + result["github_username"] = match.group(1).strip() + break + + email_patterns = [ + r"(?:gmail|email)[^@\n]*?(?:Answer to:[^\n]*)?\n*([\w\.-]+@[\w\.-]+\.\w+)", + r"([\w\.-]+@(?:gmail\.com|dartmouth\.edu))", + ] + for pattern in email_patterns: + match = re.search(pattern, text, re.IGNORECASE) + if match and "email" not in result: + result["email"] = match.group(1).strip() + break + + return result + + +def _process_complete_workflow_submission( + client: WebClient, + config: Config, + submitter_id: str, + data: dict, + github_service: GitHubService, + bio_service: Optional[BioService], + admin_channel: str, +): + """Process a complete workflow submission and send approval request.""" + + github_username = data.get("github_username", "") + email = data.get("email", "") + name = data.get("name", "") + bio_raw = data.get("bio", "") + website_url = data.get("website_url", "") + + logger.info(f"Processing complete workflow submission for {submitter_id}") + logger.info(f" GitHub: {github_username}, Email: {email}") + logger.info(f" Name: {name}, Bio: {bio_raw[:50] if bio_raw else 'N/A'}...") + + # Get user info from Slack if name not provided + if not name: + try: + user_info = client.users_info(user=submitter_id) + name = user_info["user"]["real_name"] or user_info["user"]["name"] + if not email: + email = user_info["user"].get("profile", {}).get("email", "") + except SlackApiError as e: + logger.warning(f"Could not get user info: {e}") + name = "Unknown" + + # Validate GitHub username + if github_username: + is_valid, error_msg = github_service.validate_username(github_username) + if not is_valid: + try: + client.chat_postMessage( + channel=admin_channel, + text=f":warning: GitHub username `{github_username}` for <@{submitter_id}> is invalid: {error_msg}", + ) + except SlackApiError: + pass + # Continue anyway - admin can handle it + + # Open DM channel with the new member + try: + dm_response = client.conversations_open(users=[submitter_id]) + dm_channel = dm_response["channel"]["id"] + except SlackApiError as e: + logger.error(f"Error opening DM with user: {e}") + dm_channel = None + + # Create onboarding request + request = OnboardingRequest( + slack_user_id=submitter_id, + slack_channel_id=dm_channel or admin_channel, + name=name, + email=email, + github_username=github_username, + bio_raw=bio_raw, + website_url=website_url, + ) + + # Process bio if service available + if bio_service and bio_raw: + edited_bio, bio_error = bio_service.edit_bio(bio_raw, name) + if edited_bio: + request.bio_edited = edited_bio + else: + logger.warning(f"Bio editing failed: {bio_error}") + + request.update_status(OnboardingStatus.PENDING_APPROVAL) + save_request(request) + + # Send approval request to admin + _send_workflow_approval_request(client, config, request, github_service, admin_channel) + + +def _send_workflow_approval_request( + client: WebClient, + config: Config, + request: OnboardingRequest, + github_service: GitHubService, + channel: str, +): + """Send an approval request to the admin channel.""" + + # Get GitHub teams for the checkboxes + teams = github_service.get_teams() + + # Build team options + team_options = [] + initial_options = [] + for team in teams: + option = { + "text": {"type": "plain_text", "text": team["name"]}, + "value": str(team["id"]), + } + team_options.append(option) + if team["name"] == config.github.default_team: + initial_options.append(option) + + # Build the approval message + blocks = [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": ":clipboard: New Member - Workflow Submission", + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*{request.name}* (<@{request.slack_user_id}>) submitted the \"Join the lab\" workflow.", + }, + }, + {"type": "divider"}, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*GitHub Username:* `{request.github_username or 'Not provided'}`\n" + f"*Email:* {request.email or 'Not provided'}\n" + f"*Website:* {request.website_url or 'None'}", + }, + }, + ] + + # Add bio section + if request.bio_raw: + bio_preview = request.bio_raw[:300] + "..." if len(request.bio_raw) > 300 else request.bio_raw + blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*Original Bio:*\n>{bio_preview}", + }, + }) + + if request.bio_edited: + blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*Edited Bio (CDL style):*\n>{request.bio_edited}", + }, + }) + + # Photo status + blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":camera: *Photo:* Waiting for DM (workflow asks them to send it to you)", + }, + }) + + blocks.append({"type": "divider"}) + + # GitHub team selection + if team_options: + blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Select GitHub teams:*", + }, + "accessory": { + "type": "checkboxes", + "action_id": "github_teams_select", + "options": team_options[:10], + "initial_options": initial_options if initial_options else None, + }, + }) + + # Calendar permissions info + blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Calendar Permissions (defaults):*\n" + "• Contextual Dynamics Lab: Read-only\n" + "• Out of lab: Edit\n" + "• CDL Resources: Edit", + }, + }) + + blocks.append({"type": "divider"}) + + # Action buttons + blocks.append({ + "type": "actions", + "block_id": f"workflow_approval_actions_{request.slack_user_id}", + "elements": [ + { + "type": "button", + "text": {"type": "plain_text", "text": "Approve & Send Invites"}, + "style": "primary", + "action_id": "approve_workflow_onboarding", + "value": request.slack_user_id, + }, + { + "type": "button", + "text": {"type": "plain_text", "text": "Reject"}, + "style": "danger", + "action_id": "reject_workflow_onboarding", + "value": request.slack_user_id, + }, + { + "type": "button", + "text": {"type": "plain_text", "text": "Request Changes"}, + "action_id": "request_changes_workflow_onboarding", + "value": request.slack_user_id, + }, + ], + }) + + # Send to admin + try: + result = client.chat_postMessage( + channel=channel, + text=f"New member request from {request.name}", + blocks=blocks, + ) + request.admin_approval_message_ts = result["ts"] + save_request(request) + logger.info(f"Sent approval request for {request.name}") + except SlackApiError as e: + logger.error(f"Error sending approval request: {e}") diff --git a/scripts/onboarding/handlers/workflow_step.py b/scripts/onboarding/handlers/workflow_step.py new file mode 100644 index 0000000..f0e04c5 --- /dev/null +++ b/scripts/onboarding/handlers/workflow_step.py @@ -0,0 +1,546 @@ +""" +Custom Workflow Step handlers for Slack Workflow Builder integration. + +This module provides custom steps that can be added to Workflow Builder workflows +for onboarding and offboarding processes. + +The "Process Onboarding" step receives form data from a workflow and sends +an approval request to the admin. The workflow remains paused until approved. +""" + +import logging +import os +import tempfile +from pathlib import Path +from typing import Optional + +import requests +from slack_bolt import App, Complete, Fail +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError + +from ..config import Config +from ..models.onboarding_request import OnboardingRequest, OnboardingStatus +from ..services.github_service import GitHubService +from ..services.image_service import ImageService +from ..services.bio_service import BioService +from .onboard import get_request, save_request, delete_request + +logger = logging.getLogger(__name__) + +# Store workflow execution context for completing after admin approval +_pending_workflow_executions: dict[str, dict] = {} + + +def get_workflow_execution(user_id: str) -> Optional[dict]: + """Get pending workflow execution context for a user.""" + return _pending_workflow_executions.get(user_id) + + +def save_workflow_execution(user_id: str, context: dict): + """Save workflow execution context.""" + _pending_workflow_executions[user_id] = context + + +def delete_workflow_execution(user_id: str): + """Delete workflow execution context.""" + _pending_workflow_executions.pop(user_id, None) + + +def register_workflow_step_handlers(app: App, config: Config): + """Register custom workflow step handlers with the Slack app.""" + + github_service = GitHubService(config.github.token, config.github.org_name) + image_service = ImageService(config.border_color, config.border_width) + bio_service = None + if config.anthropic: + bio_service = BioService(config.anthropic.api_key, config.anthropic.model) + + @app.function("cdl_onboarding_step") + def handle_onboarding_step(inputs: dict, fail: Fail, client: WebClient, context, body: dict): + """ + Handle the CDL onboarding workflow step. + + This function is triggered when the custom step is executed in a workflow. + It receives the form data collected by previous workflow steps and sends + an approval request to the admin. + + Expected inputs (from workflow variables): + - submitter_id: Slack user ID of the new member + - name: Full name + - github_username: GitHub username + - bio: Short bio text + - website_url: Optional personal website + - photo_url: Optional URL to profile photo (from file upload) + + The workflow will remain paused until complete() or fail() is called. + """ + try: + submitter_id = inputs.get("submitter_id") + name = inputs.get("name", "") + github_username = inputs.get("github_username", "") + bio_raw = inputs.get("bio", "") + website_url = inputs.get("website_url", "") + photo_url = inputs.get("photo_url", "") + email = inputs.get("email", "") + + logger.info(f"Onboarding step triggered for {name} ({submitter_id})") + + if not submitter_id: + fail("Missing submitter ID") + return + + if not github_username: + fail("Missing GitHub username") + return + + # Validate GitHub username + is_valid, error_msg = github_service.validate_username(github_username) + if not is_valid: + fail(f"Invalid GitHub username '{github_username}': {error_msg}") + return + + # Get user info from Slack if name not provided + if not name: + try: + user_info = client.users_info(user=submitter_id) + name = user_info["user"]["real_name"] or user_info["user"]["name"] + if not email: + email = user_info["user"].get("profile", {}).get("email", "") + except SlackApiError as e: + logger.warning(f"Could not get user info: {e}") + name = "Unknown" + + # Open DM channel with the new member + try: + dm_response = client.conversations_open(users=[submitter_id]) + dm_channel = dm_response["channel"]["id"] + except SlackApiError as e: + logger.error(f"Error opening DM with user: {e}") + fail(f"Could not open DM with user: {e}") + return + + # Create onboarding request + request = OnboardingRequest( + slack_user_id=submitter_id, + slack_channel_id=dm_channel, + name=name, + email=email, + github_username=github_username, + bio_raw=bio_raw, + website_url=website_url, + ) + + # Process bio if service available + if bio_service and bio_raw: + edited_bio, bio_error = bio_service.edit_bio(bio_raw, name) + if edited_bio: + request.bio_edited = edited_bio + else: + logger.warning(f"Bio editing failed: {bio_error}") + + # Process photo if provided + if photo_url: + try: + processed_path = _process_photo_from_url( + photo_url, submitter_id, config, image_service + ) + if processed_path: + request.photo_processed_path = processed_path + except Exception as e: + logger.warning(f"Photo processing failed: {e}") + + request.update_status(OnboardingStatus.PENDING_APPROVAL) + save_request(request) + + # Save workflow execution context for completing after approval + # The function_execution_id is needed to complete() or fail() later + function_execution_id = body.get("function_data", {}).get("execution_id") + save_workflow_execution(submitter_id, { + "execution_id": function_execution_id, + "inputs": inputs, + "context": context, + }) + + # Send acknowledgment to the new member + try: + client.chat_postMessage( + channel=dm_channel, + text="Your onboarding information has been received!", + blocks=[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":white_check_mark: *Welcome to CDL!*\n\n" + "Your onboarding information has been submitted. " + "The lab admin will review it shortly.", + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*What's next:*\n" + f"• GitHub: Invitation to ContextLab organization\n" + f"• Calendar: Access to lab calendars\n" + f"• Website: Your profile will be added to context-lab.com", + }, + }, + ], + ) + except SlackApiError as e: + logger.error(f"Error sending acknowledgment: {e}") + + # Send approval request to admin + _send_workflow_approval_request(client, config, request, github_service) + + # NOTE: We do NOT call complete() here! + # The workflow stays paused until admin approves/rejects. + # complete() will be called from the approval handler. + + except Exception as e: + logger.exception(f"Error in onboarding step: {e}") + fail(f"Onboarding step failed: {e}") + + @app.function("cdl_offboarding_step") + def handle_offboarding_step(inputs: dict, fail: Fail, client: WebClient, complete: Complete): + """ + Handle the CDL offboarding workflow step. + + Expected inputs: + - submitter_id: Slack user ID of the departing member + - name: Full name (optional, can be looked up) + + This step notifies the admin and generates an offboarding checklist. + """ + try: + submitter_id = inputs.get("submitter_id") + name = inputs.get("name", "") + + if not submitter_id: + fail("Missing submitter ID") + return + + # Get user info if name not provided + if not name: + try: + user_info = client.users_info(user=submitter_id) + name = user_info["user"]["real_name"] or user_info["user"]["name"] + except SlackApiError: + name = "Unknown" + + logger.info(f"Offboarding step triggered for {name} ({submitter_id})") + + # Send offboarding notification to admin + try: + client.chat_postMessage( + channel=config.slack.admin_user_id, + text=f"Offboarding request from {name}", + blocks=[ + { + "type": "header", + "text": { + "type": "plain_text", + "text": ":wave: Offboarding Request", + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*{name}* (<@{submitter_id}>) has initiated the offboarding process.", + }, + }, + {"type": "divider"}, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Offboarding Checklist:*\n" + ":ballot_box_with_check: Remove from GitHub ContextLab organization\n" + ":ballot_box_with_check: Remove from lab calendars\n" + ":ballot_box_with_check: Update website (remove or move to alumni)\n" + ":ballot_box_with_check: Transfer any relevant files/data\n" + ":ballot_box_with_check: Update mailing lists", + }, + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": {"type": "plain_text", "text": "Start Offboarding"}, + "style": "primary", + "action_id": "start_offboarding_workflow", + "value": submitter_id, + }, + ], + }, + ], + ) + except SlackApiError as e: + logger.error(f"Error sending offboarding notification: {e}") + fail(f"Could not send offboarding notification: {e}") + return + + # Send confirmation to departing member + try: + dm_response = client.conversations_open(users=[submitter_id]) + dm_channel = dm_response["channel"]["id"] + client.chat_postMessage( + channel=dm_channel, + text="Thank you for your time with CDL!", + blocks=[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":wave: *Thank you for your time with CDL!*\n\n" + "The lab admin has been notified about your departure. " + "They'll handle the access revocations and website updates.", + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "If you have any questions or need anything, " + "feel free to reach out to the lab admin.", + }, + }, + ], + ) + except SlackApiError as e: + logger.warning(f"Could not send confirmation to departing member: {e}") + + # Complete the workflow step + complete({"status": "notified", "member_name": name}) + + except Exception as e: + logger.exception(f"Error in offboarding step: {e}") + fail(f"Offboarding step failed: {e}") + + +def _process_photo_from_url( + photo_url: str, + user_id: str, + config: Config, + image_service: ImageService, +) -> Optional[Path]: + """Download and process a photo from URL.""" + try: + # Download the file + headers = {"Authorization": f"Bearer {config.slack.bot_token}"} + response = requests.get(photo_url, headers=headers) + response.raise_for_status() + + # Save to temp file + with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as tmp: + tmp.write(response.content) + tmp_path = Path(tmp.name) + + # Validate the image + is_valid, error_msg = image_service.validate_image(tmp_path) + if not is_valid: + tmp_path.unlink() + logger.warning(f"Image validation failed: {error_msg}") + return None + + # Process the photo + output_path = config.output_dir / f"{user_id}_photo.png" + processed_path = image_service.add_hand_drawn_border( + tmp_path, output_path, seed=hash(user_id) + ) + + # Clean up temp file + tmp_path.unlink() + + return processed_path + + except Exception as e: + logger.error(f"Error processing photo: {e}") + return None + + +def _send_workflow_approval_request( + client: WebClient, + config: Config, + request: OnboardingRequest, + github_service: GitHubService, +): + """Send an approval request to the admin (for workflow-initiated onboarding).""" + # Get GitHub teams for the checkboxes + teams = github_service.get_teams() + + # Build team options + team_options = [] + initial_options = [] + for team in teams: + option = { + "text": {"type": "plain_text", "text": team["name"]}, + "value": str(team["id"]), + } + team_options.append(option) + if team["name"] == config.github.default_team: + initial_options.append(option) + + # Build the approval message + blocks = [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": ":clipboard: New Member - Join the Lab Request", + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*{request.name}* (<@{request.slack_user_id}>) has submitted the \"Join the lab\" form.", + }, + }, + {"type": "divider"}, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*GitHub Username:* `{request.github_username}`\n" + f"*Email:* {request.email or 'Not provided'}\n" + f"*Website:* {request.website_url or 'None'}", + }, + }, + ] + + # Add bio section + if request.bio_raw: + bio_preview = request.bio_raw[:300] + "..." if len(request.bio_raw) > 300 else request.bio_raw + blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*Original Bio:*\n>{bio_preview}", + }, + }) + + if request.bio_edited: + blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*Edited Bio (for website):*\n>{request.bio_edited}", + }, + }) + + # Photo status + if request.photo_processed_path: + blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":camera: *Photo:* Received and processed with CDL border", + }, + }) + else: + blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":camera: *Photo:* Not yet uploaded", + }, + }) + + blocks.append({"type": "divider"}) + + # GitHub team selection + if team_options: + blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Select GitHub teams to add this member to:*", + }, + "accessory": { + "type": "checkboxes", + "action_id": "github_teams_select", + "options": team_options[:10], # Slack limits to 10 options + "initial_options": initial_options if initial_options else None, + }, + }) + + # Calendar permissions info + blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Calendar Permissions (defaults):*\n" + "• Contextual Dynamics Lab: Read-only\n" + "• Out of lab: Edit\n" + "• CDL Resources: Edit", + }, + }) + + blocks.append({"type": "divider"}) + + # Action buttons - using workflow-specific action IDs + blocks.append({ + "type": "actions", + "block_id": f"workflow_approval_actions_{request.slack_user_id}", + "elements": [ + { + "type": "button", + "text": {"type": "plain_text", "text": "Approve"}, + "style": "primary", + "action_id": "approve_workflow_onboarding", + "value": request.slack_user_id, + }, + { + "type": "button", + "text": {"type": "plain_text", "text": "Reject"}, + "style": "danger", + "action_id": "reject_workflow_onboarding", + "value": request.slack_user_id, + }, + { + "type": "button", + "text": {"type": "plain_text", "text": "Request Changes"}, + "action_id": "request_changes_workflow_onboarding", + "value": request.slack_user_id, + }, + ], + }) + + # Send to admin + try: + result = client.chat_postMessage( + channel=config.slack.admin_user_id, + text=f"New member request from {request.name}", + blocks=blocks, + ) + request.admin_approval_message_ts = result["ts"] + save_request(request) + except SlackApiError as e: + logger.error(f"Error sending approval request: {e}") + + +def complete_workflow_onboarding(user_id: str, success: bool, outputs: dict = None): + """ + Complete a pending workflow onboarding step. + + Called from the approval handler after admin approves/rejects. + + Args: + user_id: Slack user ID of the onboarding member + success: Whether the onboarding was approved + outputs: Output values to return to the workflow + """ + execution = get_workflow_execution(user_id) + if not execution: + logger.warning(f"No workflow execution found for user {user_id}") + return False + + # The complete/fail functions would need to be called with the execution context + # This is handled by storing the context and using Slack's API + delete_workflow_execution(user_id) + return True diff --git a/scripts/onboarding/models/__init__.py b/scripts/onboarding/models/__init__.py new file mode 100644 index 0000000..6109c0a --- /dev/null +++ b/scripts/onboarding/models/__init__.py @@ -0,0 +1,5 @@ +"""Data models for onboarding.""" + +from .onboarding_request import OnboardingRequest, OnboardingStatus + +__all__ = ["OnboardingRequest", "OnboardingStatus"] diff --git a/scripts/onboarding/models/onboarding_request.py b/scripts/onboarding/models/onboarding_request.py new file mode 100644 index 0000000..5bb49d1 --- /dev/null +++ b/scripts/onboarding/models/onboarding_request.py @@ -0,0 +1,146 @@ +""" +Data models for onboarding requests. +""" + +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from pathlib import Path +from typing import Optional + + +class OnboardingStatus(Enum): + """Status of an onboarding request.""" + PENDING_INFO = "pending_info" # Waiting for member to provide info + PENDING_APPROVAL = "pending_approval" # Waiting for admin approval + GITHUB_PENDING = "github_pending" # GitHub invite sent, awaiting acceptance + CALENDAR_PENDING = "calendar_pending" # Calendar invites being sent + PHOTO_PENDING = "photo_pending" # Waiting for photo upload + PROCESSING = "processing" # Processing bio/photo + READY_FOR_WEBSITE = "ready_for_website" # Ready for website update + COMPLETED = "completed" + REJECTED = "rejected" + ERROR = "error" + + +@dataclass +class OnboardingRequest: + """ + Represents an onboarding request for a new lab member. + + Tracks all information needed to complete the onboarding process. + """ + # Slack identifiers + slack_user_id: str + slack_channel_id: str # DM channel with the new member + + # Basic info + name: str = "" + email: str = "" + + # GitHub + github_username: str = "" + github_teams: list = field(default_factory=list) + github_invitation_sent: bool = False + + # Google Calendar + calendar_permissions: dict = field(default_factory=dict) + calendar_invites_sent: bool = False + + # Website info + bio_raw: str = "" + bio_edited: str = "" + website_url: str = "" + + # Photo + photo_original_path: Optional[Path] = None + photo_processed_path: Optional[Path] = None + + # Status tracking + status: OnboardingStatus = OnboardingStatus.PENDING_INFO + created_at: datetime = field(default_factory=datetime.now) + updated_at: datetime = field(default_factory=datetime.now) + error_message: str = "" + + # Admin approval tracking + admin_approval_message_ts: str = "" # Timestamp of the approval message in Slack + approved_by: str = "" # Admin who approved + + def update_status(self, new_status: OnboardingStatus, error_message: str = ""): + """Update the status and timestamp.""" + self.status = new_status + self.updated_at = datetime.now() + if error_message: + self.error_message = error_message + + def to_dict(self) -> dict: + """Convert to dictionary for JSON serialization.""" + return { + "slack_user_id": self.slack_user_id, + "slack_channel_id": self.slack_channel_id, + "name": self.name, + "email": self.email, + "github_username": self.github_username, + "github_teams": self.github_teams, + "github_invitation_sent": self.github_invitation_sent, + "calendar_permissions": self.calendar_permissions, + "calendar_invites_sent": self.calendar_invites_sent, + "bio_raw": self.bio_raw, + "bio_edited": self.bio_edited, + "website_url": self.website_url, + "photo_original_path": str(self.photo_original_path) if self.photo_original_path else None, + "photo_processed_path": str(self.photo_processed_path) if self.photo_processed_path else None, + "status": self.status.value, + "created_at": self.created_at.isoformat(), + "updated_at": self.updated_at.isoformat(), + "error_message": self.error_message, + "admin_approval_message_ts": self.admin_approval_message_ts, + "approved_by": self.approved_by, + } + + @classmethod + def from_dict(cls, data: dict) -> "OnboardingRequest": + """Create from dictionary.""" + return cls( + slack_user_id=data["slack_user_id"], + slack_channel_id=data["slack_channel_id"], + name=data.get("name", ""), + email=data.get("email", ""), + github_username=data.get("github_username", ""), + github_teams=data.get("github_teams", []), + github_invitation_sent=data.get("github_invitation_sent", False), + calendar_permissions=data.get("calendar_permissions", {}), + calendar_invites_sent=data.get("calendar_invites_sent", False), + bio_raw=data.get("bio_raw", ""), + bio_edited=data.get("bio_edited", ""), + website_url=data.get("website_url", ""), + photo_original_path=Path(data["photo_original_path"]) if data.get("photo_original_path") else None, + photo_processed_path=Path(data["photo_processed_path"]) if data.get("photo_processed_path") else None, + status=OnboardingStatus(data.get("status", "pending_info")), + created_at=datetime.fromisoformat(data["created_at"]) if data.get("created_at") else datetime.now(), + updated_at=datetime.fromisoformat(data["updated_at"]) if data.get("updated_at") else datetime.now(), + error_message=data.get("error_message", ""), + admin_approval_message_ts=data.get("admin_approval_message_ts", ""), + approved_by=data.get("approved_by", ""), + ) + + def get_summary(self) -> str: + """Get a human-readable summary of the request.""" + lines = [ + f"*Name:* {self.name or 'Not provided'}", + f"*Email:* {self.email or 'Not provided'}", + f"*GitHub:* {self.github_username or 'Not provided'}", + f"*Status:* {self.status.value}", + ] + + if self.github_teams: + lines.append(f"*GitHub Teams:* {', '.join(self.github_teams)}") + + if self.bio_raw: + bio_preview = self.bio_raw[:100] + "..." if len(self.bio_raw) > 100 else self.bio_raw + lines.append(f"*Bio:* {bio_preview}") + + if self.website_url: + lines.append(f"*Website:* {self.website_url}") + + return "\n".join(lines) diff --git a/scripts/onboarding/requirements.txt b/scripts/onboarding/requirements.txt new file mode 100644 index 0000000..57bf9b2 --- /dev/null +++ b/scripts/onboarding/requirements.txt @@ -0,0 +1,27 @@ +# CDL Onboarding Bot Dependencies + +# Slack integration +slack-bolt>=1.18.0 +slack-sdk>=3.21.0 + +# GitHub integration +PyGithub>=2.1.0 + +# Google Calendar integration +google-api-python-client>=2.100.0 +google-auth>=2.23.0 +google-auth-oauthlib>=1.1.0 +google-auth-httplib2>=0.1.1 + +# Image processing +Pillow>=10.0.0 + +# Bio editing (Claude API) +anthropic>=0.7.0 + +# Configuration +python-dotenv>=1.0.0 + +# Testing +pytest>=7.4.0 +pytest-asyncio>=0.21.0 diff --git a/scripts/onboarding/services/__init__.py b/scripts/onboarding/services/__init__.py new file mode 100644 index 0000000..81baeba --- /dev/null +++ b/scripts/onboarding/services/__init__.py @@ -0,0 +1,8 @@ +"""Service modules for external integrations.""" + +from .github_service import GitHubService +from .calendar_service import CalendarService +from .image_service import ImageService +from .bio_service import BioService + +__all__ = ["GitHubService", "CalendarService", "ImageService", "BioService"] diff --git a/scripts/onboarding/services/bio_service.py b/scripts/onboarding/services/bio_service.py new file mode 100644 index 0000000..0f5fb1e --- /dev/null +++ b/scripts/onboarding/services/bio_service.py @@ -0,0 +1,237 @@ +""" +Bio editing service using Claude API. + +Edits member bios to follow CDL style guidelines: +- Third person voice +- Uses first names only +- 3-4 sentences maximum +- Clear, engaging, fun style +- No inappropriate or private information +""" + +import logging +import re +from typing import Optional + +import anthropic + +logger = logging.getLogger(__name__) + + +class BioService: + """Service for editing member bios using Claude API.""" + + # Style guidelines for bio editing + STYLE_GUIDELINES = """ +Style guidelines for CDL lab member bios: +1. Use third person voice (e.g., "Jane studies..." not "I study...") +2. Use first names only after the first mention +3. Keep it to 3-4 sentences maximum +4. Write in a clear, engaging, and fun style +5. Focus on research interests and personality +6. Remove any private information (addresses, phone numbers, personal emails) +7. Remove any inappropriate content +8. Match the tone of existing CDL bios - professional but personable +""" + + # Example bios for few-shot learning + EXAMPLE_BIOS = """ +Example edited bios from the CDL website: + +Example 1: +"Jeremy is an Associate Professor of Psychological and Brain Sciences at Dartmouth and directs the Contextual Dynamics Lab. He enjoys thinking about brains, computers, and cats." + +Example 2: +"Paxton graduated from Dartmouth in 2019 with a BA in neuroscience and is continuing his research in the lab. He's interested in how we represent and understand narratives and how those processes relate to memory." + +Example 3: +"Lucy joined the lab as a research assistant after graduating from Dartmouth. She's excited to explore computational approaches to understanding memory and cognition." +""" + + def __init__(self, api_key: str, model: str = "claude-sonnet-4-20250514"): + """ + Initialize the bio service. + + Args: + api_key: Anthropic API key + model: Claude model to use + """ + self.client = anthropic.Anthropic(api_key=api_key) + self.model = model + + def edit_bio(self, raw_bio: str, name: str) -> tuple[str, Optional[str]]: + """ + Edit a bio to match CDL style guidelines. + + Args: + raw_bio: The original bio text from the user + name: The member's full name + + Returns: + Tuple of (edited_bio, error_message) + """ + if not raw_bio.strip(): + return "", "No bio text provided" + + # Extract first name for the prompt + first_name = name.split()[0] if name else "the member" + + prompt = f"""Please edit the following bio to match our lab's style guidelines. + +{self.STYLE_GUIDELINES} + +{self.EXAMPLE_BIOS} + +Member's name: {name} +First name to use: {first_name} + +Original bio: +{raw_bio} + +Please provide ONLY the edited bio text, with no additional commentary, explanations, or quotation marks. The bio should be ready to publish as-is.""" + + try: + message = self.client.messages.create( + model=self.model, + max_tokens=500, + messages=[{"role": "user", "content": prompt}], + ) + + edited_bio = message.content[0].text.strip() + + # Clean up any stray quotation marks + edited_bio = edited_bio.strip('"\'') + + # Validate the output + is_valid, validation_error = self._validate_bio(edited_bio, first_name) + if not is_valid: + logger.warning(f"Bio validation warning: {validation_error}") + + logger.info(f"Edited bio for {name}: {len(raw_bio)} -> {len(edited_bio)} chars") + return edited_bio, None + + except anthropic.APIError as e: + error_msg = f"Claude API error: {e}" + logger.error(error_msg) + return "", error_msg + except Exception as e: + error_msg = f"Error editing bio: {e}" + logger.error(error_msg) + return "", error_msg + + def _validate_bio(self, bio: str, first_name: str) -> tuple[bool, Optional[str]]: + """ + Validate that an edited bio meets our guidelines. + + Args: + bio: The edited bio text + first_name: The member's first name + + Returns: + Tuple of (is_valid, warning_message) + """ + warnings = [] + + # Check length (rough sentence count) + sentences = [s.strip() for s in re.split(r'[.!?]+', bio) if s.strip()] + if len(sentences) > 5: + warnings.append(f"Bio has {len(sentences)} sentences (recommended: 3-4)") + + # Check for first-person pronouns + first_person_pattern = r'\b(I|me|my|myself|we|us|our|ourselves)\b' + if re.search(first_person_pattern, bio, re.IGNORECASE): + warnings.append("Bio contains first-person pronouns") + + # Check that the first name is used + if first_name.lower() not in bio.lower(): + warnings.append(f"Bio doesn't mention '{first_name}'") + + # Check for potential private info patterns + phone_pattern = r'\b\d{3}[-.\s]?\d{3}[-.\s]?\d{4}\b' + email_pattern = r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b' + + if re.search(phone_pattern, bio): + warnings.append("Bio may contain a phone number") + if re.search(email_pattern, bio): + warnings.append("Bio may contain an email address") + + if warnings: + return False, "; ".join(warnings) + return True, None + + def suggest_improvements(self, bio: str, name: str) -> tuple[str, Optional[str]]: + """ + Get suggestions for improving a bio without fully rewriting it. + + Args: + bio: The current bio text + name: The member's full name + + Returns: + Tuple of (suggestions, error_message) + """ + prompt = f"""Review this lab member bio and suggest specific improvements. + +{self.STYLE_GUIDELINES} + +Member's name: {name} + +Current bio: +{bio} + +Please provide a brief list of specific suggestions for improvement. Focus on: +1. Tone and voice +2. Length appropriateness +3. Content that should be added or removed +4. Any style issues + +Keep your response concise and actionable.""" + + try: + message = self.client.messages.create( + model=self.model, + max_tokens=500, + messages=[{"role": "user", "content": prompt}], + ) + + suggestions = message.content[0].text.strip() + return suggestions, None + + except Exception as e: + error_msg = f"Error getting suggestions: {e}" + logger.error(error_msg) + return "", error_msg + + def check_for_private_info(self, text: str) -> list[str]: + """ + Check text for potential private or inappropriate information. + + Args: + text: Text to check + + Returns: + List of warnings about potential private info + """ + warnings = [] + + # Phone numbers + phone_pattern = r'\b\d{3}[-.\s]?\d{3}[-.\s]?\d{4}\b' + if re.search(phone_pattern, text): + warnings.append("Possible phone number detected") + + # Email addresses + email_pattern = r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b' + if re.search(email_pattern, text): + warnings.append("Possible email address detected") + + # Street addresses (basic pattern) + address_pattern = r'\b\d+\s+[A-Za-z]+\s+(Street|St|Avenue|Ave|Road|Rd|Drive|Dr|Lane|Ln|Court|Ct|Boulevard|Blvd)\b' + if re.search(address_pattern, text, re.IGNORECASE): + warnings.append("Possible street address detected") + + # Social security numbers + ssn_pattern = r'\b\d{3}[-.\s]?\d{2}[-.\s]?\d{4}\b' + if re.search(ssn_pattern, text): + warnings.append("Possible SSN detected") + + return warnings diff --git a/scripts/onboarding/services/calendar_service.py b/scripts/onboarding/services/calendar_service.py new file mode 100644 index 0000000..edf6a92 --- /dev/null +++ b/scripts/onboarding/services/calendar_service.py @@ -0,0 +1,231 @@ +""" +Google Calendar service for sharing calendars. + +Handles: +- Calendar listing +- Sharing calendars with users +- Managing permissions (ACL) +""" + +import logging +from typing import Optional + +from google.oauth2.service_account import Credentials +from googleapiclient.discovery import build +from googleapiclient.errors import HttpError + +logger = logging.getLogger(__name__) + + +class CalendarService: + """Service for Google Calendar operations.""" + + SCOPES = ["https://www.googleapis.com/auth/calendar"] + + # Permission role mappings + ROLE_READER = "reader" # Can see event details + ROLE_WRITER = "writer" # Can create, edit, delete events + ROLE_OWNER = "owner" # Full control + + def __init__(self, credentials_file: str, calendars: Optional[dict] = None): + """ + Initialize the Calendar service. + + Args: + credentials_file: Path to the Google service account JSON file + calendars: Optional dictionary mapping calendar names to IDs + """ + self.credentials_file = credentials_file + self.calendars = calendars or {} + self._service = None + + @property + def service(self): + """Get the Calendar API service (lazy loaded).""" + if self._service is None: + creds = Credentials.from_service_account_file( + self.credentials_file, scopes=self.SCOPES + ) + self._service = build("calendar", "v3", credentials=creds) + return self._service + + def list_calendars(self) -> list[dict]: + """ + List all calendars accessible to the service account. + + Returns: + List of calendar dictionaries with id, summary, and description + """ + calendars = [] + try: + calendar_list = self.service.calendarList().list().execute() + for calendar in calendar_list.get("items", []): + calendars.append({ + "id": calendar["id"], + "summary": calendar.get("summary", ""), + "description": calendar.get("description", ""), + "access_role": calendar.get("accessRole", ""), + }) + logger.info(f"Retrieved {len(calendars)} calendars") + except HttpError as e: + logger.error(f"Error listing calendars: {e}") + return calendars + + def get_calendar_id(self, name: str) -> Optional[str]: + """ + Get a calendar ID by its name. + + Args: + name: Calendar name (as configured) + + Returns: + Calendar ID or None if not found + """ + return self.calendars.get(name) + + def share_calendar( + self, + calendar_id: str, + email: str, + role: str = ROLE_READER, + send_notifications: bool = True, + ) -> tuple[bool, Optional[str]]: + """ + Share a calendar with a user. + + Args: + calendar_id: The calendar's ID + email: User's email to share with + role: Permission level (reader, writer, owner) + send_notifications: Whether to send email notification + + Returns: + Tuple of (success, error_message) + """ + try: + acl_rule = { + "scope": {"type": "user", "value": email}, + "role": role, + } + + result = ( + self.service.acl() + .insert( + calendarId=calendar_id, + body=acl_rule, + sendNotifications=send_notifications, + ) + .execute() + ) + + logger.info( + f"Shared calendar {calendar_id} with {email} as {role} " + f"(rule ID: {result.get('id')})" + ) + return True, None + + except HttpError as e: + error_msg = f"Error sharing calendar with {email}: {e}" + logger.error(error_msg) + return False, error_msg + + def share_multiple_calendars( + self, + email: str, + calendar_permissions: dict[str, str], + send_notifications: bool = True, + ) -> dict[str, tuple[bool, Optional[str]]]: + """ + Share multiple calendars with a user. + + Args: + email: User's email to share with + calendar_permissions: Dictionary mapping calendar names to roles + send_notifications: Whether to send email notifications + + Returns: + Dictionary mapping calendar names to (success, error_message) tuples + """ + results = {} + + for calendar_name, role in calendar_permissions.items(): + calendar_id = self.get_calendar_id(calendar_name) + if not calendar_id: + results[calendar_name] = ( + False, + f"Calendar '{calendar_name}' not configured", + ) + continue + + success, error = self.share_calendar( + calendar_id=calendar_id, + email=email, + role=role, + send_notifications=send_notifications, + ) + results[calendar_name] = (success, error) + + return results + + def remove_calendar_access( + self, calendar_id: str, email: str + ) -> tuple[bool, Optional[str]]: + """ + Remove a user's access to a calendar. + + Args: + calendar_id: The calendar's ID + email: User's email to remove + + Returns: + Tuple of (success, error_message) + """ + try: + # First, find the ACL rule ID for this user + acl_list = self.service.acl().list(calendarId=calendar_id).execute() + + rule_id = None + for rule in acl_list.get("items", []): + scope = rule.get("scope", {}) + if scope.get("type") == "user" and scope.get("value") == email: + rule_id = rule.get("id") + break + + if not rule_id: + return True, None # User doesn't have access, nothing to remove + + # Delete the ACL rule + self.service.acl().delete(calendarId=calendar_id, ruleId=rule_id).execute() + + logger.info(f"Removed {email}'s access to calendar {calendar_id}") + return True, None + + except HttpError as e: + error_msg = f"Error removing calendar access for {email}: {e}" + logger.error(error_msg) + return False, error_msg + + def get_user_permissions(self, calendar_id: str, email: str) -> Optional[str]: + """ + Get a user's current permission level for a calendar. + + Args: + calendar_id: The calendar's ID + email: User's email + + Returns: + Permission role or None if no access + """ + try: + acl_list = self.service.acl().list(calendarId=calendar_id).execute() + + for rule in acl_list.get("items", []): + scope = rule.get("scope", {}) + if scope.get("type") == "user" and scope.get("value") == email: + return rule.get("role") + + return None + + except HttpError as e: + logger.error(f"Error getting permissions for {email}: {e}") + return None diff --git a/scripts/onboarding/services/github_service.py b/scripts/onboarding/services/github_service.py new file mode 100644 index 0000000..cc08e45 --- /dev/null +++ b/scripts/onboarding/services/github_service.py @@ -0,0 +1,248 @@ +""" +GitHub service for organization management. + +Handles: +- Username validation +- Team listing +- Organization invitations +""" + +import logging +from typing import Optional + +from github import Github, GithubException +from github.NamedUser import NamedUser +from github.Organization import Organization +from github.Team import Team + +logger = logging.getLogger(__name__) + + +class GitHubService: + """Service for GitHub organization operations.""" + + def __init__(self, token: str, org_name: str = "ContextLab"): + """ + Initialize the GitHub service. + + Args: + token: GitHub personal access token with admin:org scope + org_name: Name of the GitHub organization + """ + self.github = Github(token) + self.org_name = org_name + self._org: Optional[Organization] = None + + @property + def org(self) -> Organization: + """Get the organization object (lazy loaded).""" + if self._org is None: + self._org = self.github.get_organization(self.org_name) + return self._org + + def validate_username(self, username: str) -> tuple[bool, Optional[str]]: + """ + Check if a GitHub username exists and is valid. + + Args: + username: GitHub username to validate + + Returns: + Tuple of (is_valid, error_message) + """ + try: + user = self.github.get_user(username) + # Access a property to ensure the user exists + _ = user.login + logger.info(f"Validated GitHub username: {username}") + return True, None + except GithubException as e: + if e.status == 404: + error_msg = f"GitHub user '{username}' not found" + logger.warning(error_msg) + return False, error_msg + else: + error_msg = f"Error validating GitHub user '{username}': {e}" + logger.error(error_msg) + return False, error_msg + + def get_user(self, username: str) -> Optional[NamedUser]: + """ + Get a GitHub user by username. + + Args: + username: GitHub username + + Returns: + NamedUser object or None if not found + """ + try: + return self.github.get_user(username) + except GithubException: + return None + + def get_teams(self) -> list[dict]: + """ + Get all teams in the organization. + + Returns: + List of team dictionaries with id, name, slug, and description + """ + teams = [] + try: + for team in self.org.get_teams(): + teams.append({ + "id": team.id, + "name": team.name, + "slug": team.slug, + "description": team.description or "", + }) + logger.info(f"Retrieved {len(teams)} teams from {self.org_name}") + except GithubException as e: + logger.error(f"Error retrieving teams: {e}") + return teams + + def get_team_by_name(self, team_name: str) -> Optional[Team]: + """ + Get a team by its name. + + Args: + team_name: Name of the team + + Returns: + Team object or None if not found + """ + try: + for team in self.org.get_teams(): + if team.name == team_name: + return team + except GithubException as e: + logger.error(f"Error finding team '{team_name}': {e}") + return None + + def get_team_by_id(self, team_id: int) -> Optional[Team]: + """ + Get a team by its ID. + + Args: + team_id: ID of the team + + Returns: + Team object or None if not found + """ + try: + return self.org.get_team(team_id) + except GithubException as e: + logger.error(f"Error getting team {team_id}: {e}") + return None + + def check_membership(self, username: str) -> bool: + """ + Check if a user is already a member of the organization. + + Args: + username: GitHub username + + Returns: + True if the user is a member, False otherwise + """ + try: + return self.org.has_in_members(self.github.get_user(username)) + except GithubException: + return False + + def invite_user( + self, + username: str, + team_ids: Optional[list[int]] = None, + role: str = "direct_member", + ) -> tuple[bool, Optional[str]]: + """ + Invite a user to the organization and optionally to specific teams. + + Args: + username: GitHub username to invite + team_ids: List of team IDs to add the user to + role: Role in the organization (direct_member, admin, billing_manager) + + Returns: + Tuple of (success, error_message) + """ + try: + user = self.github.get_user(username) + + # Check if already a member + if self.check_membership(username): + logger.info(f"User {username} is already a member of {self.org_name}") + # If they're already a member, just add to teams + if team_ids: + for team_id in team_ids: + team = self.get_team_by_id(team_id) + if team: + team.add_membership(user, role="member") + logger.info(f"Added {username} to team {team.name}") + return True, None + + # Get team objects + teams = [] + if team_ids: + for team_id in team_ids: + team = self.get_team_by_id(team_id) + if team: + teams.append(team) + + # Send invitation + if teams: + self.org.invite_user(user=user, role=role, teams=teams) + else: + self.org.invite_user(user=user, role=role) + + logger.info(f"Sent organization invitation to {username}") + return True, None + + except GithubException as e: + error_msg = f"Error inviting {username}: {e}" + logger.error(error_msg) + return False, error_msg + + def remove_member(self, username: str) -> tuple[bool, Optional[str]]: + """ + Remove a user from the organization. + + Note: This should only be done after admin confirmation. + + Args: + username: GitHub username to remove + + Returns: + Tuple of (success, error_message) + """ + try: + user = self.github.get_user(username) + self.org.remove_from_membership(user) + logger.info(f"Removed {username} from {self.org_name}") + return True, None + except GithubException as e: + error_msg = f"Error removing {username}: {e}" + logger.error(error_msg) + return False, error_msg + + def get_pending_invitations(self) -> list[dict]: + """ + Get list of pending organization invitations. + + Returns: + List of invitation dictionaries + """ + invitations = [] + try: + for inv in self.org.invitations(): + invitations.append({ + "id": inv.id, + "login": inv.login, + "email": inv.email, + "created_at": inv.created_at.isoformat() if inv.created_at else None, + }) + except GithubException as e: + logger.error(f"Error getting invitations: {e}") + return invitations diff --git a/scripts/onboarding/services/image_service.py b/scripts/onboarding/services/image_service.py new file mode 100644 index 0000000..ab6d70e --- /dev/null +++ b/scripts/onboarding/services/image_service.py @@ -0,0 +1,322 @@ +""" +Image processing service for adding hand-drawn borders to member photos. + +The borders match the style used on https://www.context-lab.com/people +with Dartmouth green color and slight variations for a hand-drawn look. +""" + +import logging +import math +import random +from pathlib import Path +from typing import Optional, Union + +from PIL import Image, ImageDraw + +logger = logging.getLogger(__name__) + + +class ImageService: + """Service for processing member profile photos.""" + + # Dartmouth green RGB + DARTMOUTH_GREEN = (0, 105, 62) + + # Default settings + DEFAULT_BORDER_WIDTH = 8 + DEFAULT_OUTPUT_SIZE = (400, 400) # Square output + + def __init__( + self, + border_color: tuple = DARTMOUTH_GREEN, + border_width: int = DEFAULT_BORDER_WIDTH, + ): + """ + Initialize the image service. + + Args: + border_color: RGB tuple for border color + border_width: Width of the border in pixels + """ + self.border_color = border_color + self.border_width = border_width + + def add_hand_drawn_border( + self, + input_path: Union[str, Path], + output_path: Union[str, Path], + border_width: Optional[int] = None, + wobble_amount: float = 1.5, + seed: Optional[int] = None, + ) -> Path: + """ + Add a hand-drawn style green border to an image. + + The border has slight random variations to simulate a hand-drawn look, + making each image unique while maintaining consistency. + + Args: + input_path: Path to the input image + output_path: Path for the output image + border_width: Width of the border (uses default if not specified) + wobble_amount: Maximum pixels of random variation (0 = straight lines) + seed: Random seed for reproducible results + + Returns: + Path to the processed image + """ + if seed is not None: + random.seed(seed) + + input_path = Path(input_path) + output_path = Path(output_path) + border_width = border_width or self.border_width + + # Open and process the image + img = Image.open(input_path) + + # Convert to RGBA for transparency support + if img.mode != "RGBA": + img = img.convert("RGBA") + + # Make it square (center crop if needed) + img = self._make_square(img) + + # Resize to standard size + img = img.resize(self.DEFAULT_OUTPUT_SIZE, Image.Resampling.LANCZOS) + + width, height = img.size + + # Create a new image with space for the border + border_padding = border_width + int(wobble_amount) + 2 + new_size = (width + 2 * border_padding, height + 2 * border_padding) + new_img = Image.new("RGBA", new_size, (255, 255, 255, 0)) + + # Paste the original image centered + new_img.paste(img, (border_padding, border_padding)) + + # Draw the hand-drawn border + draw = ImageDraw.Draw(new_img) + self._draw_wobbly_border( + draw, + offset=border_padding, + width=width, + height=height, + stroke_width=border_width, + wobble=wobble_amount, + ) + + # Save the result + output_path.parent.mkdir(parents=True, exist_ok=True) + + # Convert to RGB if saving as JPEG + if output_path.suffix.lower() in [".jpg", ".jpeg"]: + # Create white background + rgb_img = Image.new("RGB", new_img.size, (255, 255, 255)) + rgb_img.paste(new_img, mask=new_img.split()[3] if new_img.mode == "RGBA" else None) + rgb_img.save(output_path, quality=95) + else: + new_img.save(output_path) + + logger.info(f"Processed image saved to {output_path}") + return output_path + + def _make_square(self, img: Image.Image) -> Image.Image: + """Center crop an image to make it square.""" + width, height = img.size + + if width == height: + return img + + # Determine crop dimensions + size = min(width, height) + left = (width - size) // 2 + top = (height - size) // 2 + right = left + size + bottom = top + size + + return img.crop((left, top, right, bottom)) + + def _draw_wobbly_border( + self, + draw: ImageDraw.ImageDraw, + offset: int, + width: int, + height: int, + stroke_width: int, + wobble: float, + ): + """ + Draw a border with hand-drawn wobble effect. + + Uses multiple overlapping lines with slight variations to create + a natural, hand-drawn appearance. + """ + # Draw multiple passes for a more natural look + for pass_num in range(3): + # Slightly vary the stroke width for each pass + current_width = stroke_width - pass_num + + if current_width <= 0: + continue + + # Generate wobbly points for each edge + # Top edge + top_points = self._generate_wobbly_line( + start=(offset, offset), + end=(offset + width, offset), + wobble=wobble, + step=4, + ) + + # Right edge + right_points = self._generate_wobbly_line( + start=(offset + width, offset), + end=(offset + width, offset + height), + wobble=wobble, + step=4, + ) + + # Bottom edge + bottom_points = self._generate_wobbly_line( + start=(offset + width, offset + height), + end=(offset, offset + height), + wobble=wobble, + step=4, + ) + + # Left edge + left_points = self._generate_wobbly_line( + start=(offset, offset + height), + end=(offset, offset), + wobble=wobble, + step=4, + ) + + # Draw the lines + for points in [top_points, right_points, bottom_points, left_points]: + if len(points) >= 2: + draw.line(points, fill=self.border_color, width=current_width) + + def _generate_wobbly_line( + self, + start: tuple, + end: tuple, + wobble: float, + step: int = 4, + ) -> list: + """ + Generate points for a wobbly line between two points. + + Args: + start: Starting point (x, y) + end: Ending point (x, y) + wobble: Maximum random offset in pixels + step: Distance between points + + Returns: + List of (x, y) tuples + """ + points = [] + + dx = end[0] - start[0] + dy = end[1] - start[1] + length = math.sqrt(dx * dx + dy * dy) + + if length == 0: + return [start, end] + + # Number of segments + num_points = max(2, int(length / step)) + + for i in range(num_points + 1): + t = i / num_points + + # Base position + x = start[0] + t * dx + y = start[1] + t * dy + + # Add wobble (perpendicular to line direction) + if 0 < i < num_points: # Don't wobble endpoints + # Perpendicular direction + perp_x = -dy / length + perp_y = dx / length + + # Random offset with smooth variation + offset = random.uniform(-wobble, wobble) + + # Apply Perlin-like smoothing using sin waves + smooth_factor = math.sin(t * math.pi) * 0.5 + 0.5 + offset *= smooth_factor + + x += perp_x * offset + y += perp_y * offset + + points.append((x, y)) + + return points + + def process_photo( + self, + input_path: Union[str, Path], + output_dir: Union[str, Path], + member_id: str, + ) -> Path: + """ + Process a member's photo for the website. + + Args: + input_path: Path to the original photo + output_dir: Directory to save processed photos + member_id: Unique identifier for the member (used in filename) + + Returns: + Path to the processed photo + """ + input_path = Path(input_path) + output_dir = Path(output_dir) + + # Generate output filename + output_filename = f"{member_id}_bordered.png" + output_path = output_dir / output_filename + + # Process with a random but reproducible seed based on member_id + seed = hash(member_id) % (2**32) + + return self.add_hand_drawn_border( + input_path=input_path, + output_path=output_path, + seed=seed, + ) + + def validate_image(self, image_path: Union[str, Path]) -> tuple[bool, Optional[str]]: + """ + Validate that an image is suitable for processing. + + Args: + image_path: Path to the image file + + Returns: + Tuple of (is_valid, error_message) + """ + image_path = Path(image_path) + + if not image_path.exists(): + return False, f"Image file not found: {image_path}" + + try: + with Image.open(image_path) as img: + width, height = img.size + + # Check minimum size + if width < 200 or height < 200: + return False, f"Image too small ({width}x{height}). Minimum is 200x200." + + # Check format + if img.format not in ["JPEG", "PNG", "GIF", "WEBP"]: + return False, f"Unsupported image format: {img.format}" + + return True, None + + except Exception as e: + return False, f"Error reading image: {e}" diff --git a/tests/test_onboarding/__init__.py b/tests/test_onboarding/__init__.py new file mode 100644 index 0000000..022c0eb --- /dev/null +++ b/tests/test_onboarding/__init__.py @@ -0,0 +1,12 @@ +"""Tests for the CDL Onboarding Bot. + +IMPORTANT: These tests use REAL API calls as per project requirements. +Ensure you have valid credentials set up before running tests. + +Test Categories: +- test_github_service.py: GitHub API integration tests +- test_calendar_service.py: Google Calendar API tests +- test_image_service.py: Image processing tests +- test_bio_service.py: Claude API bio editing tests +- test_models.py: Data model tests +""" diff --git a/tests/test_onboarding/conftest.py b/tests/test_onboarding/conftest.py new file mode 100644 index 0000000..5a42845 --- /dev/null +++ b/tests/test_onboarding/conftest.py @@ -0,0 +1,95 @@ +""" +Pytest configuration and fixtures for onboarding tests. + +IMPORTANT: These tests use REAL API calls. Ensure credentials are configured. + +Environment variables required for full test suite: +- GITHUB_TOKEN: For GitHub API tests +- ANTHROPIC_API_KEY: For Claude bio editing tests +- GOOGLE_CREDENTIALS_FILE: For Calendar tests (optional) + +Tests will skip if required credentials are not available. +""" + +import os +import tempfile +from pathlib import Path + +import pytest + +# Ensure scripts package is importable +import sys +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + + +@pytest.fixture +def github_token(): + """Get GitHub token from environment.""" + token = os.environ.get("GITHUB_TOKEN") + if not token: + pytest.skip("GITHUB_TOKEN not set - skipping GitHub API tests") + return token + + +@pytest.fixture +def anthropic_api_key(): + """Get Anthropic API key from environment.""" + key = os.environ.get("ANTHROPIC_API_KEY") + if not key: + pytest.skip("ANTHROPIC_API_KEY not set - skipping Claude API tests") + return key + + +@pytest.fixture +def google_credentials_file(): + """Get Google credentials file path from environment.""" + path = os.environ.get("GOOGLE_CREDENTIALS_FILE") + if not path or not Path(path).exists(): + pytest.skip("GOOGLE_CREDENTIALS_FILE not set or file not found - skipping Calendar tests") + return path + + +@pytest.fixture +def temp_dir(): + """Create a temporary directory for test outputs.""" + with tempfile.TemporaryDirectory() as tmpdir: + yield Path(tmpdir) + + +@pytest.fixture +def sample_image(temp_dir): + """Create a sample test image.""" + from PIL import Image + + # Create a simple test image + img = Image.new("RGB", (400, 400), color=(100, 150, 200)) + img_path = temp_dir / "test_photo.png" + img.save(img_path) + return img_path + + +@pytest.fixture +def test_email(): + """Get test email address from environment or use default.""" + return os.environ.get("TEST_EMAIL", "contextualdynamicslab@gmail.com") + + +@pytest.fixture +def github_service(github_token): + """Create a GitHubService instance for testing.""" + from scripts.onboarding.services.github_service import GitHubService + return GitHubService(github_token, "ContextLab") + + +@pytest.fixture +def image_service(): + """Create an ImageService instance for testing.""" + from scripts.onboarding.services.image_service import ImageService + return ImageService() + + +@pytest.fixture +def bio_service(anthropic_api_key): + """Create a BioService instance for testing.""" + from scripts.onboarding.services.bio_service import BioService + return BioService(anthropic_api_key) diff --git a/tests/test_onboarding/test_bio_service.py b/tests/test_onboarding/test_bio_service.py new file mode 100644 index 0000000..1d9af56 --- /dev/null +++ b/tests/test_onboarding/test_bio_service.py @@ -0,0 +1,288 @@ +""" +Tests for the BioService. + +IMPORTANT: These tests make REAL Claude API calls. +Requires ANTHROPIC_API_KEY environment variable to be set. + +Tests verify: +- Bio editing produces third-person text +- Private information is detected and removed +- Output follows style guidelines +""" + +from pathlib import Path +import sys + +import pytest + +# Ensure scripts package is importable +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from scripts.onboarding.services.bio_service import BioService + + +class TestBioServiceInit: + """Tests for BioService initialization.""" + + def test_init_with_valid_key(self, anthropic_api_key): + """Test initialization with a valid API key.""" + service = BioService(anthropic_api_key) + assert service.client is not None + assert service.model == "claude-sonnet-4-20250514" + + def test_init_with_custom_model(self, anthropic_api_key): + """Test initialization with a custom model.""" + service = BioService(anthropic_api_key, model="claude-3-haiku-20240307") + assert service.model == "claude-3-haiku-20240307" + + +class TestBioEditing: + """Tests for bio editing functionality.""" + + def test_edit_first_person_bio(self, bio_service): + """Test converting first-person bio to third-person.""" + raw_bio = """ + I am a graduate student studying computational neuroscience. + I love working with brain data and developing machine learning models. + In my free time, I enjoy hiking and playing chess. + """ + + edited_bio, error = bio_service.edit_bio(raw_bio, "Jane Smith") + + assert error is None + assert edited_bio != "" + + # Should be in third person (no "I", "me", "my") + lower_bio = edited_bio.lower() + # Check that first person pronouns are removed + # (May have some in quotes or other contexts, so this is a soft check) + assert "jane" in lower_bio or "smith" in lower_bio + + print(f"Original: {raw_bio}") + print(f"Edited: {edited_bio}") + + def test_edit_long_bio_gets_shortened(self, bio_service): + """Test that overly long bios get shortened.""" + raw_bio = """ + I am a postdoctoral researcher in the lab. I completed my PhD at MIT where + I studied the neural basis of memory consolidation. My dissertation focused + on how the hippocampus interacts with the cortex during sleep. I used a + combination of electrophysiology, optogenetics, and computational modeling + to understand these processes. Before my PhD, I completed my undergraduate + degree at Stanford where I majored in biology and minored in computer science. + I am particularly interested in how we can use AI to analyze neural data. + In addition to my research, I enjoy teaching and mentoring students. + Outside of the lab, I am an avid rock climber and have climbed in Yosemite, + Red Rocks, and various locations throughout Europe. I also enjoy cooking, + especially Italian cuisine, and have recently taken up pottery. + """ + + edited_bio, error = bio_service.edit_bio(raw_bio, "Alex Johnson") + + assert error is None + assert edited_bio != "" + + # Count sentences (rough approximation) + sentences = [s.strip() for s in edited_bio.split('.') if s.strip()] + # Should be condensed to roughly 3-4 sentences + assert len(sentences) <= 6, f"Bio has {len(sentences)} sentences, expected 3-4" + + print(f"Edited bio ({len(sentences)} sentences): {edited_bio}") + + def test_edit_bio_uses_first_name(self, bio_service): + """Test that edited bio uses the member's first name.""" + raw_bio = "I study neural networks and machine learning." + + edited_bio, error = bio_service.edit_bio(raw_bio, "Maria Garcia") + + assert error is None + assert "maria" in edited_bio.lower() + + print(f"Edited: {edited_bio}") + + def test_edit_empty_bio(self, bio_service): + """Test handling of empty bio.""" + edited_bio, error = bio_service.edit_bio("", "Test User") + + assert edited_bio == "" + assert error is not None + assert "no bio" in error.lower() + + def test_edit_whitespace_only_bio(self, bio_service): + """Test handling of whitespace-only bio.""" + edited_bio, error = bio_service.edit_bio(" \n\t ", "Test User") + + assert edited_bio == "" + assert error is not None + + +class TestPrivateInfoDetection: + """Tests for private information detection.""" + + def test_detect_phone_number(self, bio_service): + """Test detection of phone numbers.""" + text = "Call me at 555-123-4567 for more info." + warnings = bio_service.check_for_private_info(text) + + assert len(warnings) > 0 + assert any("phone" in w.lower() for w in warnings) + + def test_detect_phone_number_with_dots(self, bio_service): + """Test detection of phone numbers with dots.""" + text = "My number is 555.123.4567." + warnings = bio_service.check_for_private_info(text) + + assert len(warnings) > 0 + + def test_detect_email_address(self, bio_service): + """Test detection of email addresses.""" + text = "Email me at person@example.com for questions." + warnings = bio_service.check_for_private_info(text) + + assert len(warnings) > 0 + assert any("email" in w.lower() for w in warnings) + + def test_detect_street_address(self, bio_service): + """Test detection of street addresses.""" + text = "I live at 123 Main Street in Boston." + warnings = bio_service.check_for_private_info(text) + + assert len(warnings) > 0 + assert any("address" in w.lower() for w in warnings) + + def test_detect_ssn(self, bio_service): + """Test detection of social security numbers.""" + text = "My SSN is 123-45-6789." + warnings = bio_service.check_for_private_info(text) + + assert len(warnings) > 0 + assert any("ssn" in w.lower() for w in warnings) + + def test_no_false_positives_clean_text(self, bio_service): + """Test that clean text doesn't trigger warnings.""" + text = "I am a researcher interested in computational neuroscience and machine learning." + warnings = bio_service.check_for_private_info(text) + + assert len(warnings) == 0 + + +class TestBioValidation: + """Tests for bio validation functionality.""" + + def test_validate_good_bio(self, bio_service): + """Test validation of a properly formatted bio.""" + bio = "Alex is a graduate student studying computational neuroscience. She enjoys working on machine learning problems." + + is_valid, warning = bio_service._validate_bio(bio, "Alex") + + assert is_valid is True + assert warning is None + + def test_validate_bio_too_long(self, bio_service): + """Test validation catches overly long bios.""" + bio = "Sentence one. Sentence two. Sentence three. Sentence four. Sentence five. Sentence six. Sentence seven." + + is_valid, warning = bio_service._validate_bio(bio, "Test") + + # Should flag as too long + assert is_valid is False + assert warning is not None + assert "sentences" in warning.lower() + + def test_validate_bio_first_person(self, bio_service): + """Test validation catches first-person pronouns.""" + bio = "I am a researcher and I study brains." + + is_valid, warning = bio_service._validate_bio(bio, "Test") + + assert is_valid is False + assert warning is not None + assert "first-person" in warning.lower() + + def test_validate_bio_missing_name(self, bio_service): + """Test validation catches missing name.""" + bio = "This person studies neuroscience." + + is_valid, warning = bio_service._validate_bio(bio, "Alex") + + assert is_valid is False + assert warning is not None + assert "alex" in warning.lower() + + def test_validate_bio_with_email(self, bio_service): + """Test validation catches email in bio.""" + bio = "Alex studies neuroscience. Contact: alex@example.com" + + is_valid, warning = bio_service._validate_bio(bio, "Alex") + + assert is_valid is False + assert warning is not None + assert "email" in warning.lower() + + +class TestSuggestImprovements: + """Tests for bio improvement suggestions.""" + + def test_suggest_improvements_for_long_bio(self, bio_service): + """Test getting suggestions for a long bio.""" + bio = """ + Jane is a researcher who studies many things. She completed her PhD at a + prestigious university where she worked on neural networks. Her dissertation + covered multiple topics including memory, learning, and attention. She has + published many papers and presented at numerous conferences. In her free time, + she enjoys hiking, reading, cooking, traveling, and spending time with friends. + She is also interested in science communication and public outreach. + """ + + suggestions, error = bio_service.suggest_improvements(bio, "Jane Doe") + + assert error is None + assert suggestions != "" + assert len(suggestions) > 20 # Should have substantive feedback + + print(f"Suggestions: {suggestions}") + + def test_suggest_improvements_returns_actionable_feedback(self, bio_service): + """Test that suggestions are actionable.""" + bio = "I study brains." + + suggestions, error = bio_service.suggest_improvements(bio, "Test Person") + + assert error is None + assert suggestions != "" + + print(f"Suggestions for short bio: {suggestions}") + + +class TestEdgeCases: + """Tests for edge cases and error handling.""" + + def test_edit_bio_special_characters(self, bio_service): + """Test handling bio with special characters.""" + raw_bio = "I study café culture & its effects on productivity! My research uses α and β." + + edited_bio, error = bio_service.edit_bio(raw_bio, "Marie Müller") + + assert error is None + assert edited_bio != "" + + def test_edit_bio_unicode_name(self, bio_service): + """Test handling names with unicode characters.""" + raw_bio = "I am a researcher from Japan studying memory." + + edited_bio, error = bio_service.edit_bio(raw_bio, "Yuki Tanaka") + + assert error is None + assert "yuki" in edited_bio.lower() or "tanaka" in edited_bio.lower() + + def test_edit_bio_very_short(self, bio_service): + """Test editing a very short bio.""" + raw_bio = "I study brains." + + edited_bio, error = bio_service.edit_bio(raw_bio, "Sam Lee") + + assert error is None + assert edited_bio != "" + assert len(edited_bio) >= len(raw_bio) # Should at least be as long + + print(f"Short bio edited: {edited_bio}") diff --git a/tests/test_onboarding/test_github_service.py b/tests/test_onboarding/test_github_service.py new file mode 100644 index 0000000..77ebf87 --- /dev/null +++ b/tests/test_onboarding/test_github_service.py @@ -0,0 +1,214 @@ +""" +Tests for the GitHubService. + +IMPORTANT: These tests make REAL GitHub API calls. +Requires GITHUB_TOKEN environment variable to be set. + +Tests are designed to be safe: +- Username validation only queries public GitHub API +- Team listing only reads org data +- No invitations are actually sent during testing +""" + +from pathlib import Path +import sys + +import pytest + +# Ensure scripts package is importable +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from scripts.onboarding.services.github_service import GitHubService + + +class TestGitHubServiceInit: + """Tests for GitHubService initialization.""" + + def test_init_with_valid_token(self, github_token): + """Test initialization with a valid token.""" + service = GitHubService(github_token, "ContextLab") + assert service.org_name == "ContextLab" + assert service.github is not None + assert service.org is not None + + def test_init_with_invalid_org(self, github_token): + """Test initialization with invalid organization name.""" + # The org is lazy loaded, so we need to access it to trigger the error + service = GitHubService(github_token, "nonexistent-org-that-does-not-exist-12345") + with pytest.raises(Exception): + # Accessing the org property should raise an exception + _ = service.org + + +class TestUsernameValidation: + """Tests for GitHub username validation.""" + + def test_validate_existing_username(self, github_service): + """Test validating a known existing GitHub username.""" + # Using 'octocat' which is GitHub's official test account + is_valid, error = github_service.validate_username("octocat") + assert is_valid is True + assert error is None + + def test_validate_nonexistent_username(self, github_service): + """Test validating a username that doesn't exist.""" + # Use a very unlikely username + is_valid, error = github_service.validate_username("this-user-definitely-does-not-exist-12345678") + assert is_valid is False + assert error is not None + assert "not found" in error.lower() or "does not exist" in error.lower() + + def test_validate_empty_username(self, github_service): + """Test validating an empty username.""" + is_valid, error = github_service.validate_username("") + assert is_valid is False + assert error is not None + + def test_validate_username_with_spaces(self, github_service): + """Test validating a username with invalid characters.""" + is_valid, error = github_service.validate_username("user name") + assert is_valid is False + + def test_validate_contextlab_member(self, github_service): + """Test validating a known ContextLab member (jeremymanning).""" + is_valid, error = github_service.validate_username("jeremymanning") + assert is_valid is True + assert error is None + + +class TestTeamListing: + """Tests for GitHub organization team listing.""" + + def test_get_teams_returns_list(self, github_service): + """Test that get_teams returns a list.""" + teams = github_service.get_teams() + assert isinstance(teams, list) + + def test_get_teams_contains_expected_teams(self, github_service): + """Test that known teams are in the list.""" + teams = github_service.get_teams() + team_names = [team["name"] for team in teams] + + # ContextLab should have at least some teams + assert len(teams) > 0 + + # Each team should have id, name, and description + for team in teams: + assert "id" in team + assert "name" in team + assert "description" in team + assert isinstance(team["id"], int) + assert isinstance(team["name"], str) + + def test_get_teams_includes_lab_default(self, github_service): + """Test that 'Lab default' team exists.""" + teams = github_service.get_teams() + team_names = [team["name"] for team in teams] + + # Check for common team names that should exist + # Note: Actual team names depend on the org setup + assert len(team_names) > 0 + + +class TestMembershipChecks: + """Tests for membership status checking.""" + + def test_check_membership_existing_member(self, github_service): + """Test checking membership of a known member.""" + # jeremymanning should be a member of ContextLab + is_member = github_service.check_membership("jeremymanning") + assert is_member is True + + def test_check_membership_non_member(self, github_service): + """Test checking membership of a non-member.""" + # octocat is probably not a member of ContextLab + is_member = github_service.check_membership("octocat") + assert is_member is False + + def test_check_membership_nonexistent_user(self, github_service): + """Test checking membership of nonexistent user.""" + is_member = github_service.check_membership("nonexistent-user-12345678") + assert is_member is False + + +class TestPendingInvitations: + """Tests for pending invitation listing.""" + + def test_get_pending_invitations_returns_list(self, github_service): + """Test that get_pending_invitations returns a list.""" + invitations = github_service.get_pending_invitations() + assert isinstance(invitations, list) + + def test_pending_invitations_format(self, github_service): + """Test the format of pending invitations.""" + invitations = github_service.get_pending_invitations() + + # May be empty, but if not, should have expected fields + for inv in invitations: + assert "login" in inv + assert "email" in inv + assert "invited_at" in inv + + +class TestInvitationSafety: + """Tests to verify invitation functions have proper safeguards. + + NOTE: These tests verify the function signature and error handling, + but do NOT actually send invitations. + """ + + def test_invite_user_validates_username(self, github_service): + """Test that invite_user validates the username first.""" + # Try to invite a nonexistent user - should fail validation + success, error = github_service.invite_user( + "nonexistent-user-that-does-not-exist-12345678", + team_ids=[] + ) + assert success is False + assert error is not None + + def test_invite_user_with_empty_username(self, github_service): + """Test that invite_user rejects empty username.""" + success, error = github_service.invite_user("", team_ids=[]) + assert success is False + assert error is not None + + +class TestRemoveMemberSafety: + """Tests for remove_member safety. + + NOTE: These tests verify error handling but do NOT remove anyone. + """ + + def test_remove_nonexistent_user(self, github_service): + """Test removing a user that doesn't exist.""" + success, error = github_service.remove_member("nonexistent-user-12345678") + # Should fail gracefully + assert success is False + + +class TestAPIResponseFormat: + """Tests to verify API responses are in expected format.""" + + def test_github_user_info_format(self, github_service): + """Test that user info has expected fields.""" + # Directly access the GitHub API through the service + user = github_service.github.get_user("octocat") + + # Verify expected attributes exist + assert hasattr(user, "login") + assert hasattr(user, "name") + assert hasattr(user, "email") + assert hasattr(user, "avatar_url") + assert hasattr(user, "html_url") + + # Verify login is correct + assert user.login == "octocat" + + def test_org_info_accessible(self, github_service): + """Test that organization info is accessible.""" + org = github_service.org + + assert hasattr(org, "login") + assert hasattr(org, "name") + assert org.login == "ContextLab" diff --git a/tests/test_onboarding/test_image_service.py b/tests/test_onboarding/test_image_service.py new file mode 100644 index 0000000..2e23f81 --- /dev/null +++ b/tests/test_onboarding/test_image_service.py @@ -0,0 +1,365 @@ +""" +Tests for the ImageService. + +These tests verify actual image processing with real files. +No external API calls required. +""" + +import tempfile +from pathlib import Path +import sys + +import pytest +from PIL import Image + +# Ensure scripts package is importable +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from scripts.onboarding.services.image_service import ImageService + + +class TestImageServiceInit: + """Tests for ImageService initialization.""" + + def test_default_initialization(self): + """Test default initialization values.""" + service = ImageService() + assert service.border_color == (0, 105, 62) # Dartmouth green + assert service.border_width == 8 + + def test_custom_border_color(self): + """Test custom border color.""" + custom_color = (255, 0, 0) # Red + service = ImageService(border_color=custom_color) + assert service.border_color == custom_color + + def test_custom_border_width(self): + """Test custom border width.""" + service = ImageService(border_width=12) + assert service.border_width == 12 + + +class TestImageValidation: + """Tests for image validation.""" + + def test_validate_valid_png(self, temp_dir): + """Test validating a valid PNG image.""" + service = ImageService() + + # Create a valid test image + img = Image.new("RGB", (400, 400), color=(100, 150, 200)) + img_path = temp_dir / "test.png" + img.save(img_path, format="PNG") + + is_valid, error = service.validate_image(img_path) + assert is_valid is True + assert error is None + + def test_validate_valid_jpeg(self, temp_dir): + """Test validating a valid JPEG image.""" + service = ImageService() + + img = Image.new("RGB", (300, 300), color=(100, 150, 200)) + img_path = temp_dir / "test.jpg" + img.save(img_path, format="JPEG") + + is_valid, error = service.validate_image(img_path) + assert is_valid is True + assert error is None + + def test_validate_image_too_small(self, temp_dir): + """Test validating an image that is too small.""" + service = ImageService() + + img = Image.new("RGB", (100, 100), color=(100, 150, 200)) + img_path = temp_dir / "small.png" + img.save(img_path, format="PNG") + + is_valid, error = service.validate_image(img_path) + assert is_valid is False + assert "too small" in error.lower() + + def test_validate_nonexistent_file(self, temp_dir): + """Test validating a file that doesn't exist.""" + service = ImageService() + fake_path = temp_dir / "nonexistent.png" + + is_valid, error = service.validate_image(fake_path) + assert is_valid is False + assert "not found" in error.lower() + + +class TestHandDrawnBorder: + """Tests for hand-drawn border processing.""" + + def test_add_border_creates_output(self, temp_dir): + """Test that processing creates an output file.""" + service = ImageService() + + # Create input image + input_img = Image.new("RGB", (400, 400), color=(200, 200, 200)) + input_path = temp_dir / "input.png" + input_img.save(input_path) + + output_path = temp_dir / "output.png" + + result = service.add_hand_drawn_border(input_path, output_path) + + assert result == output_path + assert output_path.exists() + + def test_output_is_larger_than_input(self, temp_dir): + """Test that output has space for border (is larger).""" + service = ImageService() + + # Create input image + input_img = Image.new("RGB", (400, 400), color=(200, 200, 200)) + input_path = temp_dir / "input.png" + input_img.save(input_path) + + output_path = temp_dir / "output.png" + service.add_hand_drawn_border(input_path, output_path) + + output_img = Image.open(output_path) + # Output should be larger due to border padding + assert output_img.size[0] > 400 + assert output_img.size[1] > 400 + + def test_border_contains_green(self, temp_dir): + """Test that the border contains Dartmouth green color.""" + service = ImageService() + + # Create input image (white) + input_img = Image.new("RGB", (400, 400), color=(255, 255, 255)) + input_path = temp_dir / "input.png" + input_img.save(input_path) + + output_path = temp_dir / "output.png" + service.add_hand_drawn_border(input_path, output_path) + + output_img = Image.open(output_path).convert("RGB") + + # Check corners (where border should be) + # The border should contain Dartmouth green (0, 105, 62) + # Check a few pixels in the border area + found_green = False + dartmouth_green = (0, 105, 62) + + # Sample the border area + for x in range(20): + for y in range(20): + pixel = output_img.getpixel((x, y)) + if pixel == dartmouth_green: + found_green = True + break + if found_green: + break + + assert found_green, "Dartmouth green not found in border area" + + def test_reproducible_with_seed(self, temp_dir): + """Test that results are reproducible with same seed.""" + service = ImageService() + + # Create input image + input_img = Image.new("RGB", (400, 400), color=(128, 128, 128)) + input_path = temp_dir / "input.png" + input_img.save(input_path) + + output1_path = temp_dir / "output1.png" + output2_path = temp_dir / "output2.png" + + # Process twice with same seed + service.add_hand_drawn_border(input_path, output1_path, seed=42) + service.add_hand_drawn_border(input_path, output2_path, seed=42) + + # Images should be identical + img1 = Image.open(output1_path) + img2 = Image.open(output2_path) + + # Compare pixel by pixel (sample a few) + for x in range(0, img1.size[0], 50): + for y in range(0, img1.size[1], 50): + assert img1.getpixel((x, y)) == img2.getpixel((x, y)) + + def test_different_seeds_produce_different_results(self, temp_dir): + """Test that different seeds produce different results.""" + service = ImageService() + + # Create input image + input_img = Image.new("RGB", (400, 400), color=(128, 128, 128)) + input_path = temp_dir / "input.png" + input_img.save(input_path) + + output1_path = temp_dir / "output1.png" + output2_path = temp_dir / "output2.png" + + # Process with different seeds + service.add_hand_drawn_border(input_path, output1_path, seed=42) + service.add_hand_drawn_border(input_path, output2_path, seed=123) + + img1 = Image.open(output1_path) + img2 = Image.open(output2_path) + + # Find at least one difference in the border region + differences_found = False + for x in range(10, 30): # Border region + for y in range(10, 30): + if img1.getpixel((x, y)) != img2.getpixel((x, y)): + differences_found = True + break + if differences_found: + break + + assert differences_found, "Different seeds should produce different wobble patterns" + + def test_jpeg_output(self, temp_dir): + """Test output as JPEG format.""" + service = ImageService() + + # Create input PNG + input_img = Image.new("RGB", (400, 400), color=(200, 200, 200)) + input_path = temp_dir / "input.png" + input_img.save(input_path) + + output_path = temp_dir / "output.jpg" + service.add_hand_drawn_border(input_path, output_path) + + assert output_path.exists() + # Verify it's actually a JPEG + with Image.open(output_path) as img: + assert img.format == "JPEG" + + +class TestMakeSquare: + """Tests for the square cropping functionality.""" + + def test_already_square(self, temp_dir): + """Test that square images are unchanged.""" + service = ImageService() + + # Create square image + img = Image.new("RGB", (400, 400), color=(200, 200, 200)) + input_path = temp_dir / "square.png" + img.save(input_path) + + output_path = temp_dir / "output.png" + service.add_hand_drawn_border(input_path, output_path) + + # Output should be processed normally + assert output_path.exists() + + def test_landscape_becomes_square(self, temp_dir): + """Test that landscape images are cropped to square.""" + service = ImageService() + + # Create wide landscape image + img = Image.new("RGB", (600, 400), color=(200, 200, 200)) + input_path = temp_dir / "landscape.png" + img.save(input_path) + + output_path = temp_dir / "output.png" + service.add_hand_drawn_border(input_path, output_path) + + # The base image should be square before border is added + # Output will be larger due to border, but underlying image is 400x400 + assert output_path.exists() + + def test_portrait_becomes_square(self, temp_dir): + """Test that portrait images are cropped to square.""" + service = ImageService() + + # Create tall portrait image + img = Image.new("RGB", (400, 600), color=(200, 200, 200)) + input_path = temp_dir / "portrait.png" + img.save(input_path) + + output_path = temp_dir / "output.png" + service.add_hand_drawn_border(input_path, output_path) + + assert output_path.exists() + + +class TestProcessPhoto: + """Tests for the process_photo convenience method.""" + + def test_process_photo_creates_file(self, temp_dir): + """Test that process_photo creates the expected output file.""" + service = ImageService() + + # Create input image + input_img = Image.new("RGB", (500, 500), color=(150, 150, 150)) + input_path = temp_dir / "original.png" + input_img.save(input_path) + + output_dir = temp_dir / "output" + output_dir.mkdir() + + result = service.process_photo(input_path, output_dir, "test_member") + + assert result.exists() + assert "test_member" in result.name + assert "_bordered" in result.name + + def test_process_photo_reproducible_by_member_id(self, temp_dir): + """Test that same member_id produces same result.""" + service = ImageService() + + # Create input image + input_img = Image.new("RGB", (500, 500), color=(150, 150, 150)) + input_path = temp_dir / "original.png" + input_img.save(input_path) + + output_dir1 = temp_dir / "output1" + output_dir2 = temp_dir / "output2" + output_dir1.mkdir() + output_dir2.mkdir() + + result1 = service.process_photo(input_path, output_dir1, "same_member") + result2 = service.process_photo(input_path, output_dir2, "same_member") + + # Images should be identical (same seed from same member_id) + img1 = Image.open(result1) + img2 = Image.open(result2) + + # Sample comparison + for x in range(0, min(img1.size[0], 200), 25): + for y in range(0, min(img1.size[1], 200), 25): + assert img1.getpixel((x, y)) == img2.getpixel((x, y)) + + +class TestCustomWobble: + """Tests for wobble amount configuration.""" + + def test_zero_wobble_straight_lines(self, temp_dir): + """Test that zero wobble produces straighter borders.""" + service = ImageService() + + input_img = Image.new("RGB", (400, 400), color=(255, 255, 255)) + input_path = temp_dir / "input.png" + input_img.save(input_path) + + # Process with no wobble + output_path = temp_dir / "straight.png" + service.add_hand_drawn_border(input_path, output_path, wobble_amount=0, seed=42) + + # Process with wobble + output_wobble_path = temp_dir / "wobbly.png" + service.add_hand_drawn_border(input_path, output_wobble_path, wobble_amount=5.0, seed=42) + + # Both should exist + assert output_path.exists() + assert output_wobble_path.exists() + + # They should be different + img_straight = Image.open(output_path) + img_wobbly = Image.open(output_wobble_path) + + # Find differences in border region + differences = 0 + for x in range(10, 30): + for y in range(10, 30): + if img_straight.getpixel((x, y)) != img_wobbly.getpixel((x, y)): + differences += 1 + + assert differences > 0, "Wobble setting should affect the output" diff --git a/tests/test_onboarding/test_models.py b/tests/test_onboarding/test_models.py new file mode 100644 index 0000000..6fe159e --- /dev/null +++ b/tests/test_onboarding/test_models.py @@ -0,0 +1,264 @@ +""" +Tests for the OnboardingRequest model. + +These tests do not require external API calls. +""" + +import json +from datetime import datetime +from pathlib import Path +import sys + +import pytest + +# Ensure scripts package is importable +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from scripts.onboarding.models.onboarding_request import OnboardingRequest, OnboardingStatus + + +class TestOnboardingStatus: + """Tests for OnboardingStatus enum.""" + + def test_all_statuses_have_values(self): + """Verify all expected status values exist.""" + expected_statuses = [ + "pending_info", + "pending_approval", + "github_pending", + "calendar_pending", + "ready_for_website", + "completed", + "rejected", + ] + actual_statuses = [s.value for s in OnboardingStatus] + for status in expected_statuses: + assert status in actual_statuses, f"Missing status: {status}" + + def test_status_from_string(self): + """Test creating status from string value.""" + status = OnboardingStatus("pending_info") + assert status == OnboardingStatus.PENDING_INFO + + +class TestOnboardingRequest: + """Tests for OnboardingRequest dataclass.""" + + def test_create_minimal_request(self): + """Test creating a request with minimal required fields.""" + request = OnboardingRequest( + slack_user_id="U12345678", + slack_channel_id="C87654321", + name="Test User", + ) + assert request.slack_user_id == "U12345678" + assert request.slack_channel_id == "C87654321" + assert request.name == "Test User" + assert request.status == OnboardingStatus.PENDING_INFO + assert request.github_username == "" + assert request.email == "" + assert request.github_teams == [] + assert request.github_invitation_sent is False + assert request.calendar_invites_sent is False + + def test_create_full_request(self): + """Test creating a request with all fields.""" + request = OnboardingRequest( + slack_user_id="U12345678", + slack_channel_id="C87654321", + name="Test User", + email="test@example.com", + github_username="testuser", + bio_raw="I am a researcher", + bio_edited="Test is a researcher", + website_url="https://example.com", + photo_original_path="/path/to/original.jpg", + photo_processed_path="/path/to/processed.png", + github_teams=[1, 2, 3], + calendar_permissions={"Lab Calendar": "reader"}, + status=OnboardingStatus.PENDING_APPROVAL, + ) + assert request.email == "test@example.com" + assert request.github_username == "testuser" + assert request.bio_raw == "I am a researcher" + assert request.bio_edited == "Test is a researcher" + assert request.website_url == "https://example.com" + assert request.github_teams == [1, 2, 3] + assert request.calendar_permissions == {"Lab Calendar": "reader"} + assert request.status == OnboardingStatus.PENDING_APPROVAL + + def test_update_status(self): + """Test status update functionality.""" + request = OnboardingRequest( + slack_user_id="U12345678", + slack_channel_id="C87654321", + name="Test User", + ) + assert request.status == OnboardingStatus.PENDING_INFO + + # Update status + request.update_status(OnboardingStatus.PENDING_APPROVAL) + assert request.status == OnboardingStatus.PENDING_APPROVAL + + # Update again + request.update_status(OnboardingStatus.GITHUB_PENDING) + assert request.status == OnboardingStatus.GITHUB_PENDING + + def test_update_status_with_error(self): + """Test status update with error message.""" + request = OnboardingRequest( + slack_user_id="U12345678", + slack_channel_id="C87654321", + name="Test User", + ) + + request.update_status(OnboardingStatus.ERROR, "Something went wrong") + assert request.status == OnboardingStatus.ERROR + assert request.error_message == "Something went wrong" + + def test_serialization_to_dict(self): + """Test converting request to dictionary.""" + request = OnboardingRequest( + slack_user_id="U12345678", + slack_channel_id="C87654321", + name="Test User", + email="test@example.com", + github_username="testuser", + ) + data = request.to_dict() + + assert isinstance(data, dict) + assert data["slack_user_id"] == "U12345678" + assert data["slack_channel_id"] == "C87654321" + assert data["name"] == "Test User" + assert data["email"] == "test@example.com" + assert data["github_username"] == "testuser" + assert data["status"] == "pending_info" + assert "created_at" in data + assert "updated_at" in data + + def test_deserialization_from_dict(self): + """Test creating request from dictionary.""" + data = { + "slack_user_id": "U12345678", + "slack_channel_id": "C87654321", + "name": "Test User", + "email": "test@example.com", + "github_username": "testuser", + "bio_raw": "I am a researcher", + "bio_edited": "Test is a researcher", + "website_url": "https://example.com", + "photo_original_path": "", + "photo_processed_path": "", + "github_teams": [1, 2], + "calendar_permissions": {"Lab": "reader"}, + "status": "pending_approval", + "github_invitation_sent": True, + "calendar_invites_sent": False, + "approved_by": "UADMIN123", + "created_at": "2024-01-15T10:30:00", + "updated_at": "2024-01-15T11:00:00", + } + + request = OnboardingRequest.from_dict(data) + + assert request.slack_user_id == "U12345678" + assert request.name == "Test User" + assert request.email == "test@example.com" + assert request.github_username == "testuser" + assert request.status == OnboardingStatus.PENDING_APPROVAL + assert request.github_invitation_sent is True + assert request.approved_by == "UADMIN123" + + def test_roundtrip_serialization(self): + """Test that to_dict and from_dict are inverses.""" + original = OnboardingRequest( + slack_user_id="U12345678", + slack_channel_id="C87654321", + name="Test User", + email="test@example.com", + github_username="testuser", + bio_raw="Original bio", + bio_edited="Edited bio", + website_url="https://example.com", + github_teams=[1, 2, 3], + calendar_permissions={"Cal1": "reader", "Cal2": "writer"}, + ) + original.update_status(OnboardingStatus.PENDING_APPROVAL) + original.github_invitation_sent = True + + # Serialize and deserialize + data = original.to_dict() + restored = OnboardingRequest.from_dict(data) + + # Verify key fields match + assert restored.slack_user_id == original.slack_user_id + assert restored.name == original.name + assert restored.email == original.email + assert restored.github_username == original.github_username + assert restored.bio_raw == original.bio_raw + assert restored.bio_edited == original.bio_edited + assert restored.website_url == original.website_url + assert restored.github_teams == original.github_teams + assert restored.calendar_permissions == original.calendar_permissions + assert restored.status == original.status + assert restored.github_invitation_sent == original.github_invitation_sent + + def test_json_serialization(self): + """Test that to_dict output is JSON serializable.""" + request = OnboardingRequest( + slack_user_id="U12345678", + slack_channel_id="C87654321", + name="Test User", + ) + request.update_status(OnboardingStatus.PENDING_APPROVAL) + + # Should not raise + json_str = json.dumps(request.to_dict()) + assert isinstance(json_str, str) + + # Should be able to parse back + parsed = json.loads(json_str) + assert parsed["slack_user_id"] == "U12345678" + + def test_error_message_field(self): + """Test error message field functionality.""" + request = OnboardingRequest( + slack_user_id="U12345678", + slack_channel_id="C87654321", + name="Test User", + ) + assert request.error_message == "" + + data = request.to_dict() + assert "error_message" in data + restored = OnboardingRequest.from_dict(data) + assert restored.error_message == "" + + def test_created_at_timestamp(self): + """Test that created_at is set automatically.""" + before = datetime.now() + request = OnboardingRequest( + slack_user_id="U12345678", + slack_channel_id="C87654321", + name="Test User", + ) + after = datetime.now() + + assert before <= request.created_at <= after + + def test_updated_at_changes_on_status_update(self): + """Test that updated_at changes when status is updated.""" + request = OnboardingRequest( + slack_user_id="U12345678", + slack_channel_id="C87654321", + name="Test User", + ) + original_updated = request.updated_at + + # Small delay to ensure time difference + import time + time.sleep(0.01) + + request.update_status(OnboardingStatus.PENDING_APPROVAL) + assert request.updated_at > original_updated