Skip to content

11. Reacting

Until now the module answered somebody. Now it will act on its own: every morning, it chases overdue loans.

One job, five kernel subsystems, and the order matters.

What it does

def sweep(engine, connection, loans, *, librarian_id, today=None, language="en") -> int:
"""Remind for every overdue loan. Returns how many reminders were sent."""
moment = today or datetime.now(UTC).date()
late = overdue_on(loans, today=moment)
for loan in late:
remind(engine, connection, loan, librarian_id=librarian_id, language=language)
return len(late)

And for each overdue loan: a mail to the member, a notification to the librarian, an event for the rest of the world.

The schedule

The module declares its recurring work. It does not schedule it itself.

from kewos.scheduler import register_cron_seed
register_cron_seed(
name="library_overdue_sweep",
callable_path="modules.library.overdue:run_overdue_sweep",
cron_expr="0 7 * * *",
)

The kernel seeds the job on every installation, and an operator can disable it for one customer without touching code. The expression is validated at registration: a wrong one fails at boot, not six months later at seven in the morning.

The mail

Drop mail_templates/library_overdue.html. One language per file: <name>.html for English, <name>.<lang>.html for the others.

{% extends "kewos:mail_base" %}
{% block title %}Overdue loan{% endblock %}
{% block description %}Sent to a member whose loan is past its due date.{% endblock %}
{% block subject %}Your loan of {{ title }} is overdue{% endblock %}
{% block body %}
<h1>Your loan is overdue</h1>
<p>Hello {{ member_name }},</p>
<p>
<strong>{{ title }}</strong> ({{ barcode }}) was due back on
<strong>{{ due_on }}</strong>, {{ days_late }} day(s) ago.
</p>
{% endblock %}

One line in the module:

from kewos.mail import register_mail_templates_from_dir
register_mail_templates_from_dir(_MODULE_DIR / "mail_templates")

And the send:

send_template_mail(
engine,
connection, # the CALLER's connection, not the engine
template="library_overdue",
to_address=loan.member_email,
language=language,
context={"member_name": ..., "title": ..., "days_late": ...},
model="library_loan",
record_id=str(loan.loan_id),
)

{{ mail_company }}, the header and the signature come from the customer’s theme: {% extends "kewos:mail_base" %} brings them.

The in-app notification

from kewos.notifications import NotificationKind, notify_user
notify_user(
connection, # again: the caller's connection
user_id=librarian_id,
kind=NotificationKind.SYSTEM,
payload={
"model": "library_loan",
"record_id": loan.loan_id,
"message_key": "library_loan.notification.overdue",
"title": loan.title,
"days_late": loan.days_late,
},
)

The interface’s bell fills itself; you write no screen.

Four kinds exist: MENTION, COMMENT, STATE_CHANGE and SYSTEM. An automatic chase is a SYSTEM: nobody wrote it by hand.

The event

from kewos.events import emit
emit("library.loan.overdue", {"loan_id": ..., "member_id": ..., "days_late": ...})

This is how another module reacts without this one knowing it exists. A collections module, a management dashboard, an SMS connector: each subscribes, none is named here.

from kewos.events import event_subscriber
@event_subscriber("library.loan.overdue")
def warn_the_desk(event) -> None:
"""React to a loan going overdue, from another module entirely."""
...

A synchronous subscriber runs inside the transaction and can fail it. An asynchronous one goes on the queue and fails on its own, with its own retries. Choose the second as soon as a subscriber’s failure must not undo the action.

The numbering

A member’s card number is not a database sequence.

from kewos.sequences import register_sequence_seed
register_sequence_seed(code="library_member_card", prefix="LIB-", padding=6)

Then, when creating the member:

from kewos.sequences import next_value
card = next_value(connection, code="library_member_card") # "LIB-000042"

What the tests catch

The reminder itself is three kernel calls. What deserves a test is what counts as late:

def test_a_loan_due_today_is_not_late() -> None:
# The most common off-by-one in this kind of sweep, and the one that emails
# somebody the morning their book is due.
assert overdue_on([_Loan(id=1, state="out", due_on=TODAY)], today=TODAY) == ()

A returned loan, a lost loan, a loan with no due date: three cases where chasing would be a mistake, and three tests. The computation is pure, so all of it is checked without a database.

Next

Automating.