diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json index 88ff2ae85..870e9d5bc 100644 --- a/client/public/locales/en/common.json +++ b/client/public/locales/en/common.json @@ -356,6 +356,35 @@ "delete_confirm_description": "This will delete the field and all associated data for all entities." } }, + "applications": { + "tab": "Applications", + "description": "

Applications add optional, pre-defined behaviors to your Spoolman instance.

Enabling an application can add specialized display, actions, calculated values, or list columns. Disabled applications remain hidden so the standard UI stays simpler.

", + "empty": "No applications are currently available.", + "all": "All", + "status": { + "active": "Active", + "partial": "Partial", + "inactive": "Inactive" + }, + "columns": { + "name": "Name", + "description": "Feature", + "enable_description": "What Enabling Adds", + "surfaces": "Surfaces", + "enabled": "Enabled" + }, + "surfaces": { + "show": "Show", + "edit": "Edit", + "list": "List", + "action": "Action", + "derived": "Derived" + }, + "messages": { + "enabled": "Enabled {{name}}.", + "disabled": "Disabled {{name}}." + } + }, "documentTitle": { "default": "Spoolman", "suffix": " | Spoolman", @@ -368,6 +397,9 @@ "locations": { "list": "Locations | Spoolman" }, + "applications": { + "list": "Applications | Spoolman" + }, "filament": { "list": "Filaments | Spoolman", "show": "#{{id}} Show Filament | Spoolman", diff --git a/client/src/App.tsx b/client/src/App.tsx index d907b8ee1..f1c1c5b03 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -6,6 +6,7 @@ import { ErrorComponent } from "@refinedev/antd"; import "@refinedev/antd/dist/reset.css"; import { + AppstoreOutlined, FileOutlined, HighlightOutlined, HomeOutlined, @@ -150,6 +151,14 @@ function App() { icon: , }, }, + { + name: "applications", + list: "/applications", + meta: { + canDelete: false, + icon: , + }, + }, { name: "settings", list: "/settings", @@ -225,6 +234,8 @@ function App() { } /> } /> } /> + } /> + } /> } /> diff --git a/client/src/components/applicationCard.tsx b/client/src/components/applicationCard.tsx new file mode 100644 index 000000000..74526948a --- /dev/null +++ b/client/src/components/applicationCard.tsx @@ -0,0 +1,87 @@ +import { useTranslate } from "@refinedev/core"; +import { Avatar, Badge, Button, Card, Space, Switch, Tag, Typography } from "antd"; +import { useNavigate } from "react-router"; +import { ApplicationState, EntityType } from "../utils/queryFields"; + +interface ApplicationCardProps { + appKey: string; + name: string; + description: string; + icon: string | null; + states: ApplicationState[]; + onToggle: (entityType: EntityType, key: string, enabled: boolean) => Promise; + toggling: { entityType: EntityType; key: string } | null; +} + +const ENTITY_COLORS: Record = { + [EntityType.spool]: "blue", + [EntityType.filament]: "green", + [EntityType.vendor]: "orange", +}; + +export function ApplicationCard({ appKey, name, description, icon, states, onToggle, toggling }: ApplicationCardProps) { + const t = useTranslate(); + const navigate = useNavigate(); + + const anyEnabled = states.some((s) => s.enabled); + const allEnabled = states.length > 0 && states.every((s) => s.enabled); + + const badgeStatus = allEnabled ? "success" : anyEnabled ? "warning" : "default"; + const badgeText = allEnabled + ? t("applications.status.active") + : anyEnabled + ? t("applications.status.partial") + : t("applications.status.inactive"); + + const avatarContent = icon ?? name.charAt(0).toUpperCase(); + + return ( + + navigate(`/applications/${appKey}`)}> + Configure + , + ]} + > + + {avatarContent} + + } + title={{name}} + description={ + + {description} + + } + /> + + {states.map((state) => ( + + {t(`${state.entity_type}.${state.entity_type}`)} + onToggle(state.entity_type, state.key, checked)} + onClick={(_, e) => e.stopPropagation()} + style={{ marginLeft: 4 }} + /> + + ))} + + + + ); +} diff --git a/client/src/pages/applications/detail/index.tsx b/client/src/pages/applications/detail/index.tsx new file mode 100644 index 000000000..f32f7ea98 --- /dev/null +++ b/client/src/pages/applications/detail/index.tsx @@ -0,0 +1,176 @@ +import { LeftOutlined } from "@ant-design/icons"; +import { useTranslate } from "@refinedev/core"; +import { Avatar, Button, Col, Empty, Form, Row, Space, Switch, Table, Tag, Typography, message, theme } from "antd"; +import { ColumnType } from "antd/es/table"; +import { useMemo, useState } from "react"; +import { useNavigate, useParams } from "react-router"; +import { + ApplicationState, + ApplicationSurface, + EntityType, + useGetApplications, + useSetApplicationEnabled, +} from "../../../utils/queryFields"; + +const ENTITY_COLORS: Record = { + [EntityType.spool]: "blue", + [EntityType.filament]: "green", + [EntityType.vendor]: "orange", +}; + +export const ApplicationDetail = () => { + const { appKey } = useParams<{ appKey: string }>(); + const t = useTranslate(); + const { token } = theme.useToken(); + const navigate = useNavigate(); + const [messageApi, contextHolder] = message.useMessage(); + const [pendingKey, setPendingKey] = useState(null); + + const spoolApps = useGetApplications(EntityType.spool); + const filamentApps = useGetApplications(EntityType.filament); + const vendorApps = useGetApplications(EntityType.vendor); + + const setSpoolEnabled = useSetApplicationEnabled(EntityType.spool); + const setFilamentEnabled = useSetApplicationEnabled(EntityType.filament); + const setVendorEnabled = useSetApplicationEnabled(EntityType.vendor); + + const states = useMemo(() => { + const all: ApplicationState[] = [ + ...(spoolApps.data ?? []), + ...(filamentApps.data ?? []), + ...(vendorApps.data ?? []), + ]; + return all.filter((s) => (s.app_key ?? s.key) === appKey); + }, [spoolApps.data, filamentApps.data, vendorApps.data, appKey]); + + const isLoading = spoolApps.isLoading || filamentApps.isLoading || vendorApps.isLoading; + const first = states[0]; + + const handleToggle = async (record: ApplicationState, enabled: boolean) => { + setPendingKey(`${record.entity_type}:${record.key}`); + try { + const setter = + record.entity_type === EntityType.spool + ? setSpoolEnabled + : record.entity_type === EntityType.filament + ? setFilamentEnabled + : setVendorEnabled; + await setter.mutateAsync({ key: record.key, enabled }); + messageApi.success( + t(enabled ? "applications.messages.enabled" : "applications.messages.disabled", { name: record.name }), + ); + } catch (err) { + if (err instanceof Error) { + messageApi.error(err.message); + } + } finally { + setPendingKey(null); + } + }; + + const columns: ColumnType[] = [ + { + title: t("applications.columns.name"), + dataIndex: "name", + key: "name", + width: "18%", + }, + { + title: "Entity", + dataIndex: "entity_type", + key: "entity_type", + width: "12%", + render: (et: EntityType) => {t(`${et}.${et}`)}, + }, + { + title: t("applications.columns.description"), + dataIndex: "description", + key: "description", + width: "24%", + render: (value: string) => ( + {value} + ), + }, + { + title: t("applications.columns.enable_description"), + dataIndex: "enable_description", + key: "enable_description", + width: "28%", + render: (value: string) => ( + {value} + ), + }, + { + title: t("applications.columns.surfaces"), + dataIndex: "surfaces", + key: "surfaces", + width: "10%", + render: (surfaces: ApplicationSurface[]) => ( + + {surfaces.map((s) => ( + {t(`applications.surfaces.${s}`)} + ))} + + ), + }, + { + title: t("applications.columns.enabled"), + dataIndex: "enabled", + key: "enabled", + align: "center", + width: "8%", + render: (enabled: boolean, record) => ( + handleToggle(record, checked)} + /> + ), + }, + ]; + + return ( +
+ {contextHolder} + + + {first && ( + + + + {first.icon ?? first.name.charAt(0).toUpperCase()} + + + + + {first.name} + + {first.description} + + + )} + +
+ `${r.entity_type}:${r.key}`} + locale={{ + emptyText: , + }} + /> + + + ); +}; + +export default ApplicationDetail; diff --git a/client/src/pages/applications/index.tsx b/client/src/pages/applications/index.tsx new file mode 100644 index 000000000..7ad3126b2 --- /dev/null +++ b/client/src/pages/applications/index.tsx @@ -0,0 +1,125 @@ +import { useTranslate } from "@refinedev/core"; +import { Col, Empty, Row, Segmented, Spin, Typography, message, theme } from "antd"; +import { useMemo, useState } from "react"; +import { ApplicationCard } from "../../components/applicationCard"; +import { ApplicationState, EntityType, useGetApplications, useSetApplicationEnabled } from "../../utils/queryFields"; + +type FilterMode = "all" | "enabled" | "disabled"; + +interface TogglingState { + entityType: EntityType; + key: string; +} + +export const Applications = () => { + const t = useTranslate(); + const { token } = theme.useToken(); + const [messageApi, contextHolder] = message.useMessage(); + const [filter, setFilter] = useState("all"); + const [toggling, setToggling] = useState(null); + + const spoolApps = useGetApplications(EntityType.spool); + const filamentApps = useGetApplications(EntityType.filament); + const vendorApps = useGetApplications(EntityType.vendor); + + const setSpoolEnabled = useSetApplicationEnabled(EntityType.spool); + const setFilamentEnabled = useSetApplicationEnabled(EntityType.filament); + const setVendorEnabled = useSetApplicationEnabled(EntityType.vendor); + + const isLoading = spoolApps.isLoading || filamentApps.isLoading || vendorApps.isLoading; + + // Merge all states by app_key, falling back to key + const groupedApps = useMemo(() => { + const all: ApplicationState[] = [ + ...(spoolApps.data ?? []), + ...(filamentApps.data ?? []), + ...(vendorApps.data ?? []), + ]; + + const byAppKey = new Map(); + for (const state of all) { + const groupKey = state.app_key ?? state.key; + if (!byAppKey.has(groupKey)) { + byAppKey.set(groupKey, []); + } + byAppKey.get(groupKey)!.push(state); + } + return byAppKey; + }, [spoolApps.data, filamentApps.data, vendorApps.data]); + + const filteredGroups = useMemo(() => { + const entries = Array.from(groupedApps.entries()); + if (filter === "enabled") return entries.filter(([, states]) => states.some((s) => s.enabled)); + if (filter === "disabled") return entries.filter(([, states]) => states.every((s) => !s.enabled)); + return entries; + }, [groupedApps, filter]); + + const handleToggle = async (entityType: EntityType, key: string, enabled: boolean) => { + setToggling({ entityType, key }); + try { + const setter = + entityType === EntityType.spool + ? setSpoolEnabled + : entityType === EntityType.filament + ? setFilamentEnabled + : setVendorEnabled; + await setter.mutateAsync({ key, enabled }); + messageApi.success( + enabled + ? t("applications.messages.enabled", { name: key }) + : t("applications.messages.disabled", { name: key }), + ); + } catch (err) { + if (err instanceof Error) { + messageApi.error(err.message); + } + } finally { + setToggling(null); + } + }; + + return ( +
+ {contextHolder} +

{t("applications.tab")}

+ + {t("applications.description")} + + setFilter(val as FilterMode)} + style={{ marginBottom: 24 }} + /> + {isLoading ? ( + + ) : filteredGroups.length === 0 ? ( + + ) : ( + + {filteredGroups.map(([appKey, states]) => { + const first = states[0]; + return ( +
+ + + ); + })} + + )} + + ); +}; + +export default Applications; diff --git a/client/src/utils/queryFields.ts b/client/src/utils/queryFields.ts index 7cde38c05..531196166 100644 --- a/client/src/utils/queryFields.ts +++ b/client/src/utils/queryFields.ts @@ -19,6 +19,14 @@ export enum EntityType { spool = "spool", } +export enum ApplicationSurface { + show = "show", + edit = "edit", + list = "list", + action = "action", + derived = "derived", +} + export interface FieldParameters { name: string; order: number; @@ -34,6 +42,21 @@ export interface Field extends FieldParameters { entity_type: EntityType; } +export interface ApplicationDefinition { + key: string; + app_key: string | null; + icon: string | null; + entity_type: EntityType; + name: string; + description: string; + enable_description: string; + surfaces: ApplicationSurface[]; +} + +export interface ApplicationState extends ApplicationDefinition { + enabled: boolean; +} + export function useGetFields(entity_type: EntityType) { return useQuery({ queryKey: ["fields", entity_type], @@ -134,3 +157,73 @@ export function useDeleteField(entity_type: EntityType) { }, }); } + +export function useGetApplications(entity_type: EntityType) { + return useQuery({ + queryKey: ["applications", entity_type], + queryFn: async () => { + const response = await fetch(`${getAPIURL()}/field/application/${entity_type}`); + return response.json(); + }, + }); +} + +export function useSetApplicationEnabled(entity_type: EntityType) { + const queryClient = useQueryClient(); + + return useMutation< + ApplicationState[], + unknown, + { key: string; enabled: boolean }, + { previousFields?: ApplicationState[] } + >({ + mutationFn: async ({ key, enabled }) => { + const response = await fetch(`${getAPIURL()}/field/application/${entity_type}/${key}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ enabled }), + }); + + if (!response.ok) { + throw new Error((await response.json()).message); + } + + return response.json(); + }, + onMutate: async ({ key, enabled }) => { + await queryClient.cancelQueries({ + queryKey: ["applications", entity_type], + }); + + const previousFields = queryClient.getQueryData(["applications", entity_type]); + + queryClient.setQueryData( + ["applications", entity_type], + (old) => + old?.map((field) => { + if (field.key !== key) { + return field; + } + return { + ...field, + enabled, + }; + }) || old, + ); + + return { previousFields }; + }, + onError: (_err, _newFields, context) => { + if (context?.previousFields) { + queryClient.setQueryData(["applications", entity_type], context.previousFields); + } + }, + onSettled: () => { + queryClient.invalidateQueries({ + queryKey: ["applications", entity_type], + }); + }, + }); +} diff --git a/migrations/versions/2025_01_01_0000-a1b2c3d4e5f6_rename_complex_fields_to_applications.py b/migrations/versions/2025_01_01_0000-a1b2c3d4e5f6_rename_complex_fields_to_applications.py new file mode 100644 index 000000000..561e424bd --- /dev/null +++ b/migrations/versions/2025_01_01_0000-a1b2c3d4e5f6_rename_complex_fields_to_applications.py @@ -0,0 +1,35 @@ +"""rename_complex_fields_to_applications. + +Revision ID: a1b2c3d4e5f6 +Revises: 415a8f855e14 +Create Date: 2025-01-01 00:00:00.000000 +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "a1b2c3d4e5f6" +down_revision = "415a8f855e14" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Rename complex_fields_* setting keys to applications_* keys.""" + connection = op.get_bind() + connection.execute(sa.text("UPDATE setting SET key = 'applications_vendor' WHERE key = 'complex_fields_vendor'")) + connection.execute( + sa.text("UPDATE setting SET key = 'applications_filament' WHERE key = 'complex_fields_filament'") + ) + connection.execute(sa.text("UPDATE setting SET key = 'applications_spool' WHERE key = 'complex_fields_spool'")) + + +def downgrade() -> None: + """Revert applications_* setting keys back to complex_fields_* keys.""" + connection = op.get_bind() + connection.execute(sa.text("UPDATE setting SET key = 'complex_fields_vendor' WHERE key = 'applications_vendor'")) + connection.execute( + sa.text("UPDATE setting SET key = 'complex_fields_filament' WHERE key = 'applications_filament'") + ) + connection.execute(sa.text("UPDATE setting SET key = 'complex_fields_spool' WHERE key = 'applications_spool'")) diff --git a/spoolman/api/v1/field.py b/spoolman/api/v1/field.py index 36e63d845..1d2150e61 100644 --- a/spoolman/api/v1/field.py +++ b/spoolman/api/v1/field.py @@ -8,6 +8,12 @@ from sqlalchemy.ext.asyncio import AsyncSession from spoolman.api.v1.models import Message +from spoolman.applications import ( + ApplicationState, + ApplicationToggleRequest, + get_applications, + set_application_enabled, +) from spoolman.database.database import get_db_session from spoolman.exceptions import ItemNotFoundError from spoolman.extra_fields import ( @@ -29,6 +35,49 @@ logger = logging.getLogger(__name__) +@router.get( + "/application/{entity_type}", + name="Get applications", + description="Get all registered applications for a specific entity type, including enabled state.", + response_model_exclude_none=True, +) +async def get_application_list( + db: Annotated[AsyncSession, Depends(get_db_session)], + entity_type: Annotated[EntityType, Path(description="Entity type this application is for")], +) -> list[ApplicationState]: + return await get_applications(db, entity_type) + + +@router.post( + "/application/{entity_type}/{key}", + name="Enable or disable application", + description=( + "Enable or disable a registered application for a specific entity type. " + "Returns the full list of registered applications for the entity type." + ), + response_model_exclude_none=True, + response_model=list[ApplicationState], + responses={404: {"model": Message}}, +) +async def set_application( + db: Annotated[AsyncSession, Depends(get_db_session)], + entity_type: Annotated[EntityType, Path(description="Entity type this application is for")], + key: Annotated[str, Path(min_length=1, max_length=64, pattern="^[a-z0-9_]+$")], + body: ApplicationToggleRequest, +) -> list[ApplicationState] | JSONResponse: + try: + await set_application_enabled(db, entity_type, key, enabled=body.enabled) + except ItemNotFoundError: + return JSONResponse( + status_code=404, + content=Message( + message=f"Application with key {key} does not exist for entity type {entity_type.name}", + ).dict(), + ) + + return await get_applications(db, entity_type) + + @router.get( "/{entity_type}", name="Get extra fields", diff --git a/spoolman/applications.py b/spoolman/applications.py new file mode 100644 index 000000000..93c1e6289 --- /dev/null +++ b/spoolman/applications.py @@ -0,0 +1,126 @@ +"""Optional application capabilities that can be enabled per entity type.""" + +import json +import logging +from enum import Enum + +from fastapi.encoders import jsonable_encoder +from pydantic import BaseModel, Field +from sqlalchemy.ext.asyncio import AsyncSession + +from spoolman.database import setting as db_setting +from spoolman.exceptions import ItemNotFoundError +from spoolman.extra_fields import EntityType +from spoolman.settings import parse_setting + +logger = logging.getLogger(__name__) + + +class ApplicationSurface(Enum): + """UI surfaces that an application can extend.""" + + show = "show" + edit = "edit" + list = "list" + action = "action" + derived = "derived" + + +class ApplicationDefinition(BaseModel): + """Code-defined description for an optional application capability.""" + + key: str = Field(description="Unique key", pattern="^[a-z0-9_]+$", min_length=1, max_length=64) + app_key: str | None = Field( + default=None, description="Groups multi-entity registrations under one app (defaults to key)" + ) + icon: str | None = Field(default=None, description="Emoji icon shown in the applications catalog (e.g. '🔔')") + entity_type: EntityType = Field(description="Entity type this application is for") + name: str = Field(description="Display name", min_length=1, max_length=128) + description: str = Field(description="Short description", min_length=1, max_length=512) + enable_description: str = Field(description="What enabling adds", min_length=1, max_length=512) + surfaces: list[ApplicationSurface] = Field( + default_factory=list, description="Surfaces affected by this application" + ) + + +class ApplicationState(ApplicationDefinition): + """Application definition with its current enabled state.""" + + enabled: bool = Field(description="Whether the application is enabled") + + +class ApplicationToggleRequest(BaseModel): + """Request body for enabling or disabling an application.""" + + enabled: bool = Field(description="Whether the application should be enabled") + + +_application_registry: dict[EntityType, list[ApplicationDefinition]] = {entity_type: [] for entity_type in EntityType} +_application_cache: dict[EntityType, list[str]] = {} + + +def register_application(application: ApplicationDefinition) -> None: + """Register an application definition for future use.""" + existing = _application_registry[application.entity_type] + if any(app.key == application.key for app in existing): + raise ValueError(f"Application {application.key} is already registered for {application.entity_type.name}.") + existing.append(application) + existing.sort(key=lambda item: (item.name.lower(), item.key)) + + +def get_registered_applications(entity_type: EntityType) -> list[ApplicationDefinition]: + """Get all registered application definitions for an entity type.""" + return list(_application_registry[entity_type]) + + +async def get_enabled_application_keys(db: AsyncSession, entity_type: EntityType) -> list[str]: + """Get the enabled application keys for an entity type.""" + if entity_type in _application_cache: + return _application_cache[entity_type] + + setting_def = parse_setting(f"applications_{entity_type.name}") + try: + setting = await db_setting.get(db, setting_def) + setting_value = setting.value + except ItemNotFoundError: + setting_value = setting_def.default + + parsed = json.loads(setting_value) + if not isinstance(parsed, list): + logger.warning("Setting %s is not a list, using default.", setting_def.key) + parsed = [] + + enabled_keys = [value for value in parsed if isinstance(value, str)] + _application_cache[entity_type] = enabled_keys + return enabled_keys + + +async def get_applications(db: AsyncSession, entity_type: EntityType) -> list[ApplicationState]: + """Get all registered applications for an entity type, including enabled state.""" + enabled_keys = set(await get_enabled_application_keys(db, entity_type)) + return [ + ApplicationState.model_validate( + { + **jsonable_encoder(definition), + "enabled": definition.key in enabled_keys, + }, + ) + for definition in get_registered_applications(entity_type) + ] + + +async def set_application_enabled(db: AsyncSession, entity_type: EntityType, key: str, *, enabled: bool) -> None: + """Enable or disable a registered application for an entity type.""" + registered = get_registered_applications(entity_type) + if not any(app.key == key for app in registered): + raise ItemNotFoundError(f"Application with key {key} does not exist.") + + enabled_keys = await get_enabled_application_keys(db, entity_type) + next_keys = [value for value in enabled_keys if value != key] + if enabled: + next_keys.append(key) + next_keys = sorted(set(next_keys)) + + setting_def = parse_setting(f"applications_{entity_type.name}") + await db_setting.update(db=db, definition=setting_def, value=json.dumps(next_keys)) + _application_cache[entity_type] = next_keys diff --git a/spoolman/demo_applications.py b/spoolman/demo_applications.py new file mode 100644 index 000000000..9e05a2af3 --- /dev/null +++ b/spoolman/demo_applications.py @@ -0,0 +1,168 @@ +"""Concept application stubs registered at startup for UI testing and demonstration. + +These applications are descriptive only — they have no backend logic and are all +disabled by default. They populate the Applications catalog so the UI is immediately +testable and visually compelling. + +Future phases will implement actual functionality for each of these concepts. +See: https://github.com/akira69/spoolman-workspace/blob/main/Agent%20Memories/pr878-summary.md +""" + +from spoolman.applications import ApplicationDefinition, ApplicationSurface, register_application +from spoolman.extra_fields import EntityType + + +def register_demo_applications() -> None: + """Register all demo/concept application stubs.""" + register_application( + ApplicationDefinition( + key="drying_tracker", + app_key="drying_tracker", + icon="🌡️", + entity_type=EntityType.spool, + name="Drying Tracker", + description=( + "Record drying cycles for hygroscopic filaments. " + "Tracks temperature, duration, and the date each spool was last dried." + ), + enable_description=( + "Adds drying cycle history to spool detail pages and a drying status badge in the spool list." + ), + surfaces=[ApplicationSurface.show, ApplicationSurface.edit, ApplicationSurface.list], + ) + ) + + register_application( + ApplicationDefinition( + key="inventory_alerts", + app_key="inventory_alerts", + icon="🔔", + entity_type=EntityType.spool, + name="Inventory Alerts", + description=( + "Warns when a spool's remaining weight drops below a configurable threshold, " + "helping you reorder before running out." + ), + enable_description=("Adds alert badges to low-stock spool rows and a summary warning on the dashboard."), + surfaces=[ApplicationSurface.list, ApplicationSurface.action], + ) + ) + + register_application( + ApplicationDefinition( + key="print_history", + app_key="print_history", + icon="🖨️", + entity_type=EntityType.spool, + name="Print History", + description=( + "Log which files were printed with each spool, along with estimated weight consumed per print job." + ), + enable_description=( + "Adds a print history tab to spool detail pages and shows a running consumed-weight total." + ), + surfaces=[ApplicationSurface.show, ApplicationSurface.action], + ) + ) + + register_application( + ApplicationDefinition( + key="weight_audit", + app_key="weight_audit", + icon="⚖️", + entity_type=EntityType.spool, + name="Weight Audit Trail", + description=( + "Maintains a timestamped log of every weight change made to a spool, " + "making it easy to track consumption over time." + ), + enable_description=("Adds a weight history tab to spool detail pages with a consumption graph."), + surfaces=[ApplicationSurface.show, ApplicationSurface.list], + ) + ) + + register_application( + ApplicationDefinition( + key="qr_customization", + app_key="qr_customization", + icon="📱", + entity_type=EntityType.spool, + name="QR Code Customization", + description=( + "Choose which spool and filament fields are encoded in the label QR code. " + "Supports NFC tags and Home Assistant integrations." + ), + enable_description=("Adds a QR field configuration panel to spool settings and label printing options."), + surfaces=[ApplicationSurface.action], + ) + ) + + register_application( + ApplicationDefinition( + key="storage_conditions", + app_key="storage_conditions", + icon="🏠", + entity_type=EntityType.spool, + name="Storage Conditions", + description=( + "Log where each spool is stored and optionally record ambient humidity " + "and temperature readings at the storage location." + ), + enable_description=("Adds storage location and conditions fields to spool detail and edit pages."), + surfaces=[ApplicationSurface.show, ApplicationSurface.edit], + ) + ) + + register_application( + ApplicationDefinition( + key="filament_calibration", + app_key="filament_calibration", + icon="🎯", + entity_type=EntityType.filament, + name="Filament Calibration Profiles", + description=( + "Store per-filament calibration data: flow rate, pressure advance, " + "PA smooth time, and temperature tower results. " + "Eliminates manual re-tuning when switching between filaments." + ), + enable_description=( + "Adds a Calibration tab to filament detail pages and a calibration status badge in the filament list." + ), + surfaces=[ + ApplicationSurface.show, + ApplicationSurface.edit, + ApplicationSurface.list, + ], + ) + ) + + register_application( + ApplicationDefinition( + key="bambu_ams_sync", + app_key="bambu_ams_sync", + icon="🔄", + entity_type=EntityType.spool, + name="Bambu AMS Sync", + description=( + "Automatically sync spool data with a Bambu Lab AMS unit. " + "Pulls active spool, updates remaining weight, and maps AMS slot to Spoolman spool." + ), + enable_description=( + "Adds AMS slot assignment to spool detail pages and auto-updates remaining weight after each print." + ), + surfaces=[ApplicationSurface.show, ApplicationSurface.edit, ApplicationSurface.action], + ) + ) + + # Future application ideas (not yet implemented): + # + # - Compatibility Notes: which printer/nozzle/settings work best per filament + # - Purchase Tracker: order IDs, receipts, price paid per spool (#292 related) + # - Reorder Automation: generate shopping list when stock drops below threshold (#834) + # - Color Matching Log: record print result vs expected color per spool + # - Filament Aging: opened-date tracking + shelf-life expiration warnings + # - Carbon Footprint: CO2 per gram consumed estimate by material type + # - Bambu Lab Sync: pull current spool from AMS automatically (#217) + # - Hygroscopic Tracking: detailed moisture %, drying history, sensor data (#609) + # - OctoEverywhere Integration: sync with OctoPrint/Klipper printers (#788) + # - Custom Export Formats: Hueforge JSON, other slicer export plugins (#854) diff --git a/spoolman/main.py b/spoolman/main.py index c49c48ea0..aaa4520a5 100644 --- a/spoolman/main.py +++ b/spoolman/main.py @@ -17,6 +17,7 @@ from spoolman.api.v1.router import app as v1_app from spoolman.client import SinglePageApplication from spoolman.database import database +from spoolman.demo_applications import register_demo_applications from spoolman.prometheus.metrics import registry # Define a console logger @@ -170,6 +171,9 @@ async def startup() -> None: logger.info("Setting up database...") database.setup_db(database.get_connection_url()) + logger.info("Registering applications...") + register_demo_applications() + logger.info("Performing migrations...") # Run alembic in a subprocess. # There is some issue with the uvicorn worker that causes the process to hang when running alembic directly. diff --git a/spoolman/settings.py b/spoolman/settings.py index 0c6ce3193..209b7c4d4 100644 --- a/spoolman/settings.py +++ b/spoolman/settings.py @@ -68,6 +68,9 @@ def parse_setting(key: str) -> SettingDefinition: register_setting("extra_fields_vendor", SettingType.ARRAY, json.dumps([])) register_setting("extra_fields_filament", SettingType.ARRAY, json.dumps([])) register_setting("extra_fields_spool", SettingType.ARRAY, json.dumps([])) +register_setting("applications_vendor", SettingType.ARRAY, json.dumps([])) +register_setting("applications_filament", SettingType.ARRAY, json.dumps([])) +register_setting("applications_spool", SettingType.ARRAY, json.dumps([])) register_setting("base_url", SettingType.STRING, json.dumps("")) register_setting("locations", SettingType.ARRAY, json.dumps([]))