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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Label Printer Hub — example environment configuration
# Copy this file to .env and fill in your values.
# NEVER commit .env with real secrets — .env is in .gitignore.

# ---------------------------------------------------------------------------
# Database
# ---------------------------------------------------------------------------
PRINTER_HUB_DATABASE_URL=sqlite:////data/printer-hub.db

# ---------------------------------------------------------------------------
# Brother printers
# Fill in the IP address (or hostname) of each printer.
# Leave empty if the printer is not used.
# ---------------------------------------------------------------------------
PRINTER_HUB_QL820_HOST=
PRINTER_HUB_QL820_PORT=9100

PRINTER_HUB_PT750W_HOST=
PRINTER_HUB_PT750W_PORT=9100

# ---------------------------------------------------------------------------
# Webhook authentication
# Minimum 32 characters. Generate with:
# openssl rand -hex 32
# ---------------------------------------------------------------------------
PRINTER_HUB_WEBHOOK_API_KEY=

# ---------------------------------------------------------------------------
# Snipe-IT integration (optional)
# ---------------------------------------------------------------------------
PRINTER_HUB_SNIPEIT_URL=
PRINTER_HUB_SNIPEIT_API_KEY=

# ---------------------------------------------------------------------------
# Grocy integration (optional)
# ---------------------------------------------------------------------------
PRINTER_HUB_GROCY_URL=
PRINTER_HUB_GROCY_API_KEY=

# ---------------------------------------------------------------------------
# Spoolman integration (no API key required)
# ---------------------------------------------------------------------------
PRINTER_HUB_SPOOLMAN_URL=

# ---------------------------------------------------------------------------
# Server
# ---------------------------------------------------------------------------
PRINTER_HUB_SERVER_PORT=8090
PRINTER_HUB_LOG_LEVEL=INFO
96 changes: 96 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""Runtime configuration via Pydantic Settings.

All settings are read from environment variables prefixed with ``PRINTER_HUB_``
(e.g. ``PRINTER_HUB_QL820_HOST``). A ``.env`` file in the working directory is
loaded automatically when present; values from the environment always take
precedence over ``.env`` values.

Usage::

from app.config import get_settings

settings = get_settings()
print(settings.ql820_host)

:func:`get_settings` is cached with :func:`functools.lru_cache` so that
settings are only parsed once per process. Tests that instantiate
:class:`Settings` directly bypass the cache and get a fresh read each time —
this is intentional and safe. To keep tests hermetic and independent of any
local ``.env`` file, pass ``_env_file=None`` when constructing
:class:`Settings` in test code.
"""

from __future__ import annotations

from functools import lru_cache

from pydantic import SecretStr, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
Comment on lines +25 to +28
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add Literal and Field to imports to support stricter validation for logging levels and port ranges.

Suggested change
from functools import lru_cache
from pydantic import SecretStr, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from functools import lru_cache
from typing import Literal
from pydantic import Field, SecretStr, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict



class Settings(BaseSettings):
"""Application-wide runtime configuration.

Every field maps to an environment variable with the ``PRINTER_HUB_``
prefix. See ``.env.example`` in the ``backend/`` directory for the full
list of supported variables and their defaults.
"""

model_config = SettingsConfigDict(
env_prefix="PRINTER_HUB_",
env_file=".env",
extra="ignore",
)

# Database
database_url: str = "sqlite:////data/printer-hub.db"

# Brother QL-820NWB — address label printer
ql820_host: str = ""
ql820_port: int = 9100
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add range validation for the printer port to ensure it stays within the valid TCP port range (1-65535).

Suggested change
ql820_port: int = 9100
ql820_port: int = Field(9100, ge=1, le=65535)


# Brother PT-750W — cable / panel label printer
pt750w_host: str = ""
pt750w_port: int = 9100
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add range validation for the printer port to ensure it stays within the valid TCP port range (1-65535).

Suggested change
pt750w_port: int = 9100
pt750w_port: int = Field(9100, ge=1, le=65535)


# Webhook authentication
webhook_api_key: SecretStr = SecretStr("")

# Snipe-IT integration (optional)
snipeit_url: str = ""
snipeit_api_key: SecretStr = SecretStr("")

# Grocy integration (optional)
grocy_url: str = ""
grocy_api_key: SecretStr = SecretStr("")

# Spoolman integration (no API key needed)
spoolman_url: str = ""

# Server
server_port: int = 8090
log_level: str = "INFO"
Comment on lines +71 to +72
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use Field for port validation and Literal for log_level. This prevents runtime errors from typos in environment variables and improves type safety for the logging configuration.

Suggested change
server_port: int = 8090
log_level: str = "INFO"
server_port: int = Field(8090, ge=1, le=65535)
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"


@field_validator("webhook_api_key")
@classmethod
def validate_api_key_length(cls, v: SecretStr) -> SecretStr:
"""Reject keys shorter than 32 characters.

An empty string is accepted so that the hub can start without
webhook authentication configured (the webhook endpoint will
refuse all requests at runtime, but startup succeeds).
"""
secret = v.get_secret_value()
if secret and len(secret) < 32:
raise ValueError("PRINTER_HUB_WEBHOOK_API_KEY must be at least 32 characters")
return v


@lru_cache
def get_settings() -> Settings:
"""Return the application settings, cached for the process lifetime.

Call ``get_settings.cache_clear()`` in tests that need a fresh read after
mutating environment variables.
"""
return Settings()
38 changes: 38 additions & 0 deletions backend/tests/unit/test_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Unit tests for app.config — Pydantic Settings."""

from __future__ import annotations

from pathlib import Path

import pytest
from app.config import Settings


def test_settings_load_from_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setenv("PRINTER_HUB_DATABASE_URL", f"sqlite:///{tmp_path}/test.db")
monkeypatch.setenv("PRINTER_HUB_QL820_HOST", "192.0.2.10")
monkeypatch.setenv("PRINTER_HUB_PT750W_HOST", "192.0.2.11")
monkeypatch.setenv("PRINTER_HUB_WEBHOOK_API_KEY", "test-key-32-bytes-long-enough-here")
monkeypatch.setenv("PRINTER_HUB_SNIPEIT_URL", "https://snipe-it.example")
monkeypatch.setenv("PRINTER_HUB_SNIPEIT_API_KEY", "snipeit-key")
monkeypatch.setenv("PRINTER_HUB_GROCY_URL", "https://grocy.example")
monkeypatch.setenv("PRINTER_HUB_GROCY_API_KEY", "grocy-key")
monkeypatch.setenv("PRINTER_HUB_SPOOLMAN_URL", "https://spoolman.example")

settings = Settings(_env_file=None)

assert settings.database_url == f"sqlite:///{tmp_path}/test.db"
Comment on lines +20 to +24
assert settings.ql820_host == "192.0.2.10"
assert settings.pt750w_host == "192.0.2.11"
assert settings.webhook_api_key.get_secret_value() == "test-key-32-bytes-long-enough-here"
assert settings.snipeit_url == "https://snipe-it.example"
assert settings.snipeit_api_key.get_secret_value() == "snipeit-key"
assert settings.grocy_url == "https://grocy.example"
assert settings.grocy_api_key.get_secret_value() == "grocy-key"
assert settings.spoolman_url == "https://spoolman.example"


def test_settings_rejects_short_api_key(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("PRINTER_HUB_WEBHOOK_API_KEY", "too-short")
with pytest.raises(ValueError, match="at least 32"):
Settings(_env_file=None)
Loading