8 Commits

9 changed files with 183 additions and 293 deletions

View File

@@ -57,7 +57,15 @@
mkVm = name: mkApp "${self.nixosConfigurations.${name}.config.system.build.vm}/bin/run-nixos-vm"; mkVm = name: mkApp "${self.nixosConfigurations.${name}.config.system.build.vm}/bin/run-nixos-vm";
in { in {
default = self.apps.${system}.worblehat; default = self.apps.${system}.worblehat;
worblehat = mkApp (lib.getExe self.packages.${system}.worblehat) "Run worblehat without any setup"; worblehat = let
app = pkgs.writeShellApplication {
name = "worblehat-with-default-config";
runtimeInputs = [ self.packages.${system}.worblehat ];
text = ''
worblehat -c ${./config-template.toml} "$@"
'';
};
in mkApp (lib.getExe app) "Run the worblehat cli with its default config against an SQLite database";
vm = mkVm "vm" "Start a NixOS VM with worblehat installed in kiosk-mode"; vm = mkVm "vm" "Start a NixOS VM with worblehat installed in kiosk-mode";
vm-non-kiosk = mkVm "vm-non-kiosk" "Start a NixOS VM with worblehat installed in nonkiosk-mode"; vm-non-kiosk = mkVm "vm-non-kiosk" "Start a NixOS VM with worblehat installed in nonkiosk-mode";
}); });

View File

@@ -54,10 +54,43 @@ in {
freeformType = format.type; freeformType = format.type;
}; };
}; };
deadline-daemon = {
enable = lib.mkEnableOption "" // {
description = ''
Whether to enable the worblehat deadline-daemon service,
which periodically checks for upcoming deadlines and notifies users.
Note that this service is independent of the main worblehat service,
and must be enabled separately.
'';
};
onCalendar = lib.mkOption {
type = lib.types.str;
description = ''
How often to trigger rendering the map,
in the format of a systemd timer onCalendar configuration.
See {manpage}`systemd.timer(5)`.
'';
default = "*-*-* 10:15:00";
};
};
};
config = lib.mkMerge [
(lib.mkIf (cfg.enable || cfg.deadline-daemon.enable) {
environment.etc."worblehat/config.toml".source = format.generate "worblehat-config.toml" cfg.settings;
users = {
users.worblehat = {
group = "worblehat";
isNormalUser = true;
};
groups.worblehat = { };
}; };
config = lib.mkIf cfg.enable (lib.mkMerge [
{
services.worblehat.settings = lib.pipe ../config-template.toml [ services.worblehat.settings = lib.pipe ../config-template.toml [
builtins.readFile builtins.readFile
builtins.fromTOML builtins.fromTOML
@@ -69,20 +102,12 @@ in {
}) })
(lib.mapAttrsRecursive (_: lib.mkDefault)) (lib.mapAttrsRecursive (_: lib.mkDefault))
]; ];
} })
(lib.mkIf cfg.enable (lib.mkMerge [
{ {
environment.systemPackages = [ cfg.package ]; environment.systemPackages = [ cfg.package ];
environment.etc."worblehat/config.toml".source = format.generate "worblehat-config.toml" cfg.settings;
users = {
users.worblehat = {
group = "worblehat";
isNormalUser = true;
};
groups.worblehat = { };
};
services.worblehat.settings.database.type = "postgresql"; services.worblehat.settings.database.type = "postgresql";
services.worblehat.settings.database.postgresql = { services.worblehat.settings.database.postgresql = {
host = "/run/postgresql"; host = "/run/postgresql";
@@ -122,7 +147,7 @@ in {
users.users.worblehat = { users.users.worblehat = {
extraGroups = [ "lp" ]; extraGroups = [ "lp" ];
shell = (pkgs.writeShellScriptBin "login-shell" "${lib.getExe cfg.screenPackage} -x worblehat") // { shell = (pkgs.writeShellScriptBin "login-shell" "${lib.getExe' cfg.screenPackage "screen"} -x worblehat") // {
shellPath = "/bin/login-shell"; shellPath = "/bin/login-shell";
}; };
}; };
@@ -153,7 +178,7 @@ in {
User = "worblehat"; User = "worblehat";
Group = "worblehat"; Group = "worblehat";
ExecStartPre = "-${lib.getExe cfg.screenPackage} -X -S worblehat kill"; ExecStartPre = "-${lib.getExe' cfg.screenPackage "screen"} -X -S worblehat kill";
ExecStart = let ExecStart = let
screenArgs = lib.escapeShellArgs [ screenArgs = lib.escapeShellArgs [
# -dm creates the screen in detached mode without accessing it # -dm creates the screen in detached mode without accessing it
@@ -174,18 +199,50 @@ in {
config = "/etc/worblehat/config.toml"; config = "/etc/worblehat/config.toml";
}; };
in "${lib.getExe cfg.screenPackage} ${screenArgs} ${lib.getExe cfg.package} ${worblehatArgs} cli"; in "${lib.getExe' cfg.screenPackage "screen"} ${screenArgs} ${lib.getExe cfg.package} ${worblehatArgs} cli";
ExecStartPost = ExecStartPost =
lib.optionals (cfg.limitScreenWidth != null) [ lib.optionals (cfg.limitScreenWidth != null) [
"${lib.getExe cfg.screenPackage} -X -S worblehat width ${toString cfg.limitScreenWidth}" "${lib.getExe' cfg.screenPackage "screen"} -X -S worblehat width ${toString cfg.limitScreenWidth}"
] ]
++ lib.optionals (cfg.limitScreenHeight != null) [ ++ lib.optionals (cfg.limitScreenHeight != null) [
"${lib.getExe cfg.screenPackage} -X -S worblehat height ${toString cfg.limitScreenHeight}" "${lib.getExe' cfg.screenPackage "screen"} -X -S worblehat height ${toString cfg.limitScreenHeight}"
]; ];
}; };
}; };
services.getty.autologinUser = "worblehat"; services.getty.autologinUser = "worblehat";
}) })
]); ]))
(lib.mkIf cfg.deadline-daemon.enable {
systemd.timers.worblehat-deadline-daemon = {
description = "Worblehat Deadline Daemon";
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = cfg.deadline-daemon.onCalendar;
Persistent = true;
};
};
systemd.services.worblehat-deadline-daemon = {
description = "Worblehat Deadline Daemon";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
serviceConfig = {
Type = "oneshot";
CPUSchedulingPolicy = "idle";
IOSchedulingClass = "idle";
ExecStart = let
worblehatArgs = lib.cli.toCommandLineShellGNU { } {
config = "/etc/worblehat/config.toml";
};
in "${lib.getExe cfg.package} ${worblehatArgs} deadline-daemon";
User = "worblehat";
Group = "worblehat";
};
};
})
];
} }

View File

@@ -48,6 +48,7 @@ nixpkgs.lib.nixosSystem {
services.worblehat = { services.worblehat = {
enable = true; enable = true;
createLocalDatabase = true; createLocalDatabase = true;
deadline-daemon.enable = true;
}; };
}) })
]; ];

View File

@@ -29,6 +29,7 @@ nixpkgs.lib.nixosSystem {
DEBUG = true; DEBUG = true;
}; };
}; };
deadline-daemon.enable = true;
}; };
}) })
]; ];

View File

@@ -7,10 +7,10 @@ license = { file = "LICENSE" }
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"alembic>=1.17", "alembic>=1.16",
"beautifulsoup4>=4.14", "beautifulsoup4>=4.14",
"click>=8.3", "click>=8.2",
"flask-admin>=2.0", "flask-admin>=1.6",
"flask-sqlalchemy>=3.1", "flask-sqlalchemy>=3.1",
"flask>=3.0", "flask>=3.0",
"isbnlib>=3.10", "isbnlib>=3.10",

View File

@@ -1,170 +0,0 @@
from worblehat.cli.subclis import select_bookcase_shelf
from sqlalchemy import select, event
from sqlalchemy.orm.session import Session
from worblehat.models import Bookcase, BookcaseShelf
from worblehat.services.metadata_fetchers import fetch_metadata_from_multiple_sources
from worblehat.services import is_valid_isbn
from pprint import pprint
from libdib.repl import (
NumberedCmd,
prompt_yes_no,
)
class BatchScanner(NumberedCmd):
def __init__(self, sql_session: Session):
super().__init__()
self.sql_session = sql_session
self.sql_session_dirty = False
self.bookcase_shelf = None
self.prompt_header = "[SHELF| NONE]"
@event.listens_for(self.sql_session, "after_flush")
def mark_session_as_dirty(*_):
self.sql_session_dirty = True
self.prompt_header = "(unsaved changes)"
@event.listens_for(self.sql_session, "after_commit")
@event.listens_for(self.sql_session, "after_rollback")
def mark_session_as_clean(*_):
self.sql_session_dirty = False
self.prompt_header = None
def _set_bookcase_shelf(self, bookcase_shelf: BookcaseShelf):
self.bookcase_shelf = bookcase_shelf
self.prompt_header = f"[SHELF| {bookcase_shelf.bookcase.uid} {bookcase_shelf.bookcase.description} / {bookcase_shelf.column}-{bookcase_shelf.row}: {bookcase_shelf.description}]"
def default(self, isbn: str):
isbn = isbn.strip()
if not is_valid_isbn(isbn):
super()._default(isbn)
return
if self.bookcase_shelf is None:
print("Please set the bookcase shelf first.")
return
print(f"Scanned ISBN: {isbn}")
data = fetch_metadata_from_multiple_sources(isbn)
pprint(data)
def do_set_bookcase_shelf(self, _: str):
bookcase = self._choose_bookcase(self.sql_session)
print()
self._print_bookcase_shelves(
sql_session=self.sql_session,
bookcase=bookcase,
)
print()
bookcase_shelf = select_bookcase_shelf(
sql_session=self.sql_session,
bookcase=bookcase,
)
self._set_bookcase_shelf(bookcase_shelf)
print(f"Bookcase shelf set to {bookcase_shelf}")
def _choose_bookcase(
self,
sql_session: Session,
) -> Bookcase:
bookcases = sql_session.scalars(
select(Bookcase)
).all()
while True:
print("Available bookcases:")
for bookcase in bookcases:
print(f" {bookcase.name} - {bookcase.description}")
bookcase_name = input("Choose a bookcase> ").strip()
bookcase = sql_session.scalars(
select(Bookcase).where(Bookcase.name == bookcase_name)
).one_or_none()
if not bookcase:
print(f"Bookcase {bookcase_name} not found")
continue
return bookcase
def _print_bookcase_shelves(
self,
sql_session: Session,
bookcase: Bookcase,
) -> None:
shelves = sql_session.scalars(
select(BookcaseShelf).where(BookcaseShelf.bookcase == bookcase)
).all()
min_col = min([shelf.column for shelf in shelves])
max_col = max([shelf.column for shelf in shelves])
min_row = min([shelf.row for shelf in shelves])
max_row = max([shelf.row for shelf in shelves])
print('Available shelves:')
for col in range(min_col, max_col + 1):
for row in range(min_row, max_row + 1):
shelf = sql_session.scalars(
select(BookcaseShelf).where(
BookcaseShelf.bookcase == bookcase,
BookcaseShelf.column == col,
BookcaseShelf.row == row,
)
).one_or_none()
if shelf:
print(f"{col}-{row}: {shelf.description}")
else:
print(f"{col}-{row}: None")
def do_save(self, _: str):
if not self.sql_session_dirty:
print("No changes to save.")
return
self.sql_session.commit()
def do_abort(self, _: str):
if not self.sql_session_dirty:
print("No changes to abort.")
return
self.sql_session.rollback()
def do_exit(self, _: str):
if self.sql_session_dirty:
if prompt_yes_no("Would you like to save your changes?"):
self.sql_session.commit()
else:
self.sql_session.rollback()
exit(0)
funcs = {
0: {
"f": default,
"doc": "Add item with its ISBN",
},
1: {
'f' : do_set_bookcase_shelf,
'doc' : 'Select shelf',
},
7: {
"f": do_save,
"doc": "Save changes",
},
8: {
"f": do_abort,
"doc": "Abort changes",
},
9: {
"f": do_exit,
"doc": "Exit",
},
}

View File

@@ -85,12 +85,6 @@ def main() -> None:
from .devscripts.seed_test_data import main from .devscripts.seed_test_data import main
main(sql_session) main(sql_session)
elif args.script == "batch-scanner":
from .devscripts.batch_scanner import BatchScanner
result = BatchScanner(sql_session).cmdloop()
print(result)
else: else:
print(devscripts_arg_parser.format_help()) print(devscripts_arg_parser.format_help())
exit(1) exit(1)

View File

@@ -52,11 +52,6 @@ devscripts_subparsers.add_parser(
help="Seed data tailorded for testing the deadline daemon, into the database", help="Seed data tailorded for testing the deadline daemon, into the database",
) )
devscripts_subparsers.add_parser(
"batch-scanner",
help="REPL optimized for scanning in tons of books",
)
arg_parser.add_argument( arg_parser.add_argument(
"-V", "-V",
"--version", "--version",

View File

@@ -2,7 +2,7 @@ import tomllib
from pathlib import Path from pathlib import Path
from pprint import pformat from pprint import pformat
from typing import Any from typing import Any
import os
class Config: class Config:
""" """
@@ -38,10 +38,14 @@ class Config:
@staticmethod @staticmethod
def read_password(password_field: str) -> str: def read_password(password_field: str) -> str:
if Path(password_field).is_file(): file: Path = Path(password_field)
if file.is_file() and any([file.stat().st_mode & 0o400 and file.stat().st_uid == os.getuid(), file.stat().st_mode & 0o040 and file.stat().st_gid == os.getgid(), file.stat().st_mode & 0o004]):
with Path(password_field).open() as f: with Path(password_field).open() as f:
return f.read() return f.read().strip()
else: else:
raise RuntimeError(
f"Testing, should only use file. {password_field}",
)
return password_field return password_field
@classmethod @classmethod