labeljetty 0.1.0b1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- labeljetty/__init__.py +3 -0
- labeljetty/__main__.py +3 -0
- labeljetty/_version.py +24 -0
- labeljetty/app.py +64 -0
- labeljetty/config.py +245 -0
- labeljetty/core/__init__.py +0 -0
- labeljetty/core/db.py +121 -0
- labeljetty/core/logging.py +177 -0
- labeljetty/core/sqltypes.py +25 -0
- labeljetty/integrations/__init__.py +0 -0
- labeljetty/integrations/homebox.py +142 -0
- labeljetty/printer/__init__.py +15 -0
- labeljetty/printer/connection.py +405 -0
- labeljetty/printer/render.py +121 -0
- labeljetty/printer/tspl.py +1141 -0
- labeljetty/service/__init__.py +0 -0
- labeljetty/service/worker.py +338 -0
- labeljetty/testbench.py +281 -0
- labeljetty/version.py +41 -0
- labeljetty/web/__init__.py +0 -0
- labeljetty/web/api.py +394 -0
- labeljetty/web/app.py +153 -0
- labeljetty/web/auth.py +202 -0
- labeljetty/web/password.py +84 -0
- labeljetty/web/static/htmx.min.js +1 -0
- labeljetty/web/static/style.css +155 -0
- labeljetty/web/templates/_homebox.html +23 -0
- labeljetty/web/templates/_homebox_results.html +32 -0
- labeljetty/web/templates/_jobs.html +20 -0
- labeljetty/web/templates/_preview.html +14 -0
- labeljetty/web/templates/_print_result.html +5 -0
- labeljetty/web/templates/_status.html +42 -0
- labeljetty/web/templates/base.html +29 -0
- labeljetty/web/templates/homebox_setup.html +67 -0
- labeljetty/web/templates/index.html +190 -0
- labeljetty/web/templates/login.html +23 -0
- labeljetty/web/ui.py +524 -0
- labeljetty-0.1.0b1.dist-info/METADATA +64 -0
- labeljetty-0.1.0b1.dist-info/RECORD +42 -0
- labeljetty-0.1.0b1.dist-info/WHEEL +4 -0
- labeljetty-0.1.0b1.dist-info/entry_points.txt +4 -0
- labeljetty-0.1.0b1.dist-info/licenses/LICENSE +21 -0
labeljetty/__init__.py
ADDED
labeljetty/__main__.py
ADDED
labeljetty/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0b1'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0, 'b1')
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
labeljetty/app.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from typing import cast
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import uvicorn
|
|
4
|
+
import asyncio
|
|
5
|
+
from uvicorn.config import LOGGING_CONFIG
|
|
6
|
+
from uvicorn.config import LifespanType
|
|
7
|
+
|
|
8
|
+
from labeljetty.web.app import FastApiAppContainer
|
|
9
|
+
from labeljetty.service.worker import PrintServiceManager
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def run():
|
|
13
|
+
from labeljetty.config import Config
|
|
14
|
+
from labeljetty.core.logging import get_logger, get_uvicorn_loglevel
|
|
15
|
+
|
|
16
|
+
config = Config()
|
|
17
|
+
log = get_logger()
|
|
18
|
+
log.info(f"LOG_LEVEL: {config.LOG_LEVEL}")
|
|
19
|
+
log.info(f"UVICORN_LOG_LEVEL: {get_uvicorn_loglevel()}")
|
|
20
|
+
log.info(f"Create image storage directory at '{config.IMAGE_STORAGE_DIRECTORY}'")
|
|
21
|
+
log.info(f"USB Printer at {config.PRINTER_USB} if not exists")
|
|
22
|
+
if config.auth_enabled():
|
|
23
|
+
log.info(f"AUTH_MODE=protected — {len(config.AUTH_TOKENS)} token(s), {len(config.AUTH_USERS)} user(s)")
|
|
24
|
+
else:
|
|
25
|
+
log.warning(
|
|
26
|
+
"AUTH_MODE=open — NO AUTHENTICATION. Every endpoint is public. "
|
|
27
|
+
"Only run this on a trusted LAN. Set AUTH_MODE=protected with "
|
|
28
|
+
"AUTH_TOKENS/AUTH_USERS before exposing it more widely."
|
|
29
|
+
)
|
|
30
|
+
Path(config.IMAGE_STORAGE_DIRECTORY).mkdir(parents=True, exist_ok=True)
|
|
31
|
+
|
|
32
|
+
event_loop = asyncio.get_event_loop()
|
|
33
|
+
uvicorn_log_config = LOGGING_CONFIG
|
|
34
|
+
fast_api_container = FastApiAppContainer()
|
|
35
|
+
uvicorn_config = uvicorn.Config(
|
|
36
|
+
app=fast_api_container.app,
|
|
37
|
+
host=config.SERVER_LISTENING_HOST,
|
|
38
|
+
port=config.SERVER_LISTENING_PORT,
|
|
39
|
+
log_level=get_uvicorn_loglevel(),
|
|
40
|
+
log_config=uvicorn_log_config,
|
|
41
|
+
loop=event_loop,
|
|
42
|
+
lifespan=cast(LifespanType, "on"),
|
|
43
|
+
)
|
|
44
|
+
uvicorn_server = uvicorn.Server(config=uvicorn_config)
|
|
45
|
+
print_service = PrintServiceManager()
|
|
46
|
+
fast_api_container.add_startup_callback(print_service.start)
|
|
47
|
+
fast_api_container.add_shutdown_callback(print_service.shutdown)
|
|
48
|
+
try:
|
|
49
|
+
log.debug("Start uvicorn server...")
|
|
50
|
+
event_loop.run_until_complete(uvicorn_server.serve())
|
|
51
|
+
except KeyboardInterrupt:
|
|
52
|
+
log.info("KeyboardInterrupt shutdown...")
|
|
53
|
+
except Exception:
|
|
54
|
+
log.info("Panic shutdown...")
|
|
55
|
+
raise
|
|
56
|
+
finally:
|
|
57
|
+
# Always tear the worker down with us — uvicorn's lifespan shutdown does
|
|
58
|
+
# not run when KeyboardInterrupt escapes serve(), so the lifespan callback
|
|
59
|
+
# alone is not enough. shutdown() is idempotent.
|
|
60
|
+
print_service.shutdown()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
if __name__ == "__main__":
|
|
64
|
+
run()
|
labeljetty/config.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import TYPE_CHECKING, List, Self
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from pydantic import BaseModel, Field, model_validator
|
|
5
|
+
from pydantic_settings import BaseSettings
|
|
6
|
+
from typing import Literal, Optional
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from labeljetty.printer.connection import TSPLPrinterConnectionUSB
|
|
10
|
+
|
|
11
|
+
# `.env` is looked up relative to the current working directory (the repo root,
|
|
12
|
+
# per the README) — the override env var takes precedence. It is intentionally
|
|
13
|
+
# not resolved relative to this file, which now lives inside the package.
|
|
14
|
+
env_file_path = os.environ.get(
|
|
15
|
+
"LABELJETTY_DOT_ENV_FILE", Path(".env")
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class LabelProfile(BaseModel):
|
|
20
|
+
"""A named label stock (width × height in mm), selectable in the web UI."""
|
|
21
|
+
|
|
22
|
+
name: str
|
|
23
|
+
width_mm: int
|
|
24
|
+
height_mm: int
|
|
25
|
+
dpi: Optional[int] = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AuthToken(BaseModel):
|
|
29
|
+
"""A named API token for machine-to-machine access (sent as a Bearer token)."""
|
|
30
|
+
|
|
31
|
+
name: str
|
|
32
|
+
token: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AuthUser(BaseModel):
|
|
36
|
+
"""A local login user. ``password_hash`` is a ``pbkdf2_sha256$…`` string —
|
|
37
|
+
generate it with the ``labeljetty-hash-password`` CLI (never store plaintext)."""
|
|
38
|
+
|
|
39
|
+
username: str
|
|
40
|
+
password_hash: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Config(BaseSettings):
|
|
44
|
+
APP_NAME: str = "LabelJetty"
|
|
45
|
+
LOG_LEVEL: Literal["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"] = Field(
|
|
46
|
+
default="DEBUG"
|
|
47
|
+
)
|
|
48
|
+
LOG_DISABLE_COLORS: bool = False
|
|
49
|
+
UVICORN_LOG_LEVEL: Optional[str] = Field(
|
|
50
|
+
default=None,
|
|
51
|
+
description="The log level of the uvicorn server. If not defined it will be the same as LOG_LEVEL.",
|
|
52
|
+
)
|
|
53
|
+
SERVER_LISTENING_PORT: int = Field(default=8888)
|
|
54
|
+
SERVER_LISTENING_HOST: str = Field(
|
|
55
|
+
default="localhost",
|
|
56
|
+
examples=["0.0.0.0", "localhost", "127.0.0.1", "176.16.8.123"],
|
|
57
|
+
)
|
|
58
|
+
SQLITE_PATH: str = Field(default="./printjobs.sqlite")
|
|
59
|
+
IMAGE_STORAGE_DIRECTORY: str = Field(
|
|
60
|
+
default="./images", description="Storage for posted images to print"
|
|
61
|
+
)
|
|
62
|
+
# --- Authentication ----------------------------------------------------- #
|
|
63
|
+
# AUTH_MODE selects the policy. "open" (default) = no auth at all — intended
|
|
64
|
+
# for a trusted LAN appliance. See the fat warning in the README before
|
|
65
|
+
# exposing the service beyond a trusted network. "protected" = every route
|
|
66
|
+
# requires a valid credential from ANY configured provider (tokens and/or
|
|
67
|
+
# local users; OIDC is planned as a drop-in third provider).
|
|
68
|
+
AUTH_MODE: Literal["open", "protected"] = Field(default="open")
|
|
69
|
+
# API tokens for machine-to-machine access, as a JSON list, e.g.
|
|
70
|
+
# AUTH_TOKENS='[{"name":"ci","token":"s3cr3t"}]'
|
|
71
|
+
# Sent by clients as `Authorization: Bearer <token>`.
|
|
72
|
+
AUTH_TOKENS: List[AuthToken] = Field(default_factory=list)
|
|
73
|
+
# Local login users (humans, via the /login form → session cookie), e.g.
|
|
74
|
+
# AUTH_USERS='[{"username":"tim","password_hash":"pbkdf2_sha256$..."}]'
|
|
75
|
+
# Generate password_hash with: labeljetty-hash-password
|
|
76
|
+
AUTH_USERS: List[AuthUser] = Field(default_factory=list)
|
|
77
|
+
# Secret used to sign session cookies. Leave unset for an ephemeral random
|
|
78
|
+
# secret (logins won't survive a restart) — set a stable value in production.
|
|
79
|
+
SESSION_SECRET: Optional[str] = Field(default=None)
|
|
80
|
+
SESSION_COOKIE_NAME: str = Field(default="labeljetty_session")
|
|
81
|
+
SESSION_MAX_AGE: int = Field(default=1_209_600, description="Session lifetime in seconds (default 14 days).")
|
|
82
|
+
# --- OIDC (reserved — planned drop-in provider, not yet implemented) ----- #
|
|
83
|
+
# AUTH_OIDC_ISSUER / AUTH_OIDC_CLIENT_ID / AUTH_OIDC_CLIENT_SECRET will be
|
|
84
|
+
# added when OIDC lands. The auth framework already returns a Principal and
|
|
85
|
+
# resolves providers from the session, so OIDC slots in without route changes.
|
|
86
|
+
DELETE_OLD_JOBS_AFTER_DAYS: int = Field(
|
|
87
|
+
default=100,
|
|
88
|
+
description="Old job entries in the database will be removed with associated files.",
|
|
89
|
+
)
|
|
90
|
+
# Default label geometry — used when a print job does not specify its own.
|
|
91
|
+
DEFAULT_LABEL_WIDTH_MM: int = Field(default=100)
|
|
92
|
+
DEFAULT_LABEL_HEIGHT_MM: int = Field(default=30)
|
|
93
|
+
DEFAULT_DPI: int = Field(default=203)
|
|
94
|
+
# Named label profiles selectable in the web UI, as a JSON list, e.g.
|
|
95
|
+
# LABEL_PROFILES='[{"name":"Homebox","width_mm":57,"height_mm":32}]'
|
|
96
|
+
# The server default geometry is always offered as a "Default" profile too.
|
|
97
|
+
LABEL_PROFILES: List[LabelProfile] = Field(default_factory=list)
|
|
98
|
+
|
|
99
|
+
# --- Homebox integration (optional module) ------------------------------ #
|
|
100
|
+
# The module's UI section + endpoints appear only when HOMEBOX_URL and
|
|
101
|
+
# HOMEBOX_API_KEY are both set (and HOMEBOX_ENABLED is true).
|
|
102
|
+
HOMEBOX_ENABLED: bool = Field(default=True)
|
|
103
|
+
HOMEBOX_URL: Optional[str] = Field(
|
|
104
|
+
default=None, description="Base URL of the Homebox server, e.g. https://box.example.com"
|
|
105
|
+
)
|
|
106
|
+
HOMEBOX_API_KEY: Optional[str] = Field(
|
|
107
|
+
default=None, description="Homebox API key (prefixed 'hb_…')."
|
|
108
|
+
)
|
|
109
|
+
HOMEBOX_API_PREFIX: str = Field(
|
|
110
|
+
default="/api/v1",
|
|
111
|
+
description="API path prefix on the Homebox server (entities live at <prefix>/entities).",
|
|
112
|
+
)
|
|
113
|
+
HOMEBOX_ENTITY_URL_TEMPLATE: str = Field(
|
|
114
|
+
default="/item/{id}",
|
|
115
|
+
description="Web path (appended to HOMEBOX_URL) an entity opens at; '{id}' is substituted. Used for the QR link.",
|
|
116
|
+
)
|
|
117
|
+
HOMEBOX_LABEL_SERVICE_AUTOPRINT: bool = Field(
|
|
118
|
+
default=True,
|
|
119
|
+
description=(
|
|
120
|
+
"For the push 'external label service' endpoint: also enqueue the print "
|
|
121
|
+
"as a side effect of Homebox requesting the label image. Disable if your "
|
|
122
|
+
"Homebox build calls the label-service URL for previews too (would cause "
|
|
123
|
+
"spurious prints) — then use the print-command script (path C) instead."
|
|
124
|
+
),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def homebox_configured(self) -> bool:
|
|
128
|
+
"""True when the Homebox module should be active."""
|
|
129
|
+
return bool(self.HOMEBOX_ENABLED and self.HOMEBOX_URL and self.HOMEBOX_API_KEY)
|
|
130
|
+
|
|
131
|
+
def auth_enabled(self) -> bool:
|
|
132
|
+
"""True when routes require authentication (``AUTH_MODE == "protected"``)."""
|
|
133
|
+
return self.AUTH_MODE == "protected"
|
|
134
|
+
|
|
135
|
+
def find_user(self, username: str) -> Optional[AuthUser]:
|
|
136
|
+
"""Return the configured user with this username, or None."""
|
|
137
|
+
for user in self.AUTH_USERS:
|
|
138
|
+
if user.username == username:
|
|
139
|
+
return user
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
@model_validator(mode="after")
|
|
143
|
+
def validate_auth_config(self) -> Self:
|
|
144
|
+
"""Guard against a lock-everyone-out / no-op auth configuration."""
|
|
145
|
+
if self.AUTH_MODE == "protected" and not self.AUTH_TOKENS and not self.AUTH_USERS:
|
|
146
|
+
raise ValueError(
|
|
147
|
+
"AUTH_MODE=protected but neither AUTH_TOKENS nor AUTH_USERS is "
|
|
148
|
+
"configured — this would lock out every request. Configure at "
|
|
149
|
+
"least one token or user, or set AUTH_MODE=open."
|
|
150
|
+
)
|
|
151
|
+
return self
|
|
152
|
+
|
|
153
|
+
def get_label_profiles(self) -> List[LabelProfile]:
|
|
154
|
+
"""Configured profiles, preceded by the server-default geometry."""
|
|
155
|
+
default = LabelProfile(
|
|
156
|
+
name="Default",
|
|
157
|
+
width_mm=self.DEFAULT_LABEL_WIDTH_MM,
|
|
158
|
+
height_mm=self.DEFAULT_LABEL_HEIGHT_MM,
|
|
159
|
+
dpi=self.DEFAULT_DPI,
|
|
160
|
+
)
|
|
161
|
+
return [default, *self.LABEL_PROFILES]
|
|
162
|
+
# USB Printer - Choose ONE of these methods:
|
|
163
|
+
PRINTER_USB: str = Field(
|
|
164
|
+
description=(
|
|
165
|
+
"USB printer identifier. Can be:\n"
|
|
166
|
+
" - Serial number: 'serial:ABC123456'\n"
|
|
167
|
+
" - Device path: 'path:/dev/bus/usb/001/004' or 'path:001/004'\n"
|
|
168
|
+
" - USB port: 'port:3-1-2'\n"
|
|
169
|
+
" - Vendor+Product ID: 'vid:1234:pid:5678' or 'vid:1234' (first match)\n"
|
|
170
|
+
" - Bus+Address: 'bus:1:addr:4'"
|
|
171
|
+
),
|
|
172
|
+
examples=[
|
|
173
|
+
"serial:ABC123456",
|
|
174
|
+
"path:/dev/bus/usb/001/004",
|
|
175
|
+
"port:3-1-2",
|
|
176
|
+
"vid:1234:pid:5678",
|
|
177
|
+
"bus:1:addr:4",
|
|
178
|
+
],
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
@model_validator(mode="after")
|
|
182
|
+
def validate_printer_config(self) -> Self:
|
|
183
|
+
"""Ensure printer USB identifier is provided"""
|
|
184
|
+
if not self.PRINTER_USB:
|
|
185
|
+
raise ValueError(
|
|
186
|
+
"PRINTER_USB must be set. Examples:\n"
|
|
187
|
+
" PRINTER_USB=serial:ABC123456\n"
|
|
188
|
+
" PRINTER_USB=port:3-1-2\n"
|
|
189
|
+
" PRINTER_USB=vid:1234:pid:5678"
|
|
190
|
+
)
|
|
191
|
+
return self
|
|
192
|
+
|
|
193
|
+
def get_printer_connection(self) -> "TSPLPrinterConnectionUSB":
|
|
194
|
+
"""Returns a printer connection using the configured identifier"""
|
|
195
|
+
from labeljetty.printer.connection import TSPLPrinterConnectionUSB
|
|
196
|
+
|
|
197
|
+
usb_id = self.PRINTER_USB
|
|
198
|
+
|
|
199
|
+
if usb_id.startswith("serial:"):
|
|
200
|
+
serial = usb_id.split(":", 1)[1]
|
|
201
|
+
return TSPLPrinterConnectionUSB.by_serial(serial)
|
|
202
|
+
|
|
203
|
+
elif usb_id.startswith("path:"):
|
|
204
|
+
path = usb_id.split(":", 1)[1]
|
|
205
|
+
return TSPLPrinterConnectionUSB.by_device_path(path)
|
|
206
|
+
|
|
207
|
+
elif usb_id.startswith("port:"):
|
|
208
|
+
port = usb_id.split(":", 1)[1]
|
|
209
|
+
return TSPLPrinterConnectionUSB.by_port(port)
|
|
210
|
+
|
|
211
|
+
elif usb_id.startswith("bus:"):
|
|
212
|
+
# Format: bus:1:addr:4
|
|
213
|
+
parts = usb_id.split(":")
|
|
214
|
+
if len(parts) != 4 or parts[2] != "addr":
|
|
215
|
+
raise ValueError(
|
|
216
|
+
f"Invalid bus format: {usb_id}. Expected 'bus:1:addr:4'"
|
|
217
|
+
)
|
|
218
|
+
bus = int(parts[1])
|
|
219
|
+
addr = int(parts[3])
|
|
220
|
+
return TSPLPrinterConnectionUSB.by_bus_and_device_id(bus, addr)
|
|
221
|
+
|
|
222
|
+
elif usb_id.startswith("vid:"):
|
|
223
|
+
# Format: vid:1234:pid:5678 or vid:1234
|
|
224
|
+
parts = usb_id.split(":")
|
|
225
|
+
vendor = parts[1]
|
|
226
|
+
product = parts[3] if len(parts) >= 4 and parts[2] == "pid" else None
|
|
227
|
+
return TSPLPrinterConnectionUSB.by_vendor_and_product_id(vendor, product)
|
|
228
|
+
|
|
229
|
+
else:
|
|
230
|
+
raise ValueError(
|
|
231
|
+
f"Invalid PRINTER_USB format: {usb_id}\n"
|
|
232
|
+
"Must start with: serial:, path:, port:, bus:, or vid:"
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
###### CONFIG END ######
|
|
236
|
+
# "class Config:" is a pydantic-settings pre-defined config class to control the behaviour of our settings model
|
|
237
|
+
# you could call it a "meta config" class
|
|
238
|
+
# if you dont know what this is you can ignore it.
|
|
239
|
+
# https://docs.pydantic.dev/latest/api/base_model/#pydantic.main.BaseModel.model_config
|
|
240
|
+
|
|
241
|
+
class Config:
|
|
242
|
+
env_nested_delimiter = "__"
|
|
243
|
+
env_file = env_file_path
|
|
244
|
+
env_file_encoding = "utf-8"
|
|
245
|
+
extra = "ignore"
|
|
File without changes
|
labeljetty/core/db.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
from typing import Optional, Literal, Dict, Generator, Any
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from sqlmodel import SQLModel, Field, Column, Session, create_engine
|
|
4
|
+
from sqlalchemy import String
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from labeljetty.core.sqltypes import SqlJsonText
|
|
7
|
+
from labeljetty.config import Config
|
|
8
|
+
import uuid
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from pydantic import field_serializer, field_validator
|
|
11
|
+
from labeljetty.printer import JobType, TSPLPrinterStatusMessage
|
|
12
|
+
|
|
13
|
+
config = Config()
|
|
14
|
+
# Database URL - configure as needed
|
|
15
|
+
DATABASE_URL = f"sqlite:///{config.SQLITE_PATH}"
|
|
16
|
+
|
|
17
|
+
# Create engine
|
|
18
|
+
engine = create_engine(DATABASE_URL, echo=False)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PrintJob(SQLModel, table=True):
|
|
22
|
+
id: Optional[uuid.UUID] = Field(default_factory=uuid.uuid4, primary_key=True)
|
|
23
|
+
|
|
24
|
+
job_type: JobType = Field(
|
|
25
|
+
sa_column=Column(String), description="Which renderer the worker should use"
|
|
26
|
+
)
|
|
27
|
+
# Type-specific parameters (text content, barcode_type, ecc_level, font_size, ...)
|
|
28
|
+
params: Dict[str, Any] = Field(
|
|
29
|
+
default_factory=dict,
|
|
30
|
+
sa_column=Column(SqlJsonText),
|
|
31
|
+
)
|
|
32
|
+
# Stored upload for file-based jobs (png/pdf); None for parameter-only jobs.
|
|
33
|
+
input_file_name: Optional[str] = None
|
|
34
|
+
|
|
35
|
+
# Per-job label geometry override; None → fall back to config defaults.
|
|
36
|
+
label_width_mm: Optional[int] = None
|
|
37
|
+
label_height_mm: Optional[int] = None
|
|
38
|
+
dpi: Optional[int] = None
|
|
39
|
+
copies: int = 1
|
|
40
|
+
|
|
41
|
+
error: Optional[str] = None
|
|
42
|
+
printer_status_on_finished: Optional[TSPLPrinterStatusMessage] = Field(
|
|
43
|
+
default=None,
|
|
44
|
+
sa_column=Column(SqlJsonText),
|
|
45
|
+
)
|
|
46
|
+
created_at: datetime = Field(default_factory=datetime.now)
|
|
47
|
+
started_at: Optional[datetime] = None
|
|
48
|
+
finished_at: Optional[datetime] = None
|
|
49
|
+
|
|
50
|
+
def get_status(self) -> Literal["queued", "processing", "done", "failed"]:
|
|
51
|
+
if self.started_at is None:
|
|
52
|
+
return "queued"
|
|
53
|
+
elif self.finished_at is None:
|
|
54
|
+
return "processing"
|
|
55
|
+
elif self.error is not None:
|
|
56
|
+
return "failed"
|
|
57
|
+
else:
|
|
58
|
+
return "done"
|
|
59
|
+
|
|
60
|
+
def get_input_file_path(self) -> Optional[Path]:
|
|
61
|
+
if self.input_file_name is None:
|
|
62
|
+
return None
|
|
63
|
+
return Path(f"{config.IMAGE_STORAGE_DIRECTORY}/{self.input_file_name}")
|
|
64
|
+
|
|
65
|
+
@field_serializer("printer_status_on_finished")
|
|
66
|
+
def serialize_printer_status(
|
|
67
|
+
self, value: Optional[TSPLPrinterStatusMessage]
|
|
68
|
+
) -> Optional[Dict[str, Any]]:
|
|
69
|
+
"""Convert TSPLPrinterStatusMessage to dict for database storage"""
|
|
70
|
+
return value.model_dump() if value is not None else None
|
|
71
|
+
|
|
72
|
+
@field_validator("printer_status_on_finished", mode="before")
|
|
73
|
+
@classmethod
|
|
74
|
+
def validate_printer_status(cls, value: Dict[str, Any] | None):
|
|
75
|
+
"""Convert dict to TSPLPrinterStatusMessage when loading from database"""
|
|
76
|
+
if value is None or isinstance(value, TSPLPrinterStatusMessage):
|
|
77
|
+
return value
|
|
78
|
+
if isinstance(value, dict):
|
|
79
|
+
return TSPLPrinterStatusMessage(**value)
|
|
80
|
+
return value
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class WorkerStatus(SQLModel, table=True):
|
|
84
|
+
id: Optional[int] = Field(
|
|
85
|
+
default=1, primary_key=True, description="dummy pk field. Will always be one"
|
|
86
|
+
)
|
|
87
|
+
worker_error: Optional[str] = Field(
|
|
88
|
+
default=None, description="If set the worker is dead"
|
|
89
|
+
)
|
|
90
|
+
process_id: Optional[int] = Field(default=None)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# Database initialization
|
|
94
|
+
def init_db():
|
|
95
|
+
"""Initialize database tables"""
|
|
96
|
+
SQLModel.metadata.create_all(engine)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# Session management
|
|
100
|
+
@contextmanager
|
|
101
|
+
def get_session() -> Generator[Session, None, None]:
|
|
102
|
+
"""Context manager for database sessions.
|
|
103
|
+
|
|
104
|
+
``expire_on_commit=False`` keeps attributes populated after commit so jobs can
|
|
105
|
+
be safely returned/serialized once the session has closed (FastAPI responses,
|
|
106
|
+
the worker handing a fetched job to the printer).
|
|
107
|
+
"""
|
|
108
|
+
session = Session(engine, expire_on_commit=False)
|
|
109
|
+
try:
|
|
110
|
+
yield session
|
|
111
|
+
session.commit()
|
|
112
|
+
except Exception:
|
|
113
|
+
session.rollback()
|
|
114
|
+
raise
|
|
115
|
+
finally:
|
|
116
|
+
session.close()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def get_db_url() -> str:
|
|
120
|
+
"""Get database URL"""
|
|
121
|
+
return DATABASE_URL
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sys
|
|
3
|
+
import os
|
|
4
|
+
import hashlib
|
|
5
|
+
from typing import Optional, Dict, Tuple
|
|
6
|
+
import inspect
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from labeljetty.config import Config
|
|
9
|
+
|
|
10
|
+
config = Config()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
APP_LOGGER_DEFAULT_NAME = config.APP_NAME
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# suppress "AttributeError: module 'bcrypt' has no attribute '__about__'"-warning
|
|
17
|
+
# https://github.com/pyca/bcrypt/issues/684
|
|
18
|
+
logging.getLogger("passlib").setLevel(logging.ERROR)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ANSI Color codes
|
|
22
|
+
class Colors:
|
|
23
|
+
"""ANSI color codes for terminal output"""
|
|
24
|
+
|
|
25
|
+
RESET = "\033[0m"
|
|
26
|
+
|
|
27
|
+
# Log level colors
|
|
28
|
+
DEBUG = "\033[36m" # Cyan
|
|
29
|
+
INFO = "\033[32m" # Green
|
|
30
|
+
WARNING = "\033[33m" # Yellow
|
|
31
|
+
ERROR = "\033[31m" # Red
|
|
32
|
+
CRITICAL = "\033[35m" # Magenta
|
|
33
|
+
|
|
34
|
+
# Module name colors - distinctive, neutral palette
|
|
35
|
+
MODULE_COLORS = [
|
|
36
|
+
"\033[34m", # Blue
|
|
37
|
+
"\033[33m", # Yellow
|
|
38
|
+
"\033[32m", # Green
|
|
39
|
+
"\033[36m", # Cyan
|
|
40
|
+
"\033[35m", # Magenta
|
|
41
|
+
"\033[94m", # Bright Blue
|
|
42
|
+
"\033[93m", # Bright Yellow
|
|
43
|
+
"\033[92m", # Bright Green
|
|
44
|
+
"\033[96m", # Bright Cyan
|
|
45
|
+
"\033[95m", # Bright Magenta
|
|
46
|
+
"\033[91m", # Bright Red (subtle, for modules)
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_loglevel():
|
|
51
|
+
return os.getenv("LOG_LEVEL", config.LOG_LEVEL)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_module_color(module_name: str) -> str:
|
|
55
|
+
"""
|
|
56
|
+
Generate a deterministic, unique color for each module name.
|
|
57
|
+
Same module name will always get the same color.
|
|
58
|
+
"""
|
|
59
|
+
if config.LOG_DISABLE_COLORS:
|
|
60
|
+
return ""
|
|
61
|
+
|
|
62
|
+
hash_digest = hashlib.md5(module_name.encode()).hexdigest()
|
|
63
|
+
hash_int = int(hash_digest, 16)
|
|
64
|
+
color_index = hash_int % len(Colors.MODULE_COLORS)
|
|
65
|
+
return Colors.MODULE_COLORS[color_index]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get_loglevel_color(level: int) -> str:
|
|
69
|
+
"""Get the color for a specific log level"""
|
|
70
|
+
if config.LOG_DISABLE_COLORS:
|
|
71
|
+
return ""
|
|
72
|
+
|
|
73
|
+
if level >= logging.CRITICAL:
|
|
74
|
+
return Colors.CRITICAL
|
|
75
|
+
elif level >= logging.ERROR:
|
|
76
|
+
return Colors.ERROR
|
|
77
|
+
elif level >= logging.WARNING:
|
|
78
|
+
return Colors.WARNING
|
|
79
|
+
elif level >= logging.INFO:
|
|
80
|
+
return Colors.INFO
|
|
81
|
+
else: # DEBUG and below
|
|
82
|
+
return Colors.DEBUG
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class ColoredFormatter(logging.Formatter):
|
|
86
|
+
"""Custom formatter that adds colors to log output"""
|
|
87
|
+
|
|
88
|
+
GRAY = "\033[90m" # Bright black (dark gray)
|
|
89
|
+
|
|
90
|
+
def format(self, record):
|
|
91
|
+
# Get colors
|
|
92
|
+
levelcolor = get_loglevel_color(record.levelno)
|
|
93
|
+
|
|
94
|
+
# Color the level name
|
|
95
|
+
record.levelname = f"{levelcolor}{record.levelname}{Colors.RESET if not config.LOG_DISABLE_COLORS else ''}"
|
|
96
|
+
|
|
97
|
+
# Format the record
|
|
98
|
+
result = super().format(record)
|
|
99
|
+
|
|
100
|
+
# Color the timestamp gray if colors are enabled
|
|
101
|
+
if not config.LOG_DISABLE_COLORS:
|
|
102
|
+
parts = result.split(" - ", 1)
|
|
103
|
+
if parts:
|
|
104
|
+
timestamp = parts[0]
|
|
105
|
+
rest = " - " + parts[1] if len(parts) > 1 else ""
|
|
106
|
+
result = f"{self.GRAY}{timestamp}{Colors.RESET}{rest}"
|
|
107
|
+
|
|
108
|
+
return result
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
active_loggers_store = None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def get_logger(
|
|
115
|
+
name: Optional[str] = APP_LOGGER_DEFAULT_NAME, modulename: Optional[str] = ""
|
|
116
|
+
) -> logging.Logger:
|
|
117
|
+
global active_loggers_store
|
|
118
|
+
if active_loggers_store is None:
|
|
119
|
+
active_loggers_store = {}
|
|
120
|
+
if not modulename:
|
|
121
|
+
modulename = Path(inspect.stack()[1].filename).name
|
|
122
|
+
store_name = f"{name}{modulename}"
|
|
123
|
+
module = ""
|
|
124
|
+
module_color_code = ""
|
|
125
|
+
|
|
126
|
+
if modulename:
|
|
127
|
+
module_color_code = get_module_color(modulename)
|
|
128
|
+
module = f" - [{module_color_code}{modulename}{Colors.RESET}]"
|
|
129
|
+
|
|
130
|
+
logger_ = None
|
|
131
|
+
|
|
132
|
+
if store_name not in active_loggers_store:
|
|
133
|
+
logger_ = logging.getLogger(store_name)
|
|
134
|
+
logger_.setLevel(get_loglevel())
|
|
135
|
+
|
|
136
|
+
# Clear existing handlers to avoid duplicate logs
|
|
137
|
+
logger_.handlers.clear()
|
|
138
|
+
|
|
139
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
140
|
+
|
|
141
|
+
format_string = f"%(asctime)s - {name}{module} - %(levelname)s - %(message)s"
|
|
142
|
+
formatter = ColoredFormatter(format_string)
|
|
143
|
+
handler.setFormatter(formatter)
|
|
144
|
+
|
|
145
|
+
logger_.addHandler(handler)
|
|
146
|
+
active_loggers_store[store_name] = logger_
|
|
147
|
+
else:
|
|
148
|
+
logger_ = active_loggers_store[store_name]
|
|
149
|
+
|
|
150
|
+
return logger_
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def get_uvicorn_loglevel() -> str:
|
|
154
|
+
# uvicorn has a different log level naming system than python, we need to translate the log level setting
|
|
155
|
+
UVICORN_LOG_LEVEL_map: Dict[Tuple[int | str, ...], str] = {
|
|
156
|
+
(logging.NOTSET, "NOTSET", "notset", "0"): "trace",
|
|
157
|
+
(logging.CRITICAL, "50", "CRITICAL", "critical", "FATAL", "fatal"): "critical",
|
|
158
|
+
(logging.ERROR, "40", "ERROR", "error"): "error",
|
|
159
|
+
(logging.WARNING, "30", "WARNING", "warning", "WARN", "warn"): "warning",
|
|
160
|
+
(logging.INFO, "20", "INFO", "info"): "info",
|
|
161
|
+
(logging.DEBUG, "10", "DEBUG", "debug"): "debug",
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
# if the uvicorn log level is not defined, it will be the same as the python log level
|
|
165
|
+
UVICORN_LOG_LEVEL: str = (
|
|
166
|
+
config.UVICORN_LOG_LEVEL
|
|
167
|
+
if config.UVICORN_LOG_LEVEL is not None
|
|
168
|
+
else config.LOG_LEVEL
|
|
169
|
+
)
|
|
170
|
+
uvicorn_log_level_mapped: str | None = None
|
|
171
|
+
for key, val in UVICORN_LOG_LEVEL_map.items():
|
|
172
|
+
if UVICORN_LOG_LEVEL in key:
|
|
173
|
+
uvicorn_log_level_mapped = val
|
|
174
|
+
break
|
|
175
|
+
if uvicorn_log_level_mapped is None:
|
|
176
|
+
uvicorn_log_level_mapped = "info"
|
|
177
|
+
return uvicorn_log_level_mapped
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from typing import Any, Optional
|
|
2
|
+
from sqlalchemy.types import TypeDecorator, Text
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SqlJsonText(TypeDecorator):
|
|
7
|
+
"""Stores JSON-serializable Python objects (dicts or Pydantic models) as TEXT.
|
|
8
|
+
|
|
9
|
+
Pydantic models are dumped via ``model_dump()`` before serialization; ``None``
|
|
10
|
+
is stored as SQL NULL and read back as ``None``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
impl = Text
|
|
14
|
+
|
|
15
|
+
def process_bind_param(self, value: Any, dialect) -> Optional[str]:
|
|
16
|
+
if value is None:
|
|
17
|
+
return None
|
|
18
|
+
if hasattr(value, "model_dump"):
|
|
19
|
+
value = value.model_dump()
|
|
20
|
+
return json.dumps(value)
|
|
21
|
+
|
|
22
|
+
def process_result_value(self, value: Optional[str], dialect) -> Any:
|
|
23
|
+
if value is None:
|
|
24
|
+
return None
|
|
25
|
+
return json.loads(value)
|
|
File without changes
|