335 lines
13 KiB
Python
335 lines
13 KiB
Python
"""
|
|
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). When SHIPSTATION_TEST_MODE is on, looks
|
|
for a TEST_{key} override first, falling back to the normal {key}
|
|
value if the override is blank - so test mode works immediately if
|
|
your test/sandbox account mirrors production's store/warehouse/
|
|
carrier IDs, while still letting you override specific values if your
|
|
test account uses different ones. When test mode is off, this is
|
|
identical to config.get(key).
|
|
"""
|
|
if is_shipstation_test_mode():
|
|
test_value = get(f"TEST_{key}", "")
|
|
if test_value:
|
|
return test_value
|
|
return get(key, "")
|