Files
Order-Manager/app/config.py
T

186 lines
6.6 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,
),
"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,
),
"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"
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"
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