Merge pull request #248 from Donkie/settings-backend
New backend settings system
This commit is contained in:
@@ -1,7 +1,18 @@
|
|||||||
# Migrations
|
# Migrations
|
||||||
|
|
||||||
Creating a new version:
|
Migrations are used to create and update the database schema. They are run automatically every time Spoolman starts.
|
||||||
|
|
||||||
|
To create a new migration, edit the tables as desired in `spoolman/database/models.py`, then start the Spoolman server to update your local sqlite database.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pdm run python -m spoolman.main
|
pdm run python -m spoolman.main
|
||||||
|
```
|
||||||
|
|
||||||
|
Stop the server once it's up.
|
||||||
|
|
||||||
|
Then, let Alembic automatically create a new migration file:
|
||||||
|
```bash
|
||||||
pdm run alembic revision -m "some title" --autogenerate
|
pdm run alembic revision -m "some title" --autogenerate
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Go into the created migration and make sure it looks good, that the column changes etc are as desired. Format it with Black and Ruff. Commit.
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Added Settings table.
|
||||||
|
|
||||||
|
Revision ID: ccbb17aeda7c
|
||||||
|
Revises: b82cd9e2aa6f
|
||||||
|
Create Date: 2024-01-03 13:46:41.362341
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "ccbb17aeda7c"
|
||||||
|
down_revision = "b82cd9e2aa6f"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Perform the upgrade."""
|
||||||
|
op.create_table(
|
||||||
|
"setting",
|
||||||
|
sa.Column("key", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("value", sa.Text(), nullable=False),
|
||||||
|
sa.Column("last_updated", sa.DateTime(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("key"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_setting_key"), "setting", ["key"], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Perform the downgrade."""
|
||||||
|
op.drop_index(op.f("ix_setting_key"), table_name="setting")
|
||||||
|
op.drop_table("setting")
|
||||||
@@ -9,6 +9,7 @@ from pydantic import Field
|
|||||||
|
|
||||||
from spoolman.database import models
|
from spoolman.database import models
|
||||||
from spoolman.math import length_from_weight
|
from spoolman.math import length_from_weight
|
||||||
|
from spoolman.settings import SettingDefinition, SettingType
|
||||||
|
|
||||||
|
|
||||||
def datetime_to_str(dt: datetime) -> str:
|
def datetime_to_str(dt: datetime) -> str:
|
||||||
@@ -31,6 +32,29 @@ class Message(BaseModel):
|
|||||||
message: str = Field()
|
message: str = Field()
|
||||||
|
|
||||||
|
|
||||||
|
class SettingResponse(BaseModel):
|
||||||
|
value: str = Field(description="Setting value.")
|
||||||
|
is_set: bool = Field(description="Whether the setting has been set. If false, 'value' contains the default value.")
|
||||||
|
type: SettingType = Field(description="Setting type. This corresponds with JSON types.")
|
||||||
|
|
||||||
|
|
||||||
|
class SettingKV(BaseModel):
|
||||||
|
key: str = Field(description="Setting key.")
|
||||||
|
setting: SettingResponse = Field(description="Setting value.")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_db(definition: SettingDefinition, set_value: Optional[str]) -> "SettingKV":
|
||||||
|
"""Create a new Pydantic vendor object from a database vendor object."""
|
||||||
|
return SettingKV(
|
||||||
|
key=definition.key,
|
||||||
|
setting=SettingResponse(
|
||||||
|
value=set_value if set_value is not None else definition.default,
|
||||||
|
is_set=set_value is not None,
|
||||||
|
type=definition.type,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Vendor(BaseModel):
|
class Vendor(BaseModel):
|
||||||
id: int = Field(description="Unique internal ID of this vendor.")
|
id: int = Field(description="Unique internal ID of this vendor.")
|
||||||
registered: datetime = Field(description="When the vendor was registered in the database. UTC Timezone.")
|
registered: datetime = Field(description="When the vendor was registered in the database. UTC Timezone.")
|
||||||
@@ -274,3 +298,10 @@ class VendorEvent(Event):
|
|||||||
|
|
||||||
payload: Vendor = Field(description="Updated vendor.")
|
payload: Vendor = Field(description="Updated vendor.")
|
||||||
resource: Literal["vendor"] = Field(description="Resource type.")
|
resource: Literal["vendor"] = Field(description="Resource type.")
|
||||||
|
|
||||||
|
|
||||||
|
class SettingEvent(Event):
|
||||||
|
"""Event."""
|
||||||
|
|
||||||
|
payload: SettingKV = Field(description="Updated setting.")
|
||||||
|
resource: Literal["setting"] = Field(description="Resource type.")
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from spoolman.database.database import backup_global_db
|
|||||||
from spoolman.exceptions import ItemNotFoundError
|
from spoolman.exceptions import ItemNotFoundError
|
||||||
from spoolman.ws import websocket_manager
|
from spoolman.ws import websocket_manager
|
||||||
|
|
||||||
from . import filament, models, other, spool, vendor
|
from . import filament, models, other, setting, spool, vendor
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -107,4 +107,5 @@ async def notify(
|
|||||||
app.include_router(filament.router)
|
app.include_router(filament.router)
|
||||||
app.include_router(spool.router)
|
app.include_router(spool.router)
|
||||||
app.include_router(vendor.router)
|
app.include_router(vendor.router)
|
||||||
|
app.include_router(setting.router)
|
||||||
app.include_router(other.router)
|
app.include_router(other.router)
|
||||||
|
|||||||
194
spoolman/api/v1/setting.py
Normal file
194
spoolman/api/v1/setting.py
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
"""Vendor related endpoints."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import Annotated, Union
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, Depends, WebSocket, WebSocketDisconnect
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from spoolman.api.v1.models import Message, SettingEvent, SettingResponse
|
||||||
|
from spoolman.database import setting
|
||||||
|
from spoolman.database.database import get_db_session
|
||||||
|
from spoolman.exceptions import ItemNotFoundError
|
||||||
|
from spoolman.settings import SETTINGS, parse_setting
|
||||||
|
from spoolman.ws import websocket_manager
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/setting",
|
||||||
|
tags=["setting"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ruff: noqa: D103,B008
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket(
|
||||||
|
"",
|
||||||
|
name="Listen to setting changes",
|
||||||
|
)
|
||||||
|
async def notify_any(
|
||||||
|
websocket: WebSocket,
|
||||||
|
) -> None:
|
||||||
|
await websocket.accept()
|
||||||
|
websocket_manager.connect(("setting",), websocket)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
if await websocket.receive_text():
|
||||||
|
await websocket.send_json({"status": "healthy"})
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
websocket_manager.disconnect(("setting",), websocket)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{key}",
|
||||||
|
name="Get setting",
|
||||||
|
description=(
|
||||||
|
"Get a specific setting. If the setting has not been set, the default value will be returned."
|
||||||
|
"A websocket is served on the same path to listen for changes to the setting. "
|
||||||
|
"See the HTTP Response code 299 for the content of the websocket messages."
|
||||||
|
),
|
||||||
|
response_model_exclude_none=True,
|
||||||
|
response_model=SettingResponse,
|
||||||
|
responses={404: {"model": Message}, 299: {"model": SettingEvent, "description": "Websocket message"}},
|
||||||
|
)
|
||||||
|
async def get(
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||||
|
key: str,
|
||||||
|
) -> Union[SettingResponse, JSONResponse]:
|
||||||
|
try:
|
||||||
|
definition = parse_setting(key)
|
||||||
|
except ValueError as e:
|
||||||
|
return JSONResponse(status_code=404, content=Message(message=str(e)).dict())
|
||||||
|
|
||||||
|
try:
|
||||||
|
db_item = await setting.get(db, definition)
|
||||||
|
value = db_item.value
|
||||||
|
is_set = True
|
||||||
|
except ItemNotFoundError:
|
||||||
|
value = definition.default
|
||||||
|
is_set = False
|
||||||
|
|
||||||
|
return SettingResponse(
|
||||||
|
value=value,
|
||||||
|
is_set=is_set,
|
||||||
|
type=definition.type,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/",
|
||||||
|
name="Get all settings",
|
||||||
|
description=("Get all settings, set or not. If the setting has not been set, 'value' will be the default value."),
|
||||||
|
response_model_exclude_none=True,
|
||||||
|
response_model=dict[str, SettingResponse],
|
||||||
|
)
|
||||||
|
async def find(
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||||
|
) -> Union[dict[str, SettingResponse], JSONResponse]:
|
||||||
|
settings: dict[str, SettingResponse] = {}
|
||||||
|
|
||||||
|
# First get all settings that have been set.
|
||||||
|
db_items = await setting.get_all(db)
|
||||||
|
for db_item in db_items:
|
||||||
|
try:
|
||||||
|
definition = parse_setting(db_item.key)
|
||||||
|
except ValueError:
|
||||||
|
continue # Ignore settings that have been removed.
|
||||||
|
|
||||||
|
settings[db_item.key] = SettingResponse(
|
||||||
|
value=db_item.value,
|
||||||
|
is_set=True,
|
||||||
|
type=definition.type,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Then get all settings that have not been set.
|
||||||
|
for settingdef in SETTINGS.values():
|
||||||
|
if settingdef.key not in settings:
|
||||||
|
settings[settingdef.key] = SettingResponse(
|
||||||
|
value=settingdef.default,
|
||||||
|
is_set=False,
|
||||||
|
type=settingdef.type,
|
||||||
|
)
|
||||||
|
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket(
|
||||||
|
"/{key}",
|
||||||
|
name="Listen to setting changes",
|
||||||
|
)
|
||||||
|
async def notify(
|
||||||
|
websocket: WebSocket,
|
||||||
|
key: str,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
parse_setting(key)
|
||||||
|
except ValueError as e:
|
||||||
|
await websocket.close(code=4040, reason=str(e))
|
||||||
|
return
|
||||||
|
|
||||||
|
await websocket.accept()
|
||||||
|
websocket_manager.connect(("setting", str(key)), websocket)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
if await websocket.receive_text():
|
||||||
|
await websocket.send_json({"status": "healthy"})
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
websocket_manager.disconnect(("setting", str(key)), websocket)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{key}",
|
||||||
|
name="Set setting",
|
||||||
|
description=(
|
||||||
|
"Set the value of a setting. The body must match the JSON type of the setting. "
|
||||||
|
"An empty body or a body containing only 'null' will reset the setting to its default value. "
|
||||||
|
"The new value will be returned."
|
||||||
|
),
|
||||||
|
response_model_exclude_none=True,
|
||||||
|
response_model=SettingResponse,
|
||||||
|
responses={404: {"model": Message}},
|
||||||
|
)
|
||||||
|
async def update(
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||||
|
key: str,
|
||||||
|
body: Annotated[str, Body()],
|
||||||
|
) -> Union[SettingResponse, JSONResponse]:
|
||||||
|
try:
|
||||||
|
definition = parse_setting(key)
|
||||||
|
except ValueError as e:
|
||||||
|
return JSONResponse(status_code=404, content=Message(message=str(e)).dict())
|
||||||
|
|
||||||
|
if body and body != "null":
|
||||||
|
try:
|
||||||
|
definition.validate_type(body)
|
||||||
|
except ValueError as e:
|
||||||
|
return JSONResponse(status_code=400, content=Message(message=str(e)).dict())
|
||||||
|
|
||||||
|
await setting.update(db=db, definition=definition, value=body)
|
||||||
|
logger.info('Setting "%s" has been set to "%s".', key, body)
|
||||||
|
else:
|
||||||
|
await setting.delete(db=db, definition=definition)
|
||||||
|
logger.info('Setting "%s" has been unset.', key)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Get the new value of the setting.
|
||||||
|
try:
|
||||||
|
db_item = await setting.get(db, definition)
|
||||||
|
value = db_item.value
|
||||||
|
is_set = True
|
||||||
|
except ItemNotFoundError:
|
||||||
|
value = definition.default
|
||||||
|
is_set = False
|
||||||
|
|
||||||
|
return SettingResponse(
|
||||||
|
value=value,
|
||||||
|
is_set=is_set,
|
||||||
|
type=definition.type,
|
||||||
|
)
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from sqlalchemy import ForeignKey, Integer, String
|
from sqlalchemy import ForeignKey, Integer, String, Text
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
|
||||||
@@ -58,3 +58,11 @@ class Spool(Base):
|
|||||||
lot_nr: Mapped[Optional[str]] = mapped_column(String(64))
|
lot_nr: Mapped[Optional[str]] = mapped_column(String(64))
|
||||||
comment: Mapped[Optional[str]] = mapped_column(String(1024))
|
comment: Mapped[Optional[str]] = mapped_column(String(1024))
|
||||||
archived: Mapped[Optional[bool]] = mapped_column()
|
archived: Mapped[Optional[bool]] = mapped_column()
|
||||||
|
|
||||||
|
|
||||||
|
class Setting(Base):
|
||||||
|
__tablename__ = "setting"
|
||||||
|
|
||||||
|
key: Mapped[str] = mapped_column(String(64), primary_key=True, index=True)
|
||||||
|
value: Mapped[str] = mapped_column(Text())
|
||||||
|
last_updated: Mapped[datetime] = mapped_column()
|
||||||
|
|||||||
69
spoolman/database/setting.py
Normal file
69
spoolman/database/setting.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""Helper functions for interacting with vendor database objects."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from spoolman.api.v1.models import EventType, SettingEvent, SettingKV
|
||||||
|
from spoolman.database import models
|
||||||
|
from spoolman.exceptions import ItemNotFoundError
|
||||||
|
from spoolman.settings import SettingDefinition
|
||||||
|
from spoolman.ws import websocket_manager
|
||||||
|
|
||||||
|
SETTING_MAX_LENGTH = 2**16 - 1
|
||||||
|
|
||||||
|
|
||||||
|
async def update(
|
||||||
|
*,
|
||||||
|
db: AsyncSession,
|
||||||
|
definition: SettingDefinition,
|
||||||
|
value: str,
|
||||||
|
) -> None:
|
||||||
|
"""Set a setting in the database."""
|
||||||
|
if len(value) > SETTING_MAX_LENGTH:
|
||||||
|
raise ValueError(f"Setting value is too big, max size is {SETTING_MAX_LENGTH} characters.")
|
||||||
|
|
||||||
|
setting = models.Setting(
|
||||||
|
key=definition.key,
|
||||||
|
value=value,
|
||||||
|
last_updated=datetime.utcnow().replace(microsecond=0),
|
||||||
|
)
|
||||||
|
await db.merge(setting)
|
||||||
|
await setting_changed(definition, value, EventType.UPDATED)
|
||||||
|
|
||||||
|
|
||||||
|
async def get(db: AsyncSession, definition: SettingDefinition) -> models.Setting:
|
||||||
|
"""Get a specific setting from the database."""
|
||||||
|
setting = await db.get(models.Setting, definition.key)
|
||||||
|
if setting is None:
|
||||||
|
raise ItemNotFoundError(f"Setting with key {definition.key} has not been set.")
|
||||||
|
return setting
|
||||||
|
|
||||||
|
|
||||||
|
async def get_all(db: AsyncSession) -> list[models.Setting]:
|
||||||
|
"""Get all set settings in the database."""
|
||||||
|
stmt = select(models.Setting)
|
||||||
|
rows = await db.execute(stmt)
|
||||||
|
return list(rows.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def delete(db: AsyncSession, definition: SettingDefinition) -> None:
|
||||||
|
"""Delete a setting from the database."""
|
||||||
|
setting = await get(db, definition)
|
||||||
|
await db.delete(setting)
|
||||||
|
await setting_changed(definition, None, EventType.DELETED)
|
||||||
|
|
||||||
|
|
||||||
|
async def setting_changed(definition: SettingDefinition, set_value: Optional[str], typ: EventType) -> None:
|
||||||
|
"""Notify websocket clients that a setting has changed."""
|
||||||
|
await websocket_manager.send(
|
||||||
|
("setting", str(definition.key)),
|
||||||
|
SettingEvent(
|
||||||
|
type=typ,
|
||||||
|
resource="setting",
|
||||||
|
date=datetime.utcnow(),
|
||||||
|
payload=SettingKV.from_db(definition, set_value),
|
||||||
|
),
|
||||||
|
)
|
||||||
64
spoolman/settings.py
Normal file
64
spoolman/settings.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"""Settings that can be changed by the user.
|
||||||
|
|
||||||
|
All settings are JSON encoded and stored in the database.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class SettingType(Enum):
|
||||||
|
"""The type of a setting."""
|
||||||
|
|
||||||
|
BOOLEAN = "boolean"
|
||||||
|
NUMBER = "number"
|
||||||
|
STRING = "string"
|
||||||
|
ARRAY = "array"
|
||||||
|
OBJECT = "object"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SettingDefinition:
|
||||||
|
"""A setting that can be changed by the user."""
|
||||||
|
|
||||||
|
key: str
|
||||||
|
type: SettingType
|
||||||
|
default: str
|
||||||
|
|
||||||
|
def validate_type(self, value: str) -> None: # noqa: C901
|
||||||
|
"""Validate that the value has the correct type."""
|
||||||
|
obj = json.loads(value)
|
||||||
|
if self.type == SettingType.BOOLEAN:
|
||||||
|
if not isinstance(obj, bool):
|
||||||
|
raise ValueError(f"Setting {self.key} must be a boolean.")
|
||||||
|
elif self.type == SettingType.NUMBER:
|
||||||
|
if not isinstance(obj, (int, float)):
|
||||||
|
raise ValueError(f"Setting {self.key} must be a number.")
|
||||||
|
elif self.type == SettingType.STRING:
|
||||||
|
if not isinstance(obj, str):
|
||||||
|
raise ValueError(f"Setting {self.key} must be a string.")
|
||||||
|
elif self.type == SettingType.ARRAY:
|
||||||
|
if not isinstance(obj, list):
|
||||||
|
raise ValueError(f"Setting {self.key} must be an array.")
|
||||||
|
elif self.type == SettingType.OBJECT: # noqa: SIM102
|
||||||
|
if not isinstance(obj, dict):
|
||||||
|
raise ValueError(f"Setting {self.key} must be an object.")
|
||||||
|
|
||||||
|
|
||||||
|
SETTINGS: dict[str, SettingDefinition] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def register_setting(key: str, typ: SettingType, default: str) -> None:
|
||||||
|
"""Register a setting."""
|
||||||
|
SETTINGS[key] = SettingDefinition(key, typ, default)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_setting(key: str) -> SettingDefinition:
|
||||||
|
"""Parse a setting key."""
|
||||||
|
if key not in SETTINGS:
|
||||||
|
raise ValueError(f"Setting {key} does not exist.")
|
||||||
|
return SETTINGS[key]
|
||||||
|
|
||||||
|
|
||||||
|
register_setting("currency", SettingType.STRING, json.dumps("EUR"))
|
||||||
1
tests_integration/tests/setting/__init__.py
Normal file
1
tests_integration/tests/setting/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Tests for the setting API."""
|
||||||
44
tests_integration/tests/setting/test_get.py
Normal file
44
tests_integration/tests/setting/test_get.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""Integration tests for the Vendor API endpoint."""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
URL = "http://spoolman:8000"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_currency():
|
||||||
|
"""Test getting the currency setting."""
|
||||||
|
# Execute
|
||||||
|
result = httpx.get(f"{URL}/api/v1/setting/currency")
|
||||||
|
result.raise_for_status()
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
setting = result.json()
|
||||||
|
assert setting == {
|
||||||
|
"value": '"EUR"',
|
||||||
|
"is_set": False,
|
||||||
|
"type": "string",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_unknown():
|
||||||
|
"""Test getting an unknown setting."""
|
||||||
|
# Execute
|
||||||
|
result = httpx.get(f"{URL}/api/v1/setting/unknown")
|
||||||
|
assert result.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_all():
|
||||||
|
"""Test getting all settings."""
|
||||||
|
# Execute
|
||||||
|
result = httpx.get(f"{URL}/api/v1/setting/")
|
||||||
|
result.raise_for_status()
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
settings = result.json()
|
||||||
|
assert settings == {
|
||||||
|
"currency": {
|
||||||
|
"value": '"EUR"',
|
||||||
|
"is_set": False,
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
}
|
||||||
115
tests_integration/tests/setting/test_set.py
Normal file
115
tests_integration/tests/setting/test_set.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"""Integration tests for the Vendor API endpoint."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
URL = "http://spoolman:8000"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_currency():
|
||||||
|
"""Test setting the currency setting."""
|
||||||
|
# Execute
|
||||||
|
result = httpx.post(
|
||||||
|
f"{URL}/api/v1/setting/currency",
|
||||||
|
json='"SEK"',
|
||||||
|
)
|
||||||
|
result.raise_for_status()
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
setting = result.json()
|
||||||
|
assert setting == {
|
||||||
|
"value": '"SEK"',
|
||||||
|
"is_set": True,
|
||||||
|
"type": "string",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
result = httpx.post(
|
||||||
|
f"{URL}/api/v1/setting/currency",
|
||||||
|
json="",
|
||||||
|
)
|
||||||
|
result.raise_for_status()
|
||||||
|
|
||||||
|
|
||||||
|
def test_unset_currency():
|
||||||
|
"""Test un-setting the currency setting."""
|
||||||
|
# Execute set
|
||||||
|
result = httpx.post(
|
||||||
|
f"{URL}/api/v1/setting/currency",
|
||||||
|
json='"SEK"',
|
||||||
|
)
|
||||||
|
result.raise_for_status()
|
||||||
|
|
||||||
|
# Verify set
|
||||||
|
setting = result.json()
|
||||||
|
assert setting == {
|
||||||
|
"value": '"SEK"',
|
||||||
|
"is_set": True,
|
||||||
|
"type": "string",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Execute unset
|
||||||
|
result = httpx.post(
|
||||||
|
f"{URL}/api/v1/setting/currency",
|
||||||
|
json="",
|
||||||
|
)
|
||||||
|
result.raise_for_status()
|
||||||
|
|
||||||
|
# Verify unset
|
||||||
|
setting = result.json()
|
||||||
|
assert setting == {
|
||||||
|
"value": '"EUR"',
|
||||||
|
"is_set": False,
|
||||||
|
"type": "string",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_unknown():
|
||||||
|
"""Test setting an invalid setting."""
|
||||||
|
# Execute
|
||||||
|
result = httpx.post(
|
||||||
|
f"{URL}/api/v1/setting/not-a-setting",
|
||||||
|
json='"SEK"',
|
||||||
|
)
|
||||||
|
assert result.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_currency_wrong_type():
|
||||||
|
"""Test setting the currency setting with the wrong type."""
|
||||||
|
# Execute
|
||||||
|
result = httpx.post(
|
||||||
|
f"{URL}/api/v1/setting/currency",
|
||||||
|
json=123,
|
||||||
|
)
|
||||||
|
assert result.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_big_value():
|
||||||
|
"""Test setting a setting to a long string which should be saved correctly."""
|
||||||
|
long_string = "a" * (2**16 - 1 - 2) # Backend guarantees that it can handle strings up to 65535 characters long.
|
||||||
|
# Remove 2 characters to account for the quotes.
|
||||||
|
|
||||||
|
# Execute
|
||||||
|
result = httpx.post(
|
||||||
|
f"{URL}/api/v1/setting/currency",
|
||||||
|
json=json.dumps(long_string),
|
||||||
|
)
|
||||||
|
result.raise_for_status()
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
result = httpx.get(f"{URL}/api/v1/setting/currency")
|
||||||
|
result.raise_for_status()
|
||||||
|
setting = result.json()
|
||||||
|
assert setting == {
|
||||||
|
"value": json.dumps(long_string),
|
||||||
|
"is_set": True,
|
||||||
|
"type": "string",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
result = httpx.post(
|
||||||
|
f"{URL}/api/v1/setting/currency",
|
||||||
|
json="",
|
||||||
|
)
|
||||||
|
result.raise_for_status()
|
||||||
Reference in New Issue
Block a user