""" Central place for all configuration. Settings live in a .env file at the project root. This module wraps python-dotenv so the rest of the app never touches the file directly - that keeps the settings dialog, the services, and the database layer all agreeing on where config comes from. As we add ShipStation / Odoo / anything else, add the new keys to DEFAULTS below and to .env.example - everything else (loading, saving, the settings dialog) will pick them up automatically because the dialog is built from this list. """ from __future__ import annotations import os from pathlib import Path from typing import Dict from dotenv import dotenv_values, set_key # Project root = the folder containing this app/ package PROJECT_ROOT = Path(__file__).resolve().parent.parent ENV_PATH = PROJECT_ROOT / ".env" # Every setting the app knows about, grouped for the settings UI. # key -> (label, group, is_secret) SETTINGS_SCHEMA: Dict[str, tuple[str, str, bool]] = { "DB_URL": ("Database URL", "Database", False), "JIRA_URL": ("JIRA Site URL", "JIRA", False), "JIRA_EMAIL": ("JIRA Account Email", "JIRA", False), "JIRA_API_TOKEN": ("JIRA API Token", "JIRA", True), "JIRA_JQL": ("JIRA Query (JQL)", "JIRA", False), "JIRA_SIGNIFY_SKU_FIELDS": ( "Signify Health JIRA Deliverable Field IDs (comma-separated)", "JIRA", False, ), "JIRA_OAKSTREET_SKU_FIELDS": ( "Oak Street Health JIRA Deliverable Field IDs (comma-separated)", "JIRA", False, ), "JIRA_FIELD_NAME": ("JIRA Field ID: Recipient Name", "JIRA Contact Fields", False), "JIRA_FIELD_PHONE": ("JIRA Field ID: Phone Number", "JIRA Contact Fields", False), "JIRA_FIELD_EMAIL": ("JIRA Field ID: Email", "JIRA Contact Fields", False), "JIRA_FIELD_ADDRESS1": ("JIRA Field ID: Address 1", "JIRA Contact Fields", False), "JIRA_FIELD_ADDRESS2": ("JIRA Field ID: Address 2", "JIRA Contact Fields", False), "JIRA_FIELD_CITY": ("JIRA Field ID: City", "JIRA Contact Fields", False), "JIRA_FIELD_STATE": ("JIRA Field ID: State", "JIRA Contact Fields", False), "JIRA_FIELD_ZIP": ("JIRA Field ID: Zip Code", "JIRA Contact Fields", False), "JIRA_FIELD_NPI": ("JIRA Field ID: NPI Number", "JIRA Contact Fields", False), "SHIPSTATION_API_KEY": ("ShipStation API Key", "ShipStation", True), "SHIPSTATION_SIGNIFY_STORE_ID": ( "ShipStation Store ID: Signify Health (for emergency sends)", "ShipStation", False, ), "SHIPSTATION_OAKSTREET_STORE_ID": ( "ShipStation Store ID: Oak Street Health (for emergency sends)", "ShipStation", False, ), "SHIPSTATION_SIGNIFY_WAREHOUSE_ID": ( "ShipStation Warehouse ID: Signify Health (ship-from location)", "ShipStation", False, ), "SHIPSTATION_OAKSTREET_WAREHOUSE_ID": ( "ShipStation Warehouse ID: Oak Street Health (ship-from location)", "ShipStation", False, ), "SHIPSTATION_TEST_MODE": ( "Use ShipStation test/sandbox API key instead of production (true/false) - " "also toggleable from the Data menu", "ShipStation Test Mode", False, ), "TEST_SHIPSTATION_API_KEY": ("ShipStation Test/Sandbox API Key", "ShipStation Test Mode", True), "TEST_SHIPSTATION_SIGNIFY_STORE_ID": ( "Test override: Signify Store ID (blank = use production value)", "ShipStation Test Mode", False, ), "TEST_SHIPSTATION_OAKSTREET_STORE_ID": ( "Test override: Oak Street Store ID (blank = use production value)", "ShipStation Test Mode", False, ), "TEST_SHIPSTATION_SIGNIFY_WAREHOUSE_ID": ( "Test override: Signify Warehouse ID (blank = use production value)", "ShipStation Test Mode", False, ), "TEST_SHIPSTATION_OAKSTREET_WAREHOUSE_ID": ( "Test override: Oak Street Warehouse ID (blank = use production value)", "ShipStation Test Mode", False, ), "TEST_SIGNIFY_RETURN_CARRIER_ID": ( "Test override: Signify Return Carrier ID (blank = use production value)", "ShipStation Test Mode", False, ), "TEST_SIGNIFY_RETURN_SERVICE_CODE": ( "Test override: Signify Return Service Code (blank = use production value)", "ShipStation Test Mode", False, ), "TEST_OAKSTREET_RETURN_CARRIER_ID": ( "Test override: Oak Street Return Carrier ID (blank = use production value)", "ShipStation Test Mode", False, ), "TEST_OAKSTREET_RETURN_SERVICE_CODE": ( "Test override: Oak Street Return Service Code (blank = use production value)", "ShipStation Test Mode", False, ), "COMPANY_SKU_MAP": ( "SKU Prefix -> Company (e.g. SH:Signify Health,OK:Oak Street Health)", "Companies", False, ), "TICKET_NUMBER_REGEX": ( "Ticket Number Pattern (regex, e.g. AR-######) - fallback only", "Companies", False, ), "EMAILED_LABEL_SKUS": ( "SKUs that mean 'create + email a return label' (comma-separated)", "Return Labels", False, ), "DEVICE_FIELD_SUGGESTIONS": ( "Serial-number fields to suggest per device keyword " "(keyword:Field One|Field Two,keyword2:Field Three)", "Packing", False, ), "SHIPPING_METHOD_LABELS": ( "ShipStation service code -> your term (e.g. ups_ground:Ground)", "Packing", False, ), "RETURN_DEVICE_EXEMPT_KEYWORDS": ( "Return types with no matching device expected (comma-separated keywords)", "Packing", False, ), "SIGNIFY_RETURN_CARRIER_ID": ("Signify Return: ShipStation Carrier ID", "Return Labels", False), "SIGNIFY_RETURN_SERVICE_CODE": ( "Signify Return: Service code (e.g. ups_ground)", "Return Labels", False, ), "OAKSTREET_RETURN_CARRIER_ID": ("Oak Street Return: ShipStation Carrier ID", "Return Labels", False), "OAKSTREET_RETURN_SERVICE_CODE": ( "Oak Street Return: Service code (e.g. ups_ground)", "Return Labels", False, ), "SHIPSTATION_RETURN_CHARGE_EVENT": ( "When to be charged: on_creation / on_carrier_acceptance / carrier_default", "Return Labels", False, ), "SIGNIFY_RETURN_NAME": ("Signify Return Address: Name", "Return Labels", False), "SIGNIFY_RETURN_PHONE": ("Signify Return Address: Phone", "Return Labels", False), "SIGNIFY_RETURN_ADDRESS1": ("Signify Return Address: Address 1", "Return Labels", False), "SIGNIFY_RETURN_ADDRESS2": ("Signify Return Address: Address 2", "Return Labels", False), "SIGNIFY_RETURN_CITY": ("Signify Return Address: City", "Return Labels", False), "SIGNIFY_RETURN_STATE": ("Signify Return Address: State", "Return Labels", False), "SIGNIFY_RETURN_ZIP": ("Signify Return Address: Zip", "Return Labels", False), "OAKSTREET_RETURN_NAME": ("Oak Street Return Address: Name", "Return Labels", False), "OAKSTREET_RETURN_PHONE": ("Oak Street Return Address: Phone", "Return Labels", False), "OAKSTREET_RETURN_ADDRESS1": ("Oak Street Return Address: Address 1", "Return Labels", False), "OAKSTREET_RETURN_ADDRESS2": ("Oak Street Return Address: Address 2", "Return Labels", False), "OAKSTREET_RETURN_CITY": ("Oak Street Return Address: City", "Return Labels", False), "OAKSTREET_RETURN_STATE": ("Oak Street Return Address: State", "Return Labels", False), "OAKSTREET_RETURN_ZIP": ("Oak Street Return Address: Zip", "Return Labels", False), # RubiconMD - an Oak Street subsidiary (RMD-prefixed SKUs). Classified as # Oak Street Health everywhere else. It uses Oak Street's own shipping # account and address in every respect - this is the one thing that's # actually different, the name shown on the return label. "RUBICONMD_RETURN_NAME": ("RubiconMD Return Address: Name (uses Oak Street's account/address otherwise)", "Return Labels", False), "ACTIVE_STATUSES": ( "Statuses that count as real active work (comma-separated) - " "everything else is treated as done", "Status Tracking", False, ), "CANCELLED_STATUSES": ( "Statuses treated as cancelled - shown in their own tab (comma-separated)", "Status Tracking", False, ), "FULFILLED_STATUS_WITH_RETURN": ( "JIRA status when a return label was also generated", "Status Tracking", False, ), "FULFILLED_STATUS_WITHOUT_RETURN": ( "JIRA status when only an outgoing label was generated", "Status Tracking", False, ), "INTAKE_CUTOFF_TIME": ( "Daily intake cutoff time, 24h HH:MM (e.g. 15:30 for 3:30 PM)", "Status Tracking", False, ), } DEFAULT_DB_URL = "sqlite:///orders.db" DEFAULT_COMPANY_SKU_MAP = "SH:Signify Health,OK:Oak Street Health,RMD:Oak Street Health" DEFAULT_TICKET_NUMBER_REGEX = r"\b[A-Z]{2,6}-\d{3,}\b" DEFAULT_FULFILLED_WITH_RETURN = "Waiting For Return" DEFAULT_FULFILLED_WITHOUT_RETURN = "Device Return Not Needed" DEFAULT_INTAKE_CUTOFF_TIME = "15:30" DEFAULT_ACTIVE_STATUSES = "Created" DEFAULT_CANCELLED_STATUSES = "Cancelled" DEFAULT_EMAILED_LABEL_SKUS = "SH007,OK012" DEFAULT_DEVICE_FIELD_SUGGESTIONS = ( "laptop:Laptop Serial Number|Laptop Asset Tag," "optiplex:OptiPlex Serial Number|OptiPlex Asset Tag," "desktop:Desktop Serial Number|Desktop Asset Tag," "phone:Phone IMEI|Phone Serial Number|Phone ICCID|Phone Asset Tag," "ipad:iPad IMEI|iPad Serial Number|iPad ICCID|iPad Asset Tag," "spiro:Spiro Serial Number|Spiro Asset Tag," "apc:UPS/APC Serial Number|UPS/APC Asset Tag," "monitor:Monitor Serial Number|Monitor Asset Tag," "accessor:Accessory Notes," "camera:Camera Serial Number|Camera Asset Tag" ) # Confirmed against ShipStation's own UPS service code reference, not guessed. DEFAULT_SHIPPING_METHOD_LABELS = ( "ups_next_day_air:Priority Overnight," "ups_2nd_day_air:Two Day," "ups_ground:Ground" ) DEFAULT_RETURN_CHARGE_EVENT = "carrier_default" def ensure_env_file_exists() -> None: """Create a .env from .env.example on first run if one doesn't exist yet.""" if ENV_PATH.exists(): return example = PROJECT_ROOT / ".env.example" if example.exists(): ENV_PATH.write_text(example.read_text()) else: ENV_PATH.touch() def sync_env_with_example() -> None: """ Add any key present in .env.example but entirely missing from an already-existing .env, using .env.example's value as the default - without touching anything the user already has. .env is gitignored on purpose (it holds real credentials/field IDs), which means it never gets updated just by pulling new code - as new settings get added over time, this is what keeps them from silently sitting blank until someone notices a feature isn't working. Safe to call every startup; a no-op once everything's already present. """ ensure_env_file_exists() example_path = PROJECT_ROOT / ".env.example" if not example_path.exists(): return example_values = dotenv_values(example_path) current_values = dotenv_values(ENV_PATH) for key, example_value in example_values.items(): if key not in current_values: set_key(str(ENV_PATH), key, example_value or "") os.environ[key] = example_value or "" def load_settings() -> Dict[str, str]: """Read current values from .env (does not touch os.environ).""" ensure_env_file_exists() values = dotenv_values(ENV_PATH) # Fill in blanks for any known key that's missing from the file return {key: values.get(key) or "" for key in SETTINGS_SCHEMA} def save_settings(values: Dict[str, str]) -> None: """Write settings back to .env, one key at a time (preserves the file).""" ensure_env_file_exists() for key, value in values.items(): if key in SETTINGS_SCHEMA: set_key(str(ENV_PATH), key, value or "") # Refresh the current process's environment too, so a running app # picks up the change without needing a restart for most settings. for key, value in values.items(): os.environ[key] = value or "" def get(key: str, default: str = "") -> str: """Convenience getter, e.g. config.get('DB_URL').""" return load_settings().get(key, default) or default def is_shipstation_test_mode() -> bool: return get("SHIPSTATION_TEST_MODE", "").strip().lower() in ("true", "1", "yes") def get_shipstation_setting(key: str) -> str: """ Test-mode-aware lookup for ShipStation-related settings (API key, store/warehouse/carrier IDs, service codes). When SHIPSTATION_TEST_MODE is on, ONLY the TEST_{key} value is used - no fallback to the production value. When test mode is off, this is identical to config.get(key). This used to fall back to the production value when the TEST_ override was blank, on the theory the sandbox might share IDs with production. Confirmed against ShipStation's own docs that it never does - "Sandbox data is isolated from production data... anything you create in the sandbox will not be accessible in production, or vice-versa." A fallback to a production store/warehouse/carrier ID under a sandbox API key isn't a convenience, it's a guaranteed rejection - and it's exactly what produced three separate confusing "not found"/"invalid" errors in a row (carrier, then warehouse, then store) before this was caught. Every caller of this function already raises a clear, specific "X is not configured" error when it gets back an empty string, so isolating test mode fully just turns those three confusing ShipStation-side rejections into one obvious message instead. """ if is_shipstation_test_mode(): return get(f"TEST_{key}", "") return get(key, "")