<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en_GB"><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://blog.entek.org.uk/feed.xml" rel="self" type="application/atom+xml" /><link href="https://blog.entek.org.uk/" rel="alternate" type="text/html" hreflang="en_GB" /><updated>2026-04-06T09:44:42+01:00</updated><id>https://blog.entek.org.uk/feed.xml</id><title type="html">Laurence’s Blog</title><subtitle>Possibly the most boring blog on the web... The random musings, technical notes, life events, etc. of a lapsed *nix sysadmin.</subtitle><author><name>Laurence</name></author><entry><title type="html">What is inclusion?</title><link href="https://blog.entek.org.uk/life/2026/03/28/what-is-inclusion.html" rel="alternate" type="text/html" title="What is inclusion?" /><published>2026-03-28T13:32:00+00:00</published><updated>2026-03-28T13:32:00+00:00</updated><id>https://blog.entek.org.uk/life/2026/03/28/what-is-inclusion</id><content type="html" xml:base="https://blog.entek.org.uk/life/2026/03/28/what-is-inclusion.html"><![CDATA[<p>A moment earlier in the week at work got me thinking about inclusion and adjustments, and my experience of them in the workplace. To be fair, I will focus on the positive experiences but no inferences should be drawn from who, what or where I have omitted (i.e. don’t assume my experience was bad in other cases).</p>

<p>Since <a href="/notes/2024/09/20/adhd-take-2-part-1-welcome-to-the-adhder-family.html">being diagnosed with ADHD, in September 2024</a>, I have been diagnosed with a myriad of physical and neurodevelopmental health conditions. I did briefly mentioned my autism diagnosis in my <a href="/notes/2025/12/02/computing-insight-united-kingdom-ciuk-2025.html">post about CIUK 2025</a>. I will probably write a post about all of the ways in which the doctors have concluded my body and mind are broken, at some point (I intend to, it’s just emotionally challenging).</p>

<h2 id="so-back-to-the-point-what-is-inclusion">So, back to the point… What is inclusion?</h2>

<p>Searching online for a definition, I found that the <a href="https://www.security.gov.uk/culture-diversity-inclusion/introduction-and-definitions/">Government Security website</a> (“This site is the home of security strategies, standards, policies, and guidance for UK government departments and their arm’s length bodies.”) defines inclusion as:</p>

<blockquote>
  <h2 id="inclusion">Inclusion</h2>
  <p>Inclusion refers to the practice or policy of providing equal access to opportunities and resources for people who might otherwise be excluded or marginalised, such as those who have physical or mental disabilities and members of other minority groups.</p>

  <p>The Civil Service definition of inclusion highlights three key components:</p>

  <ul>
    <li>Belonging – feeling like you belong in your organisation and team</li>
    <li>Authenticity – feeling like you can be your authentic self at work</li>
    <li>Voice – feeling like you have the opportunity to speak up and are heard</li>
  </ul>
</blockquote>

<h2 id="what-isnt-inclusion">What isn’t inclusion?</h2>

<p>I think there is a distinction between “inclusion” and “accommodations” (or “adjustments”, I am using those two words synonymously), or this blog post doesn’t work. Inclusion is being part of the whole; feeling and being a part of the wider community. Accommodations are the changes to practice or environment that happen, hopefully to help people to feel included but are also “box-ticking” to fulfil legal obligations, and end up feeling punishing or alienating if solely approached in that way.</p>

<p>I think that is the theme of this post, accommodations do not naturally lead to inclusion. “Inclusive culture” in the workplace is a thing that gets talked about a lot but I am struggling to think of any disability training I have been through that went beyond “we have a legal obligation to make accommodations so everyone has fair access” to “each person has a moral duty to help people feel included”. Maybe it’s another one of those unwritten rules “everyone knows”, but I don’t and that there is an example of the inclusion problem. (EDIT: <a href="#better-training">This is no longer the case</a>.)</p>

<p>My autistic brain isn’t good at working out these unwritten rules, so I do need to be told them directly in order to be included in the group of people who understand these social norms. In my case, it helps me that I am a people-pleaser so I naturally try to make everyone happy which masks my difficulty, and anxiety about, not understanding how I am supposed to be behaving most of the time (“but not well”, my wife adds). My difficulties expressing myself were identified by an educational psychologist at a young age and I think they are rooted in this uncertainty. I do not cope with uncertainty in any form well, it causes me extreme anxiety. I think that not being sure how to say things appropriately is why I rapidly withdraw and shutdown when I begin to feel overwhelmed, until I can cope no more then I spectacularly meltdown.</p>

<p>Perhaps ironically, in an emergency situation I rarely feel overwhelmed, as my very logical mind can very quickly assess the situation, weigh up all the options, policies &amp; procedures and priorities to find an objectively good<sup id="fnref:1"><a href="#fn:1" class="footnote" rel="footnote" role="doc-noteref">1</a></sup> course of action towards resolving it. Unless I am the emergency (e.g. injured etc.), in which case I almost always feel overwhelmed and shutdown. But I digress (“tangent alert!” used to be said in meetings at the University of Birmingham - an example of how I now see people were gently accommodating my ADHD, trying to help me stay on-topic even though I was unaware it was not typical to go off on tangents all the time).</p>

<h2 id="this-is-inclusion">This is inclusion!</h2>

<p>The workplaces I’ve most enjoyed working, I now look back at and recognise how accommodating people were. Without me having to ask (because I am not someone who naturally asks), without me having to have any labels (because in most of them, I didn’t have any). Inclusion is cultural, not achieved by ticking boxes. They were the places where I felt it was okay to be me. Where I could bring my authentic self to work, without fear of judgement for who I am as a person, and that enabled me to do my best work for my employer. Conversely, the places I’ve least enjoyed working are those where I have felt unable to be myself and split my energy between fitting in and working.</p>

<p>When I first started working at Loughborough University, as part of my induction I met with the heads of each team within the IT Services department. I distinctly remember many of them, but particularly my meeting with the Communications and Training Manager <a href="https://www.linkedin.com/in/robert-kirkwood-b9891527/">Rob Kirkwood</a> and the part of the conversation that went something like this:</p>

<blockquote>
  <p>Rob: So, how are you finding working here so far?<br />
Me: I’m really enjoying it. It seems like a certain amount of eccentricity is tolerated here.<br />
Rob: No, I don’t think that’s right at all… it’s positively encouraged!</p>
</blockquote>

<p>That, there, is inclusion. Not tolerating, but accommodating and celebrating individuals’ quirks and personalities.</p>

<p>My new workplace, <a href="https://ocf.co.uk">OCF Limited</a> has an inclusive culture. Since starting there, for me inclusion has been:</p>

<ul>
  <li>My first day starting with my new boss (who was aware I was recently diagnosed autistic and had been through a very difficult time with the upset of that diagnosis, going through compulsory redundancy process and my mum dying at the same time) saying “before we get down to business, just give me a hand to clear this desk in my office” and, once done, “right, if you get overwhelmed in the open plan office and need somewhere quiet to work, you can come and use this desk in my office whether I am here or not”. I have so far not needed to use that desk.</li>
  <li>My new boss then starting with the most important things of where to make a drink and the toilets are, and <em>showing</em> not telling me them.</li>
  <li>
    <p>Being empowered to set status messages to manage overwhelm:</p>

    <p><img src="/assets/posts/2026-03-28-what-is-inclusion/2026-03-03%20at%2010.29.42%20teams%20status.jpeg" alt="Teams status message" /></p>
  </li>
  <li>Adjustments coming as suggestions, not impositions. For example, “since you are known to faint while on the toilet, you can use the accessible toilet because it has an alarm cord if you need help” as opposed to, possibly, saying “you must use the accessible toilet”. (For context, the accessible toilet is also the ground-floor female toilet, so being given permission to use it was helpful.)</li>
  <li>On that topic, adjustments being a two-way conversation - I can make suggestions, but I also get suggestions being made to me - sometimes things I wasn’t aware of impacting on others. And they are always suggestions, there’s been occasions on both sides where we have said “that won’t work but let’s talk about the barrier the adjustment is trying to overcome and see what else we can do”.</li>
  <li>Having my wishes completely respected when I said “I don’t mind you joking with me about my disabilities, but I’m not quite ready for you to make fun of one particular aspect yet”.</li>
  <li>Saying “a cheap visual timer really helps me with my time-blindness” and the next day one appeared on my desk.</li>
  <li>When I pointed out the alarm cord finished mid-air, so isn’t reachable from the floor (if someone has fallen, for example), it just got quietly sorted without needing to make a case about compliance or chasing.</li>
  <li>Working somewhere where the little things, like most of the above, that make a huge difference to my ability to work and be comfortable in the environment, seem to happen informally and naturally.</li>
</ul>

<p>Where I work today, it feels like everyone tries to be considerate and accommodating of one another. That is inclusion.</p>

<p>Clearly actions are more important than words so, despite having said there’s a distinction between accommodations/adjustments and inclusion, accommodations/adjustments are necessary for inclusion. They just have to be driven by inclusion, not as a box-ticking exercise. Identifying the barriers to productivity, then working out the adjustments is inclusion, trying to do it the other way around (e.g. with a proforma “tick list of adjustments for neurodivergent employees”) I found both upsetting and alienating, when I experienced that approach. It seemed like “here is a list of all the problems we think autism causes the business in the workplace, tick which apply to you” and only catered to (for example) the sensory avoidant, not sensory seeking, neurodivergent employees. Inclusion is about conversations, not forms.</p>

<h2 id="who-cares">Who cares?</h2>

<p>Well, I do for a start. I struggle socially but I desperately want to feel included and part of the team/company/crowd/tribe (pick your own collective noun for “group”).</p>

<p>However it’s good for the business too; over the past few months I’ve spent more than one weekend driving up to the office (&gt;200 mile, 4hrs on a good day, round trip) to help out in an emergency. I feel very pleased to be working again somewhere where I want to go the extra mile. I’ve had several previous jobs where this has been the case and several where my goodwill dissipated and I would not have willingly worked at weekends, when I am not contracted to, for example.</p>

<p>A large part of my goodwill to my employer comes from feeling that I am part of the team, I am included and this starts with feeling welcomed with open arms into the organisation. I might be the weird cousin but I love working somewhere where if feels like we are a family of colleagues, rather than professional drones.</p>

<h2 id="epilogue">Epilogue</h2>

<p>It is now 3 weeks since I drafted this post (typical me, starting and not finishing yet another “project”!). Last week, at a celebration for a retiring former manager and good friend of mine, I bumped into another good friend, <a href="https://www.linkedin.com/in/georgina-ellis-4299361/">Georgina Ellis</a>. Georgina has known me for most of my career, and we had a brief conversation that I presume she started because I was wearing my <a href="https://hdsunflower.com/">Sunflower Lanyard</a> (as I often do in public these days) which also epitomises what I think I am trying to say about inclusion:</p>

<blockquote>
  <p>Georgina: I always thought “that’s just Laurence”. I remember the first time I met you at Loughborough University; this lad who looked 12 years old and was running the entire University’s HPC service. It was amazing.<br />
Me: It’s interesting that you should say that, I’ve just drafted a blog post about inclusion and what you just said, “that’s just Laurence” and he’s okay just being Laurence, sort of sums up the piece. One of the things I’ve been struggling with recently is, I thought I was doing a really good job of acting normal and fitting in. But when I started telling people, some who have known me for a very long time, I had just been diagnosed with autism most of them responded “yes, we know” and seemed really surprised this was a big shock to me.<br />
Georgina: I knew, but you were just always “Laurence”.</p>
</blockquote>

<h3 id="better-training">Better training</h3>

<p>I drafted this blog post on 4th March 2026, it was not posted until 28th March 2026 partly because of my usual “start a project then never finish it” difficulty but I also sought permission from everyone named to attribute/name them before posting. I was recently reminded that during this time between writing the words and posting, I completed my new employer’s “Diversity, Equity and Inclusion in the Workplace” training course.</p>

<p>This course really emphasised the importance of having a diverse workplace, and ensuring a diverse range of people are in meetings to ensure voices are heard and to generate new ideas. One key message I took from it was having the same people in meetings all the time is going to result in a limited pool, and potentially stagnating, ideas. As the, often misquoted and misattributed, idiom goes: “insanity is repeating the same mistakes and expecting different results” (Narcotics Anonymous, 1981).</p>

<p><img src="/assets/posts/2026-03-28-what-is-inclusion/DEI%20training%20complete.jpeg" alt="DEI training complete" /></p>

<p><img src="/assets/posts/2026-03-28-what-is-inclusion/DEI%20training%20certificate.png" alt="DEI training certificate" /></p>
<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1">
      <p>I use ‘good’ and avoid ‘best’ (e.g. in ‘good practice’, ‘good course of action’ etc.) as it implies there’s nothing better but one might not know there’s a better way at the time. A useful way to think to maintain an open mind, taught to me by <a href="https://www.linkedin.com/in/andrew-edmondson-845b36203/">Andrew Edmondson</a> while he was my manager. <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>Laurence</name></author><category term="life" /><category term="accessibility" /><category term="adhd" /><category term="autism" /><category term="disability" /><category term="health" /><category term="life" /><category term="neurodiversity" /><category term="work" /><summary type="html"><![CDATA[A moment earlier in the week at work got me thinking about inclusion and adjustments, and my experience of them in the workplace. To be fair, I will focus on the positive experiences but no inferences should be drawn from who, what or where I have omitted (i.e. don’t assume my experience was bad in other cases).]]></summary></entry><entry><title type="html">Icinga plugin to check if Debian stable system is up to date</title><link href="https://blog.entek.org.uk/notes/2026/03/10/icinga-plugin-to-check-if-debian-stable-system-is-up-to-date.html" rel="alternate" type="text/html" title="Icinga plugin to check if Debian stable system is up to date" /><published>2026-03-10T12:47:57+00:00</published><updated>2026-03-10T12:47:57+00:00</updated><id>https://blog.entek.org.uk/notes/2026/03/10/icinga-plugin-to-check-if-debian-stable-system-is-up-to-date</id><content type="html" xml:base="https://blog.entek.org.uk/notes/2026/03/10/icinga-plugin-to-check-if-debian-stable-system-is-up-to-date.html"><![CDATA[<p>I have long wanted to have a plugin for <a href="https://icinga.com/">Icinga</a> that checks if the current version of <a href="https://www.debian.org/">Debian</a> stable installed (most of my systems are running Debian stable) is the most recent version. In my busy life, I often miss new releases and although my systems automatically install updates they do not automatically get version updates. I finally got around to scratching that itch, and writing a plugin to do it.</p>

<p>In the end, this proved to be quite straight-forward. I reused some code (such as choosing <code class="language-plaintext highlighter-rouge">wget</code> or <code class="language-plaintext highlighter-rouge">curl</code>) from previous plugins I have written:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/bin/bash</span>

<span class="k">if </span>which curl &amp;&gt;/dev/null
<span class="k">then
  </span><span class="nv">CURL_CMD</span><span class="o">=</span><span class="s2">"curl -Ls"</span>
<span class="k">elif </span>which wget &amp;&gt;/dev/null
<span class="k">then
  </span><span class="nv">CURL_CMD</span><span class="o">=</span><span class="s2">"wget -q -O-"</span>
<span class="k">else
  </span><span class="nb">echo</span> <span class="s2">"ERROR: Unable to locate a webscraper (curl or wget) - cannot continue"</span>
  <span class="nb">exit </span>3  <span class="c"># Usage/internal error</span>
<span class="k">fi

</span>usage<span class="o">()</span> <span class="o">{</span>
    <span class="nb">cat</span> - <span class="o">&lt;&lt;</span><span class="no">EOF</span><span class="sh">
</span><span class="nv">$0</span><span class="sh"> [--help]
Nagios plugin to check Debian stable version status.

--help: display this message and quit
-m mirror: Specify the mirror url (defaults to https://deb.debian.org/debian)

Exits with warning if local Debian version is a minor version different to
the remote repository's stable release, exits with cricital if the major
ersion is different or exits with ok if both versions exactly match.
</span><span class="no">EOF
</span><span class="o">}</span>

<span class="k">if</span> <span class="o">[[</span> <span class="nv">$# </span><span class="o">==</span> 1 <span class="o">]]</span> <span class="o">&amp;&amp;</span> <span class="o">[[</span> <span class="nv">$1</span> <span class="o">==</span> <span class="s2">"--help"</span> <span class="o">]]</span>
<span class="k">then
  </span>usage
  <span class="nb">exit </span>0
<span class="k">fi</span>

<span class="c"># Default values</span>
<span class="nv">URL_BASE</span><span class="o">=</span>https://deb.debian.org/debian

<span class="k">while </span><span class="nb">getopts </span>hm: option
<span class="k">do
    case</span> <span class="nv">$option</span> <span class="k">in
        </span>h<span class="p">)</span> usage<span class="p">;</span> <span class="nb">exit </span>0 <span class="p">;;</span>
        m<span class="p">)</span> <span class="nv">URL_BASE</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">OPTARG</span><span class="k">}</span><span class="s2">"</span> <span class="p">;;</span>
        ?<span class="p">)</span> usage<span class="p">;</span> <span class="nb">exit </span>3 <span class="p">;;</span>
    <span class="k">esac</span>
<span class="k">done
</span><span class="nb">shift</span> <span class="k">$((</span><span class="nv">$OPTIND</span> <span class="o">-</span> <span class="m">1</span><span class="k">))</span>

<span class="c"># Only stable has a version number in it's Release file, so it does not make</span>
<span class="c"># sense to specify any other path, as this is currently written. In the future</span>
<span class="c"># it might be nice to add support for testing by checking if the version</span>
<span class="c"># codenames match as well.</span>
<span class="nv">repository_version</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span> <span class="k">${</span><span class="nv">CURL_CMD</span><span class="k">}</span> <span class="s2">"</span><span class="k">${</span><span class="nv">URL_BASE</span><span class="k">}</span><span class="s2">/dists/stable/Release"</span> | <span class="nb">grep</span> <span class="s1">'^Version: '</span> | <span class="nb">awk</span> <span class="s1">'{print $2}'</span> <span class="si">)</span><span class="s2">"</span>

<span class="k">if</span> <span class="o">[[</span> <span class="nv">$?</span> <span class="nt">-ne</span> 0 <span class="o">]]</span> <span class="o">||</span> <span class="o">[[</span> <span class="nt">-z</span> <span class="k">${</span><span class="nv">repository_version</span><span class="k">}</span> <span class="o">]]</span>
<span class="k">then
  </span><span class="nb">echo</span> <span class="s2">"ERROR: Unable to fetch Release from repository"</span>
  <span class="nb">exit  </span>3 <span class="c"># Internal error</span>
<span class="k">fi

if</span> <span class="o">[</span> <span class="nt">-f</span> /etc/os-release <span class="o">]</span>
<span class="k">then
  </span><span class="nv">local_version</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span> <span class="nb">.</span> /etc/os-release <span class="o">&amp;&amp;</span> <span class="nb">echo</span> <span class="k">${</span><span class="nv">DEBIAN_VERSION_FULL</span><span class="k">}</span> <span class="si">)</span><span class="s2">"</span>
<span class="k">fi</span>

<span class="c"># DEBIAN_VERSION_FULL was not present in os-release in Bookworm, it was in</span>
<span class="c"># Trixie</span>
<span class="k">if</span> <span class="o">[[</span> <span class="nt">-z</span> <span class="k">${</span><span class="nv">local_version</span><span class="k">}</span> <span class="o">]]</span>
<span class="k">then
  </span><span class="nv">local_version</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span> <span class="nb">cat</span> /etc/debian_version <span class="si">)</span><span class="s2">"</span>
<span class="k">fi

if</span> <span class="o">[[</span> <span class="nt">-z</span> <span class="k">${</span><span class="nv">local_version</span><span class="k">}</span> <span class="o">]]</span>
<span class="k">then
  </span><span class="nb">echo</span> <span class="s2">"ERROR: Unable to get local version from /etc"</span>
  <span class="nb">exit </span>3  <span class="c"># Internal error</span>
<span class="k">fi

if</span> <span class="o">[[</span> <span class="k">${</span><span class="nv">repository_version</span><span class="p">%.*</span><span class="k">}</span> <span class="o">==</span> <span class="k">${</span><span class="nv">local_version</span><span class="p">%.*</span><span class="k">}</span> <span class="o">]]</span>
<span class="k">then</span>
  <span class="c"># Major versions match - check for full version match</span>
  <span class="k">if</span> <span class="o">[[</span> <span class="k">${</span><span class="nv">repository_version</span><span class="k">}</span> <span class="o">==</span> <span class="k">${</span><span class="nv">local_version</span><span class="k">}</span> <span class="o">]]</span>
  <span class="k">then
   </span><span class="nb">echo</span> <span class="s2">"OK: Local version (</span><span class="k">${</span><span class="nv">local_version</span><span class="k">}</span><span class="s2">) matches current stable version (</span><span class="k">${</span><span class="nv">repository_version</span><span class="k">}</span><span class="s2">)"</span>
   <span class="nb">exit </span>0  <span class="c"># Ok</span>
  <span class="k">else</span>
    <span class="c"># Minor version mis-match</span>
    <span class="nb">echo</span> <span class="s2">"WARNING: Local minor version (</span><span class="k">${</span><span class="nv">local_version</span><span class="k">}</span><span class="s2">) does not match current stable version (</span><span class="k">${</span><span class="nv">repository_version</span><span class="k">}</span><span class="s2">)"</span>
    <span class="nb">exit </span>1  <span class="c"># Warning</span>
  <span class="k">fi
else
  </span><span class="nb">echo</span> <span class="s2">"CRITICAL: Local major version (</span><span class="k">${</span><span class="nv">local_version</span><span class="k">}</span><span class="s2">) does not match current stable version (</span><span class="k">${</span><span class="nv">repository_version</span><span class="k">}</span><span class="s2">)"</span>
  <span class="nb">exit </span>2  <span class="c"># Critical</span>
<span class="k">fi</span>
</code></pre></div></div>

<p>Adding it to be monitored on the server side just involved adding the command (which I did in <code class="language-plaintext highlighter-rouge">global-templates/commands-debian-version.conf</code>):</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>object CheckCommand "debian-version" {
	import "plugin-check-command"

	command = [ PluginContribDir + "/check_debian_version" ]

	arguments = {
                "-m" = "$debian_mirror$"
        }
}
</code></pre></div></div>

<p>and then assigned to all hosts with <code class="language-plaintext highlighter-rouge">apt</code> as the package manager, which are all Debian stable hosts, in my network - something more targetted might be required if that is not the case in yours (which I did in <code class="language-plaintext highlighter-rouge">global-templates/services-debian.conf</code> along side adding a <code class="language-plaintext highlighter-rouge">debsecan</code> check, having noticed the <code class="language-plaintext highlighter-rouge">check_descan</code> plugin from the <a href="https://packages.debian.org/stable/monitoring-plugins-contrib"><code class="language-plaintext highlighter-rouge">monitoring-plugins-contrib</code> package</a> in <code class="language-plaintext highlighter-rouge">/usr/lib/nagios/plugins</code>):</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>apply Service "debian-version" {
  display_name = "Debian stable version check"
  import "generic-service"

  check_command = "debian-version"
  command_endpoint = host.name // Execute on client

  if (host.vars.debian_mirror) {
    vars.debian_mirror = host.vars.debian_mirror
  }

  assign where host.vars.pkg_system &amp;&amp; host.vars.pkg_system == "apt"
}

apply Service "debsecan" {
  display_name = "Debian security scan"
  import "generic-service"

  check_command = "debsecan"
  command_endpoint = host.name // Execute on client

  assign where host.vars.pkg_system &amp;&amp; host.vars.pkg_system == "apt"
}
</code></pre></div></div>

<p>On hosts in the restricted server network, I set the mirror URL to be my reverse proxy that exposes a limited set of URLs to that network.</p>]]></content><author><name>Laurence</name></author><category term="notes" /><category term="automation" /><category term="bash" /><category term="debian" /><category term="icinga" /><category term="monitoring" /><summary type="html"><![CDATA[I have long wanted to have a plugin for Icinga that checks if the current version of Debian stable installed (most of my systems are running Debian stable) is the most recent version. In my busy life, I often miss new releases and although my systems automatically install updates they do not automatically get version updates. I finally got around to scratching that itch, and writing a plugin to do it.]]></summary></entry><entry><title type="html">Monitor public and Ceph private network from Proxmox hosts with Icinga</title><link href="https://blog.entek.org.uk/notes/2026/03/09/monitor-public-and-ceph-private-network-from-proxmox-hosts-with-icinga.html" rel="alternate" type="text/html" title="Monitor public and Ceph private network from Proxmox hosts with Icinga" /><published>2026-03-09T22:43:50+00:00</published><updated>2026-03-09T22:43:50+00:00</updated><id>https://blog.entek.org.uk/notes/2026/03/09/monitor-public-and-ceph-private-network-from-proxmox-hosts-with-icinga</id><content type="html" xml:base="https://blog.entek.org.uk/notes/2026/03/09/monitor-public-and-ceph-private-network-from-proxmox-hosts-with-icinga.html"><![CDATA[<p>Several times, I have been tripped up by the <a href="/notes/2025/09/16/adding-2-5-gbe-network-to-proxmox-cluster.html">2.5G USB3 network dongles</a> not connecting properly in <a href="/notes/2025/08/28/moving-proxmox-cluster-to-its-final-home.html">my Proxmox Virtual Environment cluster</a>, affecting performance. To combat this, I have added checks to my <a href="https://icinga.com/">Icinga</a> monitoring so all <a href="https://www.proxmox.com/products/proxmox-virtual-environment/overview">ProxmoxVE</a> hosts now monitor (via ping) all other ProxmoxVE hosts via both their “normal” (1G) network connection and the Ceph-private (2.5G) networks. This was interesting to write, as it turned out to be relatively complicated to append <code class="language-plaintext highlighter-rouge">-ceph</code> to the hostname portion of the Icinga Host name (which, as recommended by Icinga’s documentation, are the fully-qualified host names).</p>

<p>For context, the <code class="language-plaintext highlighter-rouge">-ceph</code> hostnames (for the Ceph private network addresses) are in <code class="language-plaintext highlighter-rouge">/etc/hosts</code> on each of the Proxmox hosts. Those network dongles are plugged into a 2.5G unmanaged switch that has no other devices attached (it is dedicated for the internal Ceph private network).</p>

<p>The final configuration I came up with was this, note that I had to split the name on <code class="language-plaintext highlighter-rouge">.</code> then remove the first item (as there is no way to select a slice of an array) before rejoining (because there is no way to limit the number of parts split splits into):</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>// Ping all proxmox hosts 
apply Service "ping-" for (target in get_objects(Host).filter((host) =&gt; host.vars.services &amp;&amp; "proxmox-ve" in host.vars.services).map((host) =&gt; host.name)) {
  import "generic-service"

  check_command = "ping"
  command_endpoint = host.name

  vars.ping_address = target

  assign where host.vars.services &amp;&amp; "proxmox-ve" in host.vars.services
}

// Ping all proxmox hosts' ceph private network interfaces
apply Service "ping-ceph-" for (target in get_objects(Host).filter((host) =&gt; host.vars.services &amp;&amp; "proxmox-ve" in host.vars.services).map((host) =&gt; host.name)) {
  import "generic-service"

  check_command = "ping"
  command_endpoint = host.name

  // Extract the domain from the host's FQDN
  var domain_parts = target.split(".")
  domain_parts.remove(0)
  var domain = domain_parts.join(".")

  vars.ping_address = target.split(".")[0] + "-ceph." + domain

  assign where host.vars.services &amp;&amp; "proxmox-ve" in host.vars.services
}
</code></pre></div></div>

<p>The hosts already had a <code class="language-plaintext highlighter-rouge">vars.services</code> array listing some of the services on them (e.g. <code class="language-plaintext highlighter-rouge">hashicorp-vault</code> to monitor <a href="/notes/2024/06/24/clustering-hashicorp-vault-and-ssl-ansible-role-improvements.html">my Hashicorp Vault cluster</a>), so I just added <code class="language-plaintext highlighter-rouge">proxmox-ve</code> to them and the all automatically got these checks to start checking all of the hosts with that service.</p>]]></content><author><name>Laurence</name></author><category term="notes" /><category term="automation" /><category term="ceph" /><category term="hashicorp" /><category term="icinga" /><category term="monitoring" /><category term="proxmox" /><category term="vault" /><summary type="html"><![CDATA[Several times, I have been tripped up by the 2.5G USB3 network dongles not connecting properly in my Proxmox Virtual Environment cluster, affecting performance. To combat this, I have added checks to my Icinga monitoring so all ProxmoxVE hosts now monitor (via ping) all other ProxmoxVE hosts via both their “normal” (1G) network connection and the Ceph-private (2.5G) networks. This was interesting to write, as it turned out to be relatively complicated to append -ceph to the hostname portion of the Icinga Host name (which, as recommended by Icinga’s documentation, are the fully-qualified host names).]]></summary></entry><entry><title type="html">Ensuring signed kernel packages are installed after updating Proxmox Virtual Environment hosts</title><link href="https://blog.entek.org.uk/notes/2026/03/08/ensuring-signed-kernel-packages-are-installed-after-updating-proxmox-virtual-environment-hosts.html" rel="alternate" type="text/html" title="Ensuring signed kernel packages are installed after updating Proxmox Virtual Environment hosts" /><published>2026-03-08T12:39:27+00:00</published><updated>2026-03-08T12:39:27+00:00</updated><id>https://blog.entek.org.uk/notes/2026/03/08/ensuring-signed-kernel-packages-are-installed-after-updating-proxmox-virtual-environment-hosts</id><content type="html" xml:base="https://blog.entek.org.uk/notes/2026/03/08/ensuring-signed-kernel-packages-are-installed-after-updating-proxmox-virtual-environment-hosts.html"><![CDATA[<p>Updating my <a href="https://www.proxmox.com/products/proxmox-virtual-environment/overview">Proxmox Virtual Environment</a> hosts, often install unsigned kernel packages which renders the system unbootable in a <a href="https://wiki.debian.org/SecureBoot">Secure Boot</a> enable environment. I modified my update playbook to ensure that the signed versions are installed, as I keep forgetting to check this before rebooting after updates.</p>

<p>The revised playbook, in full, is:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">---</span>
<span class="pi">-</span> <span class="na">hosts</span><span class="pi">:</span> <span class="s">all:!dummy</span>
  <span class="na">tasks</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Ansible sudo password is retrieved from vault, if known</span>
      <span class="na">delegate_to</span><span class="pi">:</span> <span class="s">localhost</span>
      <span class="na">community.hashi_vault.vault_read</span><span class="pi">:</span>
        <span class="c1"># So many things can determine the remote username (</span>
        <span class="c1"># ansible_user variable, SSH_DEFAULT_USER environment</span>
        <span class="c1"># variable, .ssh/config, etc. etc.) it's safer to use the</span>
        <span class="c1"># discovered fact.</span>
        <span class="na">path</span><span class="pi">:</span> <span class="s">kv/hosts/{{ inventory_hostname }}/users/{{ ansible_facts.user_id }}</span>
      <span class="na">register</span><span class="pi">:</span> <span class="s">sudo_pass</span>
      <span class="c1"># No password in vault is fine - will just not set it.</span>
      <span class="na">failed_when</span><span class="pi">:</span> <span class="kc">false</span>
    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">sudo password is set for host, if found in the vault</span>
      <span class="na">ansible.builtin.set_fact</span><span class="pi">:</span>
        <span class="na">ansible_become_password</span><span class="pi">:</span> <span class="s1">'</span><span class="s">{{</span><span class="nv"> </span><span class="s">sudo_pass.data.data.password</span><span class="nv"> </span><span class="s">}}'</span>
      <span class="na">when</span><span class="pi">:</span> <span class="s2">"</span><span class="s">'data'</span><span class="nv"> </span><span class="s">in</span><span class="nv"> </span><span class="s">sudo_pass"</span>
    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Updates are installed (apt systems)</span>
      <span class="na">become</span><span class="pi">:</span> <span class="s">yes</span>
      <span class="na">ansible.builtin.apt</span><span class="pi">:</span>
        <span class="na">update_cache</span><span class="pi">:</span> <span class="kc">true</span>
        <span class="na">upgrade</span><span class="pi">:</span> <span class="kc">true</span>
      <span class="na">when</span><span class="pi">:</span> <span class="s">ansible_facts['os_family'] == 'Debian'</span>
    <span class="c1"># Proxmox updates keep installing the unsigned kernels, in my secure-boot enabled</span>
    <span class="c1"># environment. Ensure the signed variants are installed.</span>
    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Package facts are known</span>
      <span class="na">ansible.builtin.package_facts</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">All non-signed proxmox kernels are replaced with signed variants</span>
      <span class="na">become</span><span class="pi">:</span> <span class="kc">true</span>
      <span class="na">ansible.builtin.package</span><span class="pi">:</span>
        <span class="na">name</span><span class="pi">:</span> <span class="s1">'</span><span class="s">{{</span><span class="nv"> </span><span class="s">item</span><span class="nv"> </span><span class="s">}}-signed'</span>
      <span class="na">loop</span><span class="pi">:</span> <span class="s2">"</span><span class="s">{{</span><span class="nv"> </span><span class="s">ansible_facts.packages.keys()</span><span class="nv"> </span><span class="s">|</span><span class="nv"> </span><span class="s">select('ansible.builtin.match',</span><span class="nv"> </span><span class="s">'^proxmox-kernel-.*-pve$')</span><span class="nv"> </span><span class="s">}}"</span>

<span class="nn">...</span>
</code></pre></div></div>]]></content><author><name>Laurence</name></author><category term="notes" /><category term="ansible" /><category term="automation" /><category term="debian" /><category term="proxmox" /><category term="security" /><category term="secure-boot" /><category term="updates" /><summary type="html"><![CDATA[Updating my Proxmox Virtual Environment hosts, often install unsigned kernel packages which renders the system unbootable in a Secure Boot enable environment. I modified my update playbook to ensure that the signed versions are installed, as I keep forgetting to check this before rebooting after updates.]]></summary></entry><entry><title type="html">Patching monitoring-plugins check_running_kernel with Ansible for ProxmoxVE kernels</title><link href="https://blog.entek.org.uk/notes/2026/03/08/patching-monitoring-plugins-check-running-kernel-with-ansible-for-proxmoxve-kernels.html" rel="alternate" type="text/html" title="Patching monitoring-plugins check_running_kernel with Ansible for ProxmoxVE kernels" /><published>2026-03-08T11:44:43+00:00</published><updated>2026-03-08T11:44:43+00:00</updated><id>https://blog.entek.org.uk/notes/2026/03/08/patching-monitoring-plugins-check-running-kernel-with-ansible-for-proxmoxve-kernels</id><content type="html" xml:base="https://blog.entek.org.uk/notes/2026/03/08/patching-monitoring-plugins-check-running-kernel-with-ansible-for-proxmoxve-kernels.html"><![CDATA[<p>On annoyance I have had for a while is that my <a href="https://www.proxmox.com/products/proxmox-virtual-environment/overview">Proxmox Virtual Environment</a> hosts report the running kernel does not match the on-disk kernel image in my <a href="https://icinga.com/">icinga</a> monitoring, due to a spurious <code class="language-plaintext highlighter-rouge">()</code> at the end of one of the version strings. I finally got around to fixing this today, by patching the <code class="language-plaintext highlighter-rouge">check_running_kernel</code> plugin (from the <a href="https://packages.debian.org/stable/monitoring-plugins-contrib"><code class="language-plaintext highlighter-rouge">monitoring-plugins-contrib</code> package on Debian</a>) to strip off those empty parenthesis from the version retrieved from the disk image.</p>

<p>Although specific to the Proxmox kernels, I chose to apply the patch to everything in order to avoid having to either have the patch outside of my monitoring role or have to make my monitoring role selectively apply tasks based on what groups the hosts were in.</p>

<p>Firstly, I added <code class="language-plaintext highlighter-rouge">patch</code> to the list of packages the role installs so I can use <a href="https://docs.ansible.com/projects/ansible/latest/collections/ansible/posix/patch_module.html"><code class="language-plaintext highlighter-rouge">ansible.posix.patch</code></a> to apply the fix:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Packages are installed (icinga2 itself and nagios plugins)</span>
  <span class="na">become</span><span class="pi">:</span> <span class="s">yes</span>
  <span class="na">ansible.builtin.package</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">icinga2</span>  <span class="c1"># Same package provides client &amp; server</span>
      <span class="pi">-</span> <span class="s">monitoring-plugins</span>
      <span class="pi">-</span> <span class="s">monitoring-plugins-contrib</span>
      <span class="pi">-</span> <span class="s">xz-utils</span>  <span class="c1"># Required for check_running_kernel plugin</span>
      <span class="pi">-</span> <span class="s">mokutil</span>  <span class="c1"># Required for my check_secure_boot plugin</span>
      <span class="pi">-</span> <span class="s">patch</span>  <span class="c1"># Required to apply check_running_kernel patch, below</span>
    <span class="na">state</span><span class="pi">:</span> <span class="s">present</span>
</code></pre></div></div>

<p>Next, I moved my existing custom plugins into a subdirectory (which I called <code class="language-plaintext highlighter-rouge">nagios-plugins</code>) in the role’s <code class="language-plaintext highlighter-rouge">files</code> directory. Previously, these were the only files so I have put them at the top level. I then added this new prefix to the <a href="https://docs.ansible.com/projects/ansible/latest/collections/ansible/builtin/fileglob_lookup.html">`ansible.builtin.fileglob</a> lookup that ensures out all of my local plugins are deployed:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Additional (locally created) Icinga plugins are deployed</span>
  <span class="na">become</span><span class="pi">:</span> <span class="kc">true</span>
  <span class="na">ansible.builtin.copy</span><span class="pi">:</span>
    <span class="na">owner</span><span class="pi">:</span> <span class="s">root</span>
    <span class="na">group</span><span class="pi">:</span> <span class="s">root</span>
    <span class="na">mode</span><span class="pi">:</span> <span class="m">0555</span>
    <span class="na">src</span><span class="pi">:</span> <span class="s2">"</span><span class="s">{{</span><span class="nv"> </span><span class="s">item</span><span class="nv"> </span><span class="s">}}"</span>
    <span class="na">dest</span><span class="pi">:</span> <span class="s">/usr/lib/nagios/plugins/{{ plugin_name }}</span>
  <span class="na">loop</span><span class="pi">:</span> <span class="s2">"</span><span class="s">{{</span><span class="nv"> </span><span class="s">q('ansible.builtin.fileglob',</span><span class="nv"> </span><span class="s">'nagios-plugins/check_*')</span><span class="nv"> </span><span class="s">}}"</span>
  <span class="na">vars</span><span class="pi">:</span>
    <span class="c1"># Strip extension off plugin name when deployed (just to help</span>
    <span class="c1"># distinguish different languages this side).</span>
    <span class="na">plugin_name</span><span class="pi">:</span> <span class="s2">"</span><span class="s">{{</span><span class="nv"> </span><span class="s">item</span><span class="nv"> </span><span class="s">|</span><span class="nv"> </span><span class="s">ansible.builtin.basename</span><span class="nv"> </span><span class="s">|</span><span class="nv"> </span><span class="s">ansible.builtin.splitext</span><span class="nv"> </span><span class="s">|</span><span class="nv"> </span><span class="s">first</span><span class="nv"> </span><span class="s">}}"</span>
</code></pre></div></div>

<p>Then I dropped the patch I had created into the <code class="language-plaintext highlighter-rouge">files</code> directory (I called it <code class="language-plaintext highlighter-rouge">check_running_kernel.proxmox.patch</code>) - please excuse the long line length and comment length, I followed the style of the rest of that script which has very long line:</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gd">--- /usr/lib/nagios/plugins/check_running_kernel	2025-04-20 22:08:25.000000000 +0100
</span><span class="gi">+++ /usr/lib/nagios/plugins/check_running_kernel	2026-03-08 11:09:16.315946430 +0000
</span><span class="p">@@ -204,7 +204,8 @@</span>
 			exit $UNKNOWN
 		fi
 		if [ "${on_disk/vmlinu}" != "$on_disk" ]; then
<span class="gd">-			on_disk_version="`get_image_linux "$on_disk" | $STRINGS | grep 'Linux version' | tail -n1`"
</span><span class="gi">+			# Local patch - Proxmox kernel images seem to have an empty parentheses at the end that is now showing in /proc/version. Added sed to stip this.
+			on_disk_version="`get_image_linux "$on_disk" | $STRINGS | grep 'Linux version' | tail -n1 | sed -e 's/ ()//'`"
</span> 			if [ -x /usr/bin/lsb_release ] ; then
 				vendor=$(lsb_release -i -s)
 				if [ -n "$vendor" ] &amp;&amp; [ "xDebian" != "x$vendor" ] ; then
</code></pre></div></div>

<p>Finally, I added the task to ensure the patch has been applied with Ansible’s patch module:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">check_running_kernel is patched to work with Proxmox kernels</span>
  <span class="na">become</span><span class="pi">:</span> <span class="kc">true</span>
  <span class="na">ansible.posix.patch</span><span class="pi">:</span>
    <span class="na">dest</span><span class="pi">:</span> <span class="s">/usr/lib/nagios/plugins/check_running_kernel</span>
    <span class="na">src</span><span class="pi">:</span> <span class="s">check_running_kernel.proxmox.patch</span>
</code></pre></div></div>]]></content><author><name>Laurence</name></author><category term="notes" /><category term="ansible" /><category term="automation" /><category term="bash" /><category term="development" /><category term="icinga" /><category term="monitoring" /><category term="nagios" /><category term="proxmox" /><category term="system-administration" /><summary type="html"><![CDATA[On annoyance I have had for a while is that my Proxmox Virtual Environment hosts report the running kernel does not match the on-disk kernel image in my icinga monitoring, due to a spurious () at the end of one of the version strings. I finally got around to fixing this today, by patching the check_running_kernel plugin (from the monitoring-plugins-contrib package on Debian) to strip off those empty parenthesis from the version retrieved from the disk image.]]></summary></entry><entry><title type="html">Adding notes and linking transactions to my budgeting system</title><link href="https://blog.entek.org.uk/notes/2026/02/08/adding-notes-and-linking-transactions-to-my-budgeting-system.html" rel="alternate" type="text/html" title="Adding notes and linking transactions to my budgeting system" /><published>2026-02-08T12:32:07+00:00</published><updated>2026-02-08T12:32:07+00:00</updated><id>https://blog.entek.org.uk/notes/2026/02/08/adding-notes-and-linking-transactions-to-my-budgeting-system</id><content type="html" xml:base="https://blog.entek.org.uk/notes/2026/02/08/adding-notes-and-linking-transactions-to-my-budgeting-system.html"><![CDATA[<p>I have a budgeting system that I first wrote in <a href="https://www.perl.org/">Perl</a> using <a href="https://github.com/perl-catalyst/catalyst-runtime">Catalyst</a> in 2009, and last re-wrote in <a href="https://www.python.org/">Python</a> using my own web application framework, linawf (which is a recursive nonsense acronym for “Linawf is not a web framework”) and <a href="https://www.sqlalchemy.org/">SQLAlchemy</a> in 2012. It implements a digital version of the <a href="https://en.wikipedia.org/wiki/Envelope_system">envelope budgeting method</a> (although I call them “buckets” rather than “envelopes”) where I allocate money from my income to specific purposes, such as car fuel, energy bills, holiday, food, etc. This post is about updating that to enable adding notes to transactions and then linking related transactions.</p>

<p>My system automatically allocates income to the buckets based on rules, including (optionally) allocating surplus to a different bucket (instead of falling through) if the bucket is “full” (at an upper limit I set). For example, I used to have a hobby bucket (before I consolidated it into my “savings” one) and any surplus from my monthly mobile phone and broadband allocations, once I already had what was required to pay the monthly bill but a little contingency, went into it.</p>

<p>At some point I think I want to re-write this (again) using <a href="https://www.djangoproject.com/">Django</a>. One thing I have in mind it to add a receipts management component too, currently I just save these in a folder structured by date (e.g. <code class="language-plaintext highlighter-rouge">year/month/year-month-day &lt;receipt source&gt; [tags].pdf</code> - they are mostly PDF format) and I would like to then link the receipt to the transaction in my accounts system. I wasn’t sure how to link them, if I created separate Django applications for them, while keeping it optional to enable/disable the applications individually (as I would hope to make this available on <a href="https://github.com/home">GitHub</a>). I think I have found the answer to this, from a <a href="https://stackoverflow.com/questions/34184458/in-django-get-a-reference-to-a-model-in-another-app">Stack Overflow question on referencing models from other applications</a> and <a href="https://docs.djangoproject.com/en/dev/ref/applications/">Django’s documentation on application registry</a>, which explains how to discover if applications are enabled. So I should be able to make the model and UI references conditional on the other application being enabled. Maybe.</p>

<p>Anyway, back to the change at hand…</p>

<h2 id="adding-notes-to-transactions">Adding notes to transactions</h2>

<p>To do this, I just added a <code class="language-plaintext highlighter-rouge">notes</code> column to the database then updated the UI elements to support adding, editing, searching and displaying this.</p>

<h3 id="databaseorm-changes">Database/ORM changes</h3>

<p>Firstly, I added the <code class="language-plaintext highlighter-rouge">notes</code> column to my <code class="language-plaintext highlighter-rouge">transactions</code> table definition which was in the files <code class="language-plaintext highlighter-rouge">budget/entities.py</code> in my source (everything else here was already there):</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">Transaction</span><span class="p">(</span><span class="n">Base</span><span class="p">):</span>
    <span class="n">__tablename__</span> <span class="o">=</span> <span class="sh">'</span><span class="s">transactions</span><span class="sh">'</span>

    <span class="nb">id</span> <span class="o">=</span> <span class="n">sa</span><span class="p">.</span><span class="nc">Column</span><span class="p">(</span><span class="n">sa</span><span class="p">.</span><span class="n">Integer</span><span class="p">,</span> <span class="n">primary_key</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
    <span class="n">date</span> <span class="o">=</span> <span class="n">sa</span><span class="p">.</span><span class="nc">Column</span><span class="p">(</span><span class="n">sa</span><span class="p">.</span><span class="n">Date</span><span class="p">,</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>
    <span class="n">description</span> <span class="o">=</span> <span class="n">sa</span><span class="p">.</span><span class="nc">Column</span><span class="p">(</span><span class="n">sa</span><span class="p">.</span><span class="n">Text</span><span class="p">)</span>
    <span class="n">notes</span> <span class="o">=</span> <span class="n">sa</span><span class="p">.</span><span class="nc">Column</span><span class="p">(</span><span class="n">sa</span><span class="p">.</span><span class="n">Text</span><span class="p">)</span>
    <span class="n">ratified</span> <span class="o">=</span> <span class="n">sa</span><span class="p">.</span><span class="nc">Column</span><span class="p">(</span><span class="n">sa</span><span class="p">.</span><span class="n">Boolean</span><span class="p">,</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>
    <span class="n">template</span> <span class="o">=</span> <span class="n">sa</span><span class="p">.</span><span class="nc">Column</span><span class="p">(</span><span class="n">sa</span><span class="p">.</span><span class="n">Boolean</span><span class="p">,</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>

    <span class="n">account_postings</span> <span class="o">=</span> <span class="n">sao</span><span class="p">.</span><span class="nf">relation</span><span class="p">(</span><span class="n">TransactionAccountPosting</span><span class="p">,</span> <span class="n">backref</span><span class="o">=</span><span class="n">sao</span><span class="p">.</span><span class="nf">backref</span><span class="p">(</span><span class="sh">'</span><span class="s">transaction</span><span class="sh">'</span><span class="p">))</span>
    <span class="n">bucket_postings</span> <span class="o">=</span> <span class="n">sao</span><span class="p">.</span><span class="nf">relation</span><span class="p">(</span><span class="n">TransactionBucketPosting</span><span class="p">,</span> <span class="n">backref</span><span class="o">=</span><span class="n">sao</span><span class="p">.</span><span class="nf">backref</span><span class="p">(</span><span class="sh">'</span><span class="s">transaction</span><span class="sh">'</span><span class="p">))</span>
</code></pre></div></div>

<p>I also updated the <code class="language-plaintext highlighter-rouge">search</code> class method to support searching the notes as well as the description:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@classmethod</span>
<span class="k">def</span> <span class="nf">search</span><span class="p">(</span><span class="n">cls</span><span class="p">,</span> <span class="n">description</span><span class="o">=</span><span class="bp">None</span><span class="p">,</span> <span class="n">search_notes</span><span class="o">=</span><span class="bp">False</span><span class="p">,</span> <span class="n">start_date</span><span class="o">=</span><span class="bp">None</span><span class="p">,</span> <span class="n">end_date</span><span class="o">=</span><span class="bp">None</span><span class="p">,</span> <span class="n">min_value</span><span class="o">=</span><span class="bp">None</span><span class="p">,</span> <span class="n">max_value</span><span class="o">=</span><span class="bp">None</span><span class="p">,</span> <span class="n">limit</span><span class="o">=</span><span class="bp">None</span><span class="p">,</span> <span class="n">offset</span><span class="o">=</span><span class="bp">None</span><span class="p">):</span>
    <span class="n">query</span> <span class="o">=</span> <span class="n">Session</span><span class="p">.</span><span class="nf">query</span><span class="p">(</span><span class="n">cls</span><span class="p">)</span>

    <span class="c1"># Going to want to join TAP in all cases - for overall value and also if min/max are provided
</span>    <span class="n">query</span> <span class="o">=</span> <span class="n">query</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="n">TransactionAccountPosting</span><span class="p">).</span><span class="nf">distinct</span><span class="p">()</span>

    <span class="n">logger</span><span class="p">.</span><span class="nf">info</span><span class="p">(</span><span class="sh">"</span><span class="s">Searching for %s transactions, offset %d, with description </span><span class="sh">'</span><span class="s">%s</span><span class="sh">'</span><span class="s"> (%s notes), %s&lt;=date&lt;=%s and %d&lt;=value&lt;=%s</span><span class="sh">"</span><span class="p">,</span> <span class="n">limit</span> <span class="ow">or</span> <span class="sh">'</span><span class="s">all</span><span class="sh">'</span><span class="p">,</span> <span class="n">offset</span> <span class="ow">or</span> <span class="mi">0</span><span class="p">,</span> <span class="n">description</span><span class="p">,</span> <span class="sh">'</span><span class="s">including</span><span class="sh">'</span> <span class="k">if</span> <span class="n">search_notes</span> <span class="k">else</span> <span class="sh">'</span><span class="s">not including</span><span class="sh">'</span><span class="p">,</span> <span class="n">start_date</span> <span class="ow">or</span> <span class="sh">'</span><span class="s">any</span><span class="sh">'</span><span class="p">,</span> <span class="n">end_date</span> <span class="ow">or</span> <span class="sh">'</span><span class="s">any</span><span class="sh">'</span><span class="p">,</span> <span class="n">min_value</span> <span class="ow">or</span> <span class="mi">0</span><span class="p">,</span> <span class="n">max_value</span> <span class="ow">or</span> <span class="sh">'</span><span class="s">infinity</span><span class="sh">'</span><span class="p">)</span>

    <span class="k">if</span> <span class="n">description</span><span class="p">:</span>
        <span class="k">if</span> <span class="n">search_notes</span><span class="p">:</span>
            <span class="n">query</span> <span class="o">=</span> <span class="n">query</span><span class="p">.</span><span class="nf">filter</span><span class="p">(</span><span class="nf">or_</span><span class="p">(</span><span class="n">cls</span><span class="p">.</span><span class="n">description</span><span class="p">.</span><span class="nf">ilike</span><span class="p">(</span><span class="sh">'</span><span class="s">%{}%</span><span class="sh">'</span><span class="p">.</span><span class="nf">format</span><span class="p">(</span><span class="n">description</span><span class="p">)),</span> <span class="n">cls</span><span class="p">.</span><span class="n">notes</span><span class="p">.</span><span class="nf">ilike</span><span class="p">(</span><span class="sh">'</span><span class="s">%{}%</span><span class="sh">'</span><span class="p">.</span><span class="nf">format</span><span class="p">(</span><span class="n">description</span><span class="p">))))</span>
        <span class="k">else</span><span class="p">:</span>
            <span class="n">query</span> <span class="o">=</span> <span class="n">query</span><span class="p">.</span><span class="nf">filter</span><span class="p">(</span><span class="n">cls</span><span class="p">.</span><span class="n">description</span><span class="p">.</span><span class="nf">ilike</span><span class="p">(</span><span class="sh">'</span><span class="s">%{}%</span><span class="sh">'</span><span class="p">.</span><span class="nf">format</span><span class="p">(</span><span class="n">description</span><span class="p">)))</span>

    <span class="k">if</span> <span class="n">start_date</span><span class="p">:</span>
        <span class="n">query</span> <span class="o">=</span> <span class="n">query</span><span class="p">.</span><span class="nf">filter</span><span class="p">(</span><span class="n">cls</span><span class="p">.</span><span class="n">date</span> <span class="o">&gt;=</span> <span class="n">start_date</span><span class="p">)</span>

    <span class="k">if</span> <span class="n">end_date</span><span class="p">:</span>
        <span class="n">query</span> <span class="o">=</span> <span class="n">query</span><span class="p">.</span><span class="nf">filter</span><span class="p">(</span><span class="n">cls</span><span class="p">.</span><span class="n">date</span> <span class="o">&lt;=</span> <span class="n">end_date</span><span class="p">)</span>

    <span class="c1"># Take min/max to be any individual account posting between the limits
</span>    <span class="k">if</span> <span class="n">min_value</span> <span class="ow">and</span> <span class="n">max_value</span><span class="p">:</span>
        <span class="n">query</span> <span class="o">=</span> <span class="n">query</span><span class="p">.</span><span class="nf">filter</span><span class="p">(</span><span class="nf">or_</span><span class="p">(</span><span class="nf">and_</span><span class="p">(</span><span class="n">TransactionAccountPosting</span><span class="p">.</span><span class="n">value</span><span class="o">&gt;=</span><span class="n">min_value</span><span class="p">,</span> <span class="n">TransactionAccountPosting</span><span class="p">.</span><span class="n">value</span><span class="o">&lt;=</span><span class="n">max_value</span><span class="p">),</span> <span class="nf">and_</span><span class="p">(</span><span class="n">TransactionAccountPosting</span><span class="p">.</span><span class="n">value</span><span class="o">&lt;=-</span><span class="mi">1</span><span class="o">*</span><span class="n">min_value</span><span class="p">,</span> <span class="n">TransactionAccountPosting</span><span class="p">.</span><span class="n">value</span><span class="o">&gt;=-</span><span class="mi">1</span><span class="o">*</span><span class="n">max_value</span><span class="p">)))</span>
    <span class="k">elif</span> <span class="n">min_value</span><span class="p">:</span>
        <span class="n">query</span> <span class="o">=</span> <span class="n">query</span><span class="p">.</span><span class="nf">filter</span><span class="p">(</span><span class="nf">or_</span><span class="p">(</span><span class="n">TransactionAccountPosting</span><span class="p">.</span><span class="n">value</span><span class="o">&gt;=</span><span class="n">min_value</span><span class="p">,</span> <span class="n">TransactionAccountPosting</span><span class="p">.</span><span class="n">value</span><span class="o">&lt;=-</span><span class="mi">1</span><span class="o">*</span><span class="n">min_value</span><span class="p">))</span>
    <span class="k">elif</span> <span class="n">max_value</span><span class="p">:</span>
        <span class="n">query</span> <span class="o">=</span> <span class="n">query</span><span class="p">.</span><span class="nf">filter</span><span class="p">(</span><span class="nf">and_</span><span class="p">(</span><span class="n">TransactionAccountPosting</span><span class="p">.</span><span class="n">value</span><span class="o">&lt;=</span><span class="n">max_value</span><span class="p">,</span> <span class="n">TransactionAccountPosting</span><span class="p">.</span><span class="n">value</span><span class="o">&gt;=-</span><span class="mi">1</span><span class="o">*</span><span class="n">max_value</span><span class="p">))</span>

    <span class="n">count</span> <span class="o">=</span> <span class="n">query</span><span class="p">.</span><span class="nf">count</span><span class="p">()</span>
    <span class="n">total_value</span> <span class="o">=</span> <span class="n">query</span><span class="p">.</span><span class="nf">with_entities</span><span class="p">(</span><span class="n">sa</span><span class="p">.</span><span class="n">func</span><span class="p">.</span><span class="nf">sum</span><span class="p">(</span><span class="n">TransactionAccountPosting</span><span class="p">.</span><span class="n">value</span><span class="p">)).</span><span class="nf">scalar</span><span class="p">()</span>

    <span class="n">query</span> <span class="o">=</span> <span class="n">query</span><span class="p">.</span><span class="nf">order_by</span><span class="p">(</span><span class="n">Transaction</span><span class="p">.</span><span class="n">date</span><span class="p">.</span><span class="nf">desc</span><span class="p">(),</span> <span class="n">Transaction</span><span class="p">.</span><span class="nb">id</span><span class="p">)</span>

    <span class="k">if</span> <span class="n">limit</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span><span class="p">:</span>
        <span class="n">query</span> <span class="o">=</span> <span class="n">query</span><span class="p">.</span><span class="nf">limit</span><span class="p">(</span><span class="n">limit</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">offset</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span><span class="p">:</span>
        <span class="n">query</span> <span class="o">=</span> <span class="n">query</span><span class="p">.</span><span class="nf">offset</span><span class="p">(</span><span class="n">offset</span><span class="p">)</span>

    <span class="k">return</span> <span class="p">{</span>
        <span class="sh">'</span><span class="s">count</span><span class="sh">'</span><span class="p">:</span> <span class="n">count</span><span class="p">,</span>
        <span class="sh">'</span><span class="s">transactions</span><span class="sh">'</span><span class="p">:</span> <span class="n">query</span><span class="p">.</span><span class="nf">all</span><span class="p">(),</span>
        <span class="sh">'</span><span class="s">total_value</span><span class="sh">'</span><span class="p">:</span> <span class="n">total_value</span>
    <span class="p">}</span>
</code></pre></div></div>

<p>Currently I have no mechanism for updating the database - in previous iterations of the system I had migration scripts for doing this. This is another driver for wanting to move to Django. Instead, I manually added the column to my database:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">ALTER</span> <span class="k">TABLE</span> <span class="n">transactions</span> <span class="k">ADD</span> <span class="k">COLUMN</span> <span class="n">notes</span> <span class="nb">TEXT</span><span class="p">;</span>
</code></pre></div></div>

<h3 id="ui-changes">UI changes</h3>

<p>The were a number of places I wanted to update for this field - obviously editing and displaying individual transactions, but I also wanted to see which transactions had notes in the transaction list display and to optionally search the notes as well as descriptions from the search form.</p>

<h4 id="edit-transaction-form">Edit transaction form</h4>

<p>My original plan was to add <code class="language-plaintext highlighter-rouge">&lt;textarea&gt;...&lt;/textarea&gt;</code> to the form, to allow the flexibility to have multiline notes. However, my current wizard uses <code class="language-plaintext highlighter-rouge">&lt;input type="hidden" .../&gt;</code> to store the values from the previous steps, which won’t work with values that have newlines. So, instead, I just used a single-line <code class="language-plaintext highlighter-rouge">&lt;input type="text" .../&gt;</code> for the notes - less flexible but better than the current situation (nothing). This was added to my <code class="language-plaintext highlighter-rouge">templates/create.j2</code> template (which is now a misnomer as the same template is used for editing transactions):</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;p&gt;&lt;label</span> <span class="na">for=</span><span class="s">"txtNotes"</span><span class="nt">&gt;</span>Notes:<span class="nt">&lt;/label&gt;&lt;input</span> <span class="na">type=</span><span class="s">"text"</span> <span class="na">id=</span><span class="s">"txtNotes"</span> <span class="na">name=</span><span class="s">"notes"</span> <span class="err">{{</span> <span class="na">macros.value_if</span><span class="err">(</span><span class="na">transaction</span><span class="err">,</span> <span class="err">'</span><span class="na">notes</span><span class="err">')</span> <span class="na">or</span> <span class="na">macros.value_if</span><span class="err">(</span><span class="na">template</span><span class="err">,</span> <span class="err">'</span><span class="na">notes</span><span class="err">')</span> <span class="err">}}</span><span class="nt">/&gt;&lt;/p&gt;</span>
</code></pre></div></div>

<p>The other templates (<code class="language-plaintext highlighter-rouge">allocate_account.j2</code> and <code class="language-plaintext highlighter-rouge">allocate_buckets.j2</code>) just needed a hidden field adding to preserve the value through the rest of the add/edit transaction (same templates for either) wizard:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;input</span> <span class="na">type=</span><span class="s">"hidden"</span> <span class="na">id=</span><span class="s">"hdnNotes"</span> <span class="na">name=</span><span class="s">"notes"</span> <span class="na">value=</span><span class="s">"{{ notes }}"</span> <span class="nt">/&gt;</span>
</code></pre></div></div>

<h5 id="backend-code-changes">Backend code changes</h5>

<p>Relatively few code changes were required - in my <code class="language-plaintext highlighter-rouge">budget/controller/transaction.py</code> file I passed the notes through to the template for the <code class="language-plaintext highlighter-rouge">allocate_account</code> and <code class="language-plaintext highlighter-rouge">allocate_buckets</code> functions:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">allocate_account</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="n">response</span><span class="p">):</span>
    <span class="c1"># [...]
</span>    <span class="n">response</span><span class="p">.</span><span class="n">unicode_body</span> <span class="o">=</span> <span class="n">v</span><span class="p">.</span><span class="nf">render_view</span><span class="p">(</span><span class="sh">'</span><span class="s">transaction/allocate_account.j2</span><span class="sh">'</span><span class="p">,</span> <span class="n">request</span><span class="p">,</span>
        <span class="nb">type</span><span class="o">=</span><span class="n">request</span><span class="p">.</span><span class="n">params</span><span class="p">[</span><span class="sh">'</span><span class="s">type</span><span class="sh">'</span><span class="p">],</span>
        <span class="n">date</span><span class="o">=</span><span class="n">request</span><span class="p">.</span><span class="n">params</span><span class="p">[</span><span class="sh">'</span><span class="s">date</span><span class="sh">'</span><span class="p">],</span>
        <span class="n">description</span><span class="o">=</span><span class="n">request</span><span class="p">.</span><span class="n">params</span><span class="p">[</span><span class="sh">'</span><span class="s">description</span><span class="sh">'</span><span class="p">],</span>
        <span class="n">notes</span><span class="o">=</span><span class="n">request</span><span class="p">.</span><span class="n">params</span><span class="p">[</span><span class="sh">'</span><span class="s">notes</span><span class="sh">'</span><span class="p">],</span>
        <span class="n">template</span><span class="o">=</span><span class="n">request</span><span class="p">.</span><span class="n">params</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">'</span><span class="s">template</span><span class="sh">'</span><span class="p">,</span> <span class="bp">False</span><span class="p">),</span>
        <span class="n">base_template</span><span class="o">=</span><span class="n">template</span><span class="p">,</span>
        <span class="o">**</span><span class="n">data</span>
    <span class="p">)</span>

<span class="k">def</span> <span class="nf">allocate_buckets</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="n">response</span><span class="p">):</span>
    <span class="n">data</span> <span class="o">=</span> <span class="p">{}</span>
    
    <span class="n">data</span><span class="p">[</span><span class="sh">'</span><span class="s">transaction</span><span class="sh">'</span><span class="p">]</span> <span class="o">=</span> <span class="nf">_get_transaction</span><span class="p">(</span><span class="n">request</span><span class="p">)</span>

    <span class="k">for</span> <span class="n">key</span> <span class="ow">in</span> <span class="p">(</span><span class="sh">'</span><span class="s">type</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">date</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">description</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">notes</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">ratified</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">template</span><span class="sh">'</span><span class="p">):</span>
        <span class="n">data</span><span class="p">[</span><span class="n">key</span><span class="p">]</span> <span class="o">=</span> <span class="n">request</span><span class="p">.</span><span class="n">params</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="n">key</span><span class="p">)</span>
    <span class="c1"># [...]
</span></code></pre></div></div>

<p>The other changes were in the <code class="language-plaintext highlighter-rouge">do_create</code> function (another misnomer, as it also updates edited transactions), to add/update the notes (for new/existing transactions respectively):</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">do_create</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="n">response</span><span class="p">):</span>
    <span class="c1"># [...]
</span>    <span class="k">if</span> <span class="n">transaction</span> <span class="ow">is</span> <span class="bp">None</span><span class="p">:</span>
        <span class="n">transaction</span> <span class="o">=</span> <span class="n">entities</span><span class="p">.</span><span class="nc">Transaction</span><span class="p">(</span><span class="n">request</span><span class="p">.</span><span class="n">params</span><span class="p">[</span><span class="sh">'</span><span class="s">date</span><span class="sh">'</span><span class="p">],</span> <span class="n">request</span><span class="p">.</span><span class="n">params</span><span class="p">[</span><span class="sh">'</span><span class="s">description</span><span class="sh">'</span><span class="p">],</span> <span class="n">request</span><span class="p">.</span><span class="n">params</span><span class="p">[</span><span class="sh">'</span><span class="s">notes</span><span class="sh">'</span><span class="p">],</span> <span class="n">ratified</span><span class="p">)</span>
        <span class="n">logger</span><span class="p">.</span><span class="nf">debug</span><span class="p">(</span><span class="sh">"</span><span class="s">New transaction</span><span class="sh">"</span><span class="p">)</span>
    <span class="k">else</span><span class="p">:</span>
        <span class="c1"># [...]
</span>        <span class="k">if</span> <span class="n">transaction</span><span class="p">.</span><span class="n">notes</span> <span class="o">!=</span> <span class="n">request</span><span class="p">.</span><span class="n">params</span><span class="p">[</span><span class="sh">'</span><span class="s">notes</span><span class="sh">'</span><span class="p">]:</span>
            <span class="n">logger</span><span class="p">.</span><span class="nf">debug</span><span class="p">(</span><span class="sh">"</span><span class="s">Transaction %d has new notes (old: %s, new: %s)</span><span class="sh">"</span><span class="p">,</span> <span class="n">transaction</span><span class="p">.</span><span class="nb">id</span><span class="p">,</span> <span class="n">transaction</span><span class="p">.</span><span class="n">notes</span><span class="p">,</span> <span class="n">request</span><span class="p">.</span><span class="n">params</span><span class="p">[</span><span class="sh">'</span><span class="s">notes</span><span class="sh">'</span><span class="p">])</span>
            <span class="n">transaction</span><span class="p">.</span><span class="n">notes</span> <span class="o">=</span> <span class="n">request</span><span class="p">.</span><span class="n">params</span><span class="p">[</span><span class="sh">'</span><span class="s">notes</span><span class="sh">'</span><span class="p">]</span>
        <span class="c1"># [...]
</span>
</code></pre></div></div>

<p>Finally, I have an function to bulk-create transactions from multiple template transactions, which also needed the <code class="language-plaintext highlighter-rouge">notes</code> field adding to the constructor call for a new transaction:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">bulk_add_template</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="n">response</span><span class="p">):</span>
    <span class="c1"># [...]
</span>
    <span class="k">for</span> <span class="nb">id</span> <span class="ow">in</span> <span class="n">ids</span><span class="p">:</span>
        <span class="c1"># [...]
</span>
        <span class="n">new_transaction</span> <span class="o">=</span> <span class="n">entities</span><span class="p">.</span><span class="nc">Transaction</span><span class="p">(</span><span class="n">transaction_date</span><span class="p">,</span> <span class="n">template_transaction</span><span class="p">.</span><span class="n">description</span><span class="p">,</span> <span class="n">template_transaction</span><span class="p">.</span><span class="n">notes</span><span class="p">)</span>

        <span class="c1"># [...]
</span></code></pre></div></div>

<h5 id="textarea-version-notes">Textarea version notes</h5>

<p>From my initial work to add notes as a <code class="language-plaintext highlighter-rouge">textarea</code>, I had a few notes that I want to preserve although I reverted these code changes as they are unnecessary with the plain <code class="language-plaintext highlighter-rouge">text</code> input version:</p>

<p>The new CSS class was needed to ensure the label is vertically aligned neatly.</p>

<p>Without the class:</p>

<p><img src="/assets/posts/2026-02-08-adding-notes-and-linking-transactions-to-my-budgeting-system/textarea-without-css-class.png" alt="textarea and label without CSS class" style="display:block; margin-left:auto; margin-right:auto" /></p>

<p>With the class:</p>

<p><img src="/assets/posts/2026-02-08-adding-notes-and-linking-transactions-to-my-budgeting-system/textarea-with-css-class.png" alt="textarea and label with CSS class" style="display:block; margin-left:auto; margin-right:auto" /></p>

<p>The CSS for the class is (I added it to the file <code class="language-plaintext highlighter-rouge">htdocs/static/styles/main.css</code> in my source tree - <code class="language-plaintext highlighter-rouge">htdocs/static</code> is served up directly by the webserver, rather than passed through to uwsgi and my application, for efficiency):</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">/*
 * Display label and textarea vertically aligned (otherwise label will sit at the bottom of the text area).
 * Based on: https://stackoverflow.com/a/1839450
 */</span>
<span class="nc">.textarea_container</span> <span class="p">{</span>
       <span class="nl">display</span><span class="p">:</span> <span class="nb">flex</span><span class="p">;</span>
       <span class="nl">align-items</span><span class="p">:</span> <span class="nb">center</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>I had an existing macro to output a <code class="language-plaintext highlighter-rouge">value=</code> attribute for <code class="language-plaintext highlighter-rouge">input</code> elements, however the textarea takes its text as content rather than an attribute. I therefore added a new macro to my <code class="language-plaintext highlighter-rouge">templates/macros.j2</code> file to output just the value (without the surrounding <code class="language-plaintext highlighter-rouge">value="</code> and <code class="language-plaintext highlighter-rouge">"</code>). Only <code class="language-plaintext highlighter-rouge">text_if</code> is new but I’ve reproduced the existing macro here for reference - note the macro returning nothing if there’s no value is useful for the <code class="language-plaintext highlighter-rouge">value_if(...) or value_if(...)</code> logic in the template above:</p>

<div class="language-jinja highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">{%</span> <span class="k">macro</span> <span class="nv">value_if</span><span class="p">(</span><span class="nv">item</span><span class="p">,</span> <span class="nv">value</span><span class="o">=</span><span class="kp">None</span><span class="p">)</span> <span class="o">-</span><span class="cp">%}</span>
<span class="cp">{%</span>     <span class="k">if</span> <span class="nv">item</span> <span class="cp">%}</span>value="<span class="cp">{%</span> <span class="k">if</span> <span class="nv">value</span> <span class="cp">%}{%</span> <span class="k">if</span> <span class="nv">item</span><span class="p">[</span><span class="nv">value</span><span class="p">]</span> <span class="ow">and</span> <span class="nv">item</span><span class="p">[</span><span class="nv">value</span><span class="p">]</span> <span class="o">!=</span> <span class="kp">None</span> <span class="cp">%}{{</span> <span class="nv">item</span><span class="p">[</span><span class="nv">value</span><span class="p">]</span> <span class="cp">}}{%</span> <span class="k">endif</span> <span class="cp">%}{%</span> <span class="k">else</span> <span class="cp">%}{{</span> <span class="nv">item</span> <span class="cp">}}{%</span> <span class="k">endif</span> <span class="cp">%}</span>"<span class="cp">{%</span>     <span class="k">endif</span> <span class="cp">%}</span>
<span class="cp">{%</span><span class="o">-</span> <span class="k">endmacro</span> <span class="cp">%}</span>
<span class="cp">{%</span> <span class="k">macro</span> <span class="nv">text_if</span><span class="p">(</span><span class="nv">item</span><span class="p">,</span> <span class="nv">value</span><span class="o">=</span><span class="kp">None</span><span class="p">)</span> <span class="o">-</span><span class="cp">%}</span>
<span class="cp">{%</span>     <span class="k">if</span> <span class="nv">item</span> <span class="cp">%}{%</span> <span class="k">if</span> <span class="nv">value</span> <span class="cp">%}{%</span> <span class="k">if</span> <span class="nv">item</span><span class="p">[</span><span class="nv">value</span><span class="p">]</span> <span class="ow">and</span> <span class="nv">item</span><span class="p">[</span><span class="nv">value</span><span class="p">]</span> <span class="o">!=</span> <span class="kp">None</span> <span class="cp">%}{{</span> <span class="nv">item</span><span class="p">[</span><span class="nv">value</span><span class="p">]</span> <span class="cp">}}{%</span> <span class="k">endif</span> <span class="cp">%}{%</span> <span class="k">else</span> <span class="cp">%}{{</span> <span class="nv">item</span> <span class="cp">}}{%</span> <span class="k">endif</span> <span class="cp">%}{%</span>     <span class="k">endif</span> <span class="cp">%}</span>
<span class="cp">{%</span><span class="o">-</span> <span class="k">endmacro</span> <span class="cp">%}</span>
</code></pre></div></div>

<h4 id="transaction-display">Transaction display</h4>

<p>This was a simple one line addition to my <code class="language-plaintext highlighter-rouge">templates/transaction/view.j2</code> template:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;p&gt;</span>Notes: {{ transaction.notes or '' }}<span class="nt">&lt;/p&gt;</span>
</code></pre></div></div>

<h4 id="transaction-list">Transaction list</h4>

<p>I wanted to display if a transaction had a note in the lists of transactions, so far my application has no images and I did not want to start adding some so I used <a href="https://unicodeplus.com/">the UnicodePlus website</a> to search for a suitable Unicode character to use. I chose 🗒, “Spiral Note Pad” as other symbols that sounded more appropriate, like “Note”, “Note Page” were not rendering in <a href="https://www.google.com/chrome/">Chrome</a>.</p>

<p>In my <code class="language-plaintext highlighter-rouge">budget/entities.py</code> ORM code, I added the new <code class="language-plaintext highlighter-rouge">notes</code> field to the list of fields retrieved by the <code class="language-plaintext highlighter-rouge">transactions</code> method on the <code class="language-plaintext highlighter-rouge">Account</code> and <code class="language-plaintext highlighter-rouge">Bucket</code> classes:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">Bucket</span><span class="p">(</span><span class="n">base</span><span class="p">):</span>
    <span class="c1"># [...]
</span>    <span class="k">def</span> <span class="nf">transactions</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">limit</span><span class="o">=</span><span class="bp">None</span><span class="p">,</span> <span class="n">offset</span><span class="o">=</span><span class="bp">None</span><span class="p">):</span>
		<span class="n">query</span> <span class="o">=</span> <span class="n">Session</span><span class="p">.</span><span class="nf">object_session</span><span class="p">(</span><span class="n">self</span><span class="p">)</span> \
			<span class="p">.</span><span class="nf">query</span><span class="p">(</span><span class="n">Transaction</span><span class="p">.</span><span class="nb">id</span><span class="p">,</span> <span class="n">Transaction</span><span class="p">.</span><span class="n">date</span><span class="p">,</span> <span class="n">Transaction</span><span class="p">.</span><span class="n">description</span><span class="p">,</span> <span class="n">Transaction</span><span class="p">.</span><span class="n">notes</span><span class="p">,</span> <span class="n">Transaction</span><span class="p">.</span><span class="n">ratified</span><span class="p">,</span> <span class="n">TransactionBucketPosting</span><span class="p">.</span><span class="n">value</span><span class="p">)</span> \
			<span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="n">TransactionBucketPosting</span><span class="p">)</span> \
			<span class="p">.</span><span class="nf">filter</span><span class="p">(</span><span class="n">TransactionBucketPosting</span><span class="p">.</span><span class="n">bucket_id</span><span class="o">==</span><span class="n">self</span><span class="p">.</span><span class="nb">id</span><span class="p">)</span> \
			<span class="p">.</span><span class="nf">order_by</span><span class="p">(</span><span class="n">Transaction</span><span class="p">.</span><span class="n">date</span><span class="p">.</span><span class="nf">desc</span><span class="p">(),</span> <span class="n">Transaction</span><span class="p">.</span><span class="nb">id</span><span class="p">)</span>
		
		<span class="k">if</span> <span class="n">limit</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span><span class="p">:</span>
			<span class="n">query</span> <span class="o">=</span> <span class="n">query</span><span class="p">.</span><span class="nf">limit</span><span class="p">(</span><span class="n">limit</span><span class="p">)</span>
		<span class="k">if</span> <span class="n">offset</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span><span class="p">:</span>
			<span class="n">query</span> <span class="o">=</span> <span class="n">query</span><span class="p">.</span><span class="nf">offset</span><span class="p">(</span><span class="n">offset</span><span class="p">)</span>
		
		<span class="k">return</span> <span class="n">query</span><span class="p">.</span><span class="nf">all</span><span class="p">()</span>
    <span class="c1"># [...]
</span>
<span class="k">class</span> <span class="nc">Account</span><span class="p">(</span><span class="n">base</span><span class="p">):</span>
    <span class="c1"># [...]
</span>	<span class="k">def</span> <span class="nf">transactions</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">limit</span><span class="o">=</span><span class="bp">None</span><span class="p">,</span> <span class="n">offset</span><span class="o">=</span><span class="bp">None</span><span class="p">):</span>
		<span class="n">query</span> <span class="o">=</span> <span class="n">Session</span><span class="p">.</span><span class="nf">object_session</span><span class="p">(</span><span class="n">self</span><span class="p">)</span> \
			<span class="p">.</span><span class="nf">query</span><span class="p">(</span><span class="n">Transaction</span><span class="p">.</span><span class="nb">id</span><span class="p">,</span> <span class="n">Transaction</span><span class="p">.</span><span class="n">date</span><span class="p">,</span> <span class="n">Transaction</span><span class="p">.</span><span class="n">description</span><span class="p">,</span> <span class="n">Transaction</span><span class="p">.</span><span class="n">notes</span><span class="p">,</span> <span class="n">Transaction</span><span class="p">.</span><span class="n">ratified</span><span class="p">,</span> <span class="n">TransactionAccountPosting</span><span class="p">.</span><span class="n">value</span><span class="p">)</span> \
			<span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="n">TransactionAccountPosting</span><span class="p">)</span> \
			<span class="p">.</span><span class="nf">filter</span><span class="p">(</span><span class="n">TransactionAccountPosting</span><span class="p">.</span><span class="n">account_id</span><span class="o">==</span><span class="n">self</span><span class="p">.</span><span class="nb">id</span><span class="p">)</span> \
			<span class="p">.</span><span class="nf">order_by</span><span class="p">(</span><span class="n">Transaction</span><span class="p">.</span><span class="n">date</span><span class="p">.</span><span class="nf">desc</span><span class="p">(),</span> <span class="n">Transaction</span><span class="p">.</span><span class="nb">id</span><span class="p">)</span>
		
		<span class="k">if</span> <span class="n">limit</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span><span class="p">:</span>
			<span class="n">query</span> <span class="o">=</span> <span class="n">query</span><span class="p">.</span><span class="nf">limit</span><span class="p">(</span><span class="n">limit</span><span class="p">)</span>
		<span class="k">if</span> <span class="n">offset</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span><span class="p">:</span>
			<span class="n">query</span> <span class="o">=</span> <span class="n">query</span><span class="p">.</span><span class="nf">offset</span><span class="p">(</span><span class="n">offset</span><span class="p">)</span>
		
		<span class="k">return</span> <span class="n">query</span><span class="p">.</span><span class="nf">all</span><span class="p">()</span>
</code></pre></div></div>

<p>There are a number of templates, where transactions are displayed, to edit, however the change is the same for all of them. The templates are:</p>

<ul>
  <li>Account display (<code class="language-plaintext highlighter-rouge">templates/account.j2</code>)</li>
  <li>Bucket display (<code class="language-plaintext highlighter-rouge">templates/bucket.j2</code>)</li>
  <li>Search results (<code class="language-plaintext highlighter-rouge">templates/search/form.j2</code> (another misnomer, it shows both the search form and, if a search has been conducted, the results below it))</li>
  <li>Template transactions management page (<code class="language-plaintext highlighter-rouge">templates/manage/template_transactions/list.j2</code>)</li>
</ul>

<p>I enabled the note to display as a “tooltip”esque popup, using CSS and an attribute on a block element based on <a href="https://stackoverflow.com/a/77796790">a Stack Overflow answer</a>. The CSS just went in my static <code class="language-plaintext highlighter-rouge">htdocs/static/styles/main.css</code> file:</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">[</span><span class="nt">data-tooltip</span><span class="o">]</span> <span class="p">{</span>
	<span class="nl">display</span><span class="p">:</span> <span class="nb">inline-block</span><span class="p">;</span>
	<span class="nl">cursor</span><span class="p">:</span> <span class="nb">pointer</span><span class="p">;</span>
<span class="p">}</span>

<span class="o">[</span><span class="nt">data-tooltip</span><span class="o">]</span><span class="nd">:hover::after</span> <span class="p">{</span>
	<span class="nl">display</span><span class="p">:</span> <span class="nb">block</span><span class="p">;</span>
	<span class="nl">position</span><span class="p">:</span> <span class="nb">absolute</span><span class="p">;</span>
	<span class="nl">content</span><span class="p">:</span> <span class="nf">attr</span><span class="p">(</span><span class="n">data-tooltip</span><span class="p">);</span>
    <span class="nl">color</span><span class="p">:</span> <span class="nx">#000</span><span class="p">;</span>
	<span class="nl">font-weight</span><span class="p">:</span> <span class="nb">normal</span><span class="p">;</span>
	<span class="nl">border</span><span class="p">:</span> <span class="m">1px</span> <span class="nb">solid</span> <span class="nx">black</span><span class="p">;</span>
	<span class="nl">background</span><span class="p">:</span> <span class="nx">#eee</span><span class="p">;</span>
	<span class="nl">padding</span><span class="p">:</span> <span class="m">.25em</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Within the templates, I just added the note icon with the note as data in a <code class="language-plaintext highlighter-rouge">div</code> element after the existing display of the <code class="language-plaintext highlighter-rouge">description</code>:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;a</span> <span class="na">href=</span><span class="s">"{{ request.script_name }}/transaction/{{ transaction.id }}"</span><span class="nt">&gt;</span>{{ transaction.description }}<span class="nt">&lt;/a&gt;</span>{% if transaction.notes %}<span class="nt">&lt;div</span> <span class="na">data-tooltip=</span><span class="s">"{{ transaction.notes }}"</span><span class="nt">&gt;</span><span class="ni">&amp;#x1f5d2;</span><span class="nt">&lt;/div&gt;</span>{% endif %}
</code></pre></div></div>

<h4 id="searching">Searching</h4>

<p>Although I have <a href="#transaction-list">already added support for displaying notes in search results</a> and <a href="#databaseorm-changes">support for searching notes to the ORM layer</a>, I have not added the UI support for searching notes.</p>

<p>This is just a checkbox on the search form, so say whether the description text search should also search the notes (for now, I have decided not to separately search notes). The parenthesis are just aesthetic, it appears in brackets after the description search box:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code>(
    <span class="nt">&lt;input</span> <span class="na">type=</span><span class="s">"checkbox"</span> <span class="na">id=</span><span class="s">"chkIncludeNotes"</span> <span class="na">name=</span><span class="s">"include_notes"</span> <span class="err">{%</span> <span class="na">if</span> <span class="na">params</span> <span class="na">and</span> <span class="na">params.get</span><span class="err">('</span><span class="na">include_notes</span><span class="err">',</span> <span class="na">False</span><span class="err">)</span> <span class="err">%}</span><span class="na">checked=</span><span class="s">"checked"</span> <span class="err">{%</span> <span class="na">endif</span> <span class="err">%}</span> <span class="nt">/&gt;</span>
    <span class="nt">&lt;label</span> <span class="na">for=</span><span class="s">"chkIncludeNotes"</span><span class="nt">&gt;</span>also search notes<span class="nt">&lt;/label&gt;</span>
)
</code></pre></div></div>

<p>Since I had done most of the work, I just needed to adjust the <code class="language-plaintext highlighter-rouge">do_search</code> function in my <code class="language-plaintext highlighter-rouge">budget/controller/search.py</code> file to pass through the value to the transaction search method and template (so the state of the tickbox is correct on the form after searching) respectively:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">do_search</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="n">response</span><span class="p">):</span>
	<span class="c1"># [...]
</span>
	<span class="n">description</span> <span class="o">=</span> <span class="n">request</span><span class="p">.</span><span class="n">params</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">'</span><span class="s">term</span><span class="sh">'</span><span class="p">,</span> <span class="bp">None</span><span class="p">)</span>
	<span class="n">include_notes</span> <span class="o">=</span> <span class="n">request</span><span class="p">.</span><span class="n">params</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">'</span><span class="s">include_notes</span><span class="sh">'</span><span class="p">)</span>
	<span class="k">if</span> <span class="n">include_notes</span> <span class="o">==</span> <span class="sh">'</span><span class="s">on</span><span class="sh">'</span><span class="p">:</span>
		<span class="n">include_notes</span> <span class="o">=</span> <span class="bp">True</span>
	<span class="k">else</span><span class="p">:</span>
		<span class="n">include_notes</span> <span class="o">=</span> <span class="bp">False</span>
	<span class="n">start_date</span> <span class="o">=</span> <span class="n">request</span><span class="p">.</span><span class="n">params</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">'</span><span class="s">start_date</span><span class="sh">'</span><span class="p">,</span> <span class="bp">None</span><span class="p">)</span>
	<span class="n">end_date</span> <span class="o">=</span> <span class="n">request</span><span class="p">.</span><span class="n">params</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">'</span><span class="s">end_date</span><span class="sh">'</span><span class="p">,</span> <span class="bp">None</span><span class="p">)</span>

    <span class="c1"># [...]
</span>
	<span class="n">logger</span><span class="p">.</span><span class="nf">debug</span><span class="p">(</span><span class="sh">"</span><span class="s">Searching for transactions with description </span><span class="sh">'</span><span class="s">%s</span><span class="sh">'</span><span class="s"> (%s notes), %s&lt;=date&lt;=%s and %d&lt;=value&lt;=%s</span><span class="sh">"</span><span class="p">,</span> <span class="n">description</span><span class="p">,</span> <span class="sh">'</span><span class="s">including</span><span class="sh">'</span> <span class="k">if</span> <span class="n">include_notes</span> <span class="k">else</span> <span class="sh">'</span><span class="s">not including</span><span class="sh">'</span><span class="p">,</span> <span class="n">start_date</span> <span class="ow">or</span> <span class="sh">'</span><span class="s">any</span><span class="sh">'</span><span class="p">,</span> <span class="n">end_date</span> <span class="ow">or</span> <span class="sh">'</span><span class="s">any</span><span class="sh">'</span><span class="p">,</span> <span class="n">min_value</span> <span class="ow">or</span> <span class="mi">0</span><span class="p">,</span> <span class="n">max_value</span> <span class="ow">or</span> <span class="sh">'</span><span class="s">infinity</span><span class="sh">'</span><span class="p">)</span>

	<span class="c1"># Result with be a dict with keys 'count', 'transactions' (the individual, paginated, transactions), 'total_value'
</span>	<span class="n">result</span> <span class="o">=</span> <span class="n">entities</span><span class="p">.</span><span class="n">Transaction</span><span class="p">.</span><span class="nf">search</span><span class="p">(</span><span class="n">description</span><span class="o">=</span><span class="n">description</span><span class="p">,</span> <span class="n">search_notes</span><span class="o">=</span><span class="n">include_notes</span><span class="p">,</span> <span class="n">start_date</span><span class="o">=</span><span class="n">start_date</span><span class="p">,</span> <span class="n">end_date</span><span class="o">=</span><span class="n">end_date</span><span class="p">,</span> <span class="n">min_value</span><span class="o">=</span><span class="n">min_value</span><span class="p">,</span> <span class="n">max_value</span><span class="o">=</span><span class="n">max_value</span><span class="p">,</span> <span class="n">limit</span><span class="o">=</span><span class="n">page_size</span><span class="p">,</span> <span class="n">offset</span><span class="o">=</span><span class="p">((</span><span class="n">page</span><span class="o">-</span><span class="mi">1</span><span class="p">)</span><span class="o">*</span><span class="n">page_size</span><span class="p">))</span>

	<span class="c1"># Multiply by 1.0 to give python the hint we want a float to round up
</span>	<span class="n">pages</span> <span class="o">=</span> <span class="nf">int</span><span class="p">(</span><span class="n">math</span><span class="p">.</span><span class="nf">ceil</span><span class="p">(</span><span class="n">result</span><span class="p">[</span><span class="sh">'</span><span class="s">count</span><span class="sh">'</span><span class="p">]</span> <span class="o">*</span> <span class="mf">1.0</span> <span class="o">/</span> <span class="n">page_size</span><span class="p">))</span>

	<span class="k">return</span> <span class="p">{</span>
		<span class="sh">'</span><span class="s">page</span><span class="sh">'</span><span class="p">:</span> <span class="n">page</span><span class="p">,</span>
		<span class="sh">'</span><span class="s">pages</span><span class="sh">'</span><span class="p">:</span> <span class="n">pages</span><span class="p">,</span>
		<span class="sh">'</span><span class="s">params</span><span class="sh">'</span><span class="p">:</span> <span class="p">{</span>
			<span class="sh">'</span><span class="s">term</span><span class="sh">'</span><span class="p">:</span> <span class="n">description</span><span class="p">,</span>
			<span class="sh">'</span><span class="s">include_notes</span><span class="sh">'</span><span class="p">:</span> <span class="n">include_notes</span><span class="p">,</span>
			<span class="sh">'</span><span class="s">start_date</span><span class="sh">'</span><span class="p">:</span> <span class="n">start_date</span><span class="p">,</span>
			<span class="sh">'</span><span class="s">end_date</span><span class="sh">'</span><span class="p">:</span> <span class="n">end_date</span><span class="p">,</span>
			<span class="sh">'</span><span class="s">min_pounds</span><span class="sh">'</span><span class="p">:</span> <span class="n">min_pounds</span><span class="p">,</span>
			<span class="sh">'</span><span class="s">min_pence</span><span class="sh">'</span><span class="p">:</span> <span class="n">min_pence</span><span class="p">,</span>
			<span class="sh">'</span><span class="s">max_pounds</span><span class="sh">'</span><span class="p">:</span> <span class="n">max_pounds</span><span class="p">,</span>
			<span class="sh">'</span><span class="s">max_pence</span><span class="sh">'</span><span class="p">:</span> <span class="n">max_pence</span>
		<span class="p">},</span>
		<span class="sh">'</span><span class="s">search_results</span><span class="sh">'</span><span class="p">:</span> <span class="n">result</span>
	<span class="p">}</span>

</code></pre></div></div>

<p>And that’s all there was to adding support for making arbitrary notes on my transactions.</p>

<h2 id="linking-transactions">Linking transactions</h2>

<p>Now I can add notes, I also want to be able to link related transactions together. For example, I transfer our mortgage payment to our joint account and then it’s separately collected from there by the bank each month (so it would be helpful to link those), I pay for my wife’s mobile phone and she transfers the payment to me each month (again, they can be linked) and when I have out of pocket work-related expenses, it would be helpful to link the transaction reimbursing me for them to the amount going out. I might even want to go a step further and link all costs related to a particular holiday, for example (although I might want to be able to add a description to the set of links for that one).</p>

<p>One complication is how this works with my template transactions. A “template transaction”, in my system, is just a boolean “template” flag on the transaction which causes it to appear in a list on a “Add Transaction from Template” page - there’s also a “Clone transaction” option on every transaction’s details page and adding a transaction from template just does a “clone” on the template transaction (and any transaction can be cloned). While I’m quite happy with the idea that links to other transactions get cloned, what I’m wrestling with is whether that’s a shallow or deep copy of the links - i.e. do linked transactions also get duplicated (so the clone is a complete set of new transactions) or do the links to the existing related transactions, but not the linked transactions themselves, get duplicated (so the clone is itself a new transaction but linked to the same, existing, transactions as the old one).</p>

<p>Thinking this through, with the idea of labelling sets of links too, I think I need to create an idea of “link sets” and allow for cloning a link set (which clones the entire set, i.e. does a “deep copy” creating a brand new copy of every transaction in the set) separately from cloning a transaction (which clones the links to the existing transactions, i.e. does a “shallow copy” creating one new transaction and new links within the same link set).</p>

<h3 id="databaseorm-changes-1">Database/ORM changes</h3>

<p>So, in my database I need two new tables:</p>

<ol>
  <li>
    <p><code class="language-plaintext highlighter-rouge">link_sets</code></p>

    <p>This will defines each set of links with a description and whether or not this link set is a template (as with transactions, I am imagining that any link set is able to be cloned, the <code class="language-plaintext highlighter-rouge">template</code> flag is just a convenience that controls whether it appears in template lists). I expect there to be many link sets, for example one for each month of my work expenses that I claim (and get reimbursed for) together, so I also added a <code class="language-plaintext highlighter-rouge">show_in_list</code> flag (I did call it <code class="language-plaintext highlighter-rouge">list</code> but that clashes with the Python builtin function, so I changed the name) to control whether it gets displayed by default in lists to allocate transactions to or has to be searched for.</p>

    <p>Therefore is needs three fields:</p>

    <ol>
      <li><code class="language-plaintext highlighter-rouge">id</code> (primary key)</li>
      <li><code class="language-plaintext highlighter-rouge">description</code> (which may be null)</li>
      <li><code class="language-plaintext highlighter-rouge">template</code> boolean (default <code class="language-plaintext highlighter-rouge">false</code>)</li>
      <li><code class="language-plaintext highlighter-rouge">show_in_list</code> boolean (default <code class="language-plaintext highlighter-rouge">false</code>)</li>
    </ol>
  </li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">transaction_link_sets</code></p>

    <p>This will link transactions that are linked via a link set to the link set definition. The neatness of this approach is that it captures the non-directional nature of these links (i.e. if a transaction appears in the link set, it is linked to all other transactions in that set and there is no need to specify links between individual transactions directly).</p>

    <p>It therefore needs three fields:</p>

    <ol>
      <li><code class="language-plaintext highlighter-rouge">id</code> (primary key - should be unnecessary but is with SQLAlchemy)</li>
      <li><code class="language-plaintext highlighter-rouge">transaction_id</code> (links to the transaction)</li>
      <li><code class="language-plaintext highlighter-rouge">link_set_id</code> (links to the link set)</li>
    </ol>

    <p>Ideally the primary key will be <code class="language-plaintext highlighter-rouge">(transaction_id, link_set_id)</code> to ensure no duplication of any transactions occurs within a single link set, however SQLAlechemy only likes single column primary keys so I have to add an <code class="language-plaintext highlighter-rouge">id</code> column. <a href="https://docs.djangoproject.com/en/6.0/topics/composite-primary-key/">Django does support composite primary keys (since version 5.2)</a> - another reason to rewrite in it…</p>
  </li>
</ol>

<p>I added the new classes to my ORM, in <code class="language-plaintext highlighter-rouge">budget/entities.py</code>:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">TransactionLinkSet</span><span class="p">(</span><span class="n">Base</span><span class="p">):</span>
	<span class="n">__tablename__</span> <span class="o">=</span> <span class="sh">'</span><span class="s">transaction_link_sets</span><span class="sh">'</span>
	
	<span class="nb">id</span> <span class="o">=</span> <span class="n">sa</span><span class="p">.</span><span class="nc">Column</span><span class="p">(</span><span class="n">sa</span><span class="p">.</span><span class="n">Integer</span><span class="p">,</span> <span class="n">primary_key</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
	<span class="n">transaction_id</span> <span class="o">=</span> <span class="n">sa</span><span class="p">.</span><span class="nc">Column</span><span class="p">(</span><span class="n">sa</span><span class="p">.</span><span class="n">Integer</span><span class="p">,</span> <span class="n">sa</span><span class="p">.</span><span class="nc">ForeignKey</span><span class="p">(</span><span class="sh">'</span><span class="s">transactions.id</span><span class="sh">'</span><span class="p">),</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>
	<span class="n">link_set_id</span> <span class="o">=</span> <span class="n">sa</span><span class="p">.</span><span class="nc">Column</span><span class="p">(</span><span class="n">sa</span><span class="p">.</span><span class="n">Integer</span><span class="p">,</span> <span class="n">sa</span><span class="p">.</span><span class="nc">ForeignKey</span><span class="p">(</span><span class="sh">'</span><span class="s">link_sets.id</span><span class="sh">'</span><span class="p">),</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>
	
	<span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">transaction</span><span class="p">,</span> <span class="n">link_set</span><span class="p">):</span>
		<span class="n">self</span><span class="p">.</span><span class="n">transaction</span> <span class="o">=</span> <span class="n">transaction</span>
		<span class="n">self</span><span class="p">.</span><span class="n">link_set</span> <span class="o">=</span> <span class="n">link_set</span>

<span class="c1"># [...]
</span>
<span class="k">class</span> <span class="nc">Transaction</span><span class="p">(</span><span class="n">Base</span><span class="p">):</span>
    <span class="c1"># [...]
</span>    <span class="n">linksets</span> <span class="o">=</span> <span class="n">sao</span><span class="p">.</span><span class="nf">relation</span><span class="p">(</span><span class="n">TransactionLinkSet</span><span class="p">,</span> <span class="n">backref</span><span class="o">=</span><span class="n">sao</span><span class="p">.</span><span class="nf">backref</span><span class="p">(</span><span class="sh">'</span><span class="s">transaction</span><span class="sh">'</span><span class="p">))</span>

<span class="c1"># [...]
</span>
<span class="k">class</span> <span class="nc">LinkSet</span><span class="p">(</span><span class="n">Base</span><span class="p">):</span>
	<span class="n">__tablename__</span> <span class="o">=</span> <span class="sh">'</span><span class="s">link_sets</span><span class="sh">'</span>
	
	<span class="nb">id</span> <span class="o">=</span> <span class="n">sa</span><span class="p">.</span><span class="nc">Column</span><span class="p">(</span><span class="n">sa</span><span class="p">.</span><span class="n">Integer</span><span class="p">,</span> <span class="n">primary_key</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
	<span class="n">description</span> <span class="o">=</span> <span class="n">sa</span><span class="p">.</span><span class="nc">Column</span><span class="p">(</span><span class="n">sa</span><span class="p">.</span><span class="n">Text</span><span class="p">)</span>
	<span class="n">template</span> <span class="o">=</span> <span class="n">sa</span><span class="p">.</span><span class="nc">Column</span><span class="p">(</span><span class="n">sa</span><span class="p">.</span><span class="n">Boolean</span><span class="p">,</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>
	<span class="n">show_in_list</span> <span class="o">=</span> <span class="n">sa</span><span class="p">.</span><span class="nc">Column</span><span class="p">(</span><span class="n">sa</span><span class="p">.</span><span class="n">Boolean</span><span class="p">,</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>
	
	<span class="n">transactions</span> <span class="o">=</span> <span class="n">sao</span><span class="p">.</span><span class="nf">relation</span><span class="p">(</span><span class="n">TransactionLinkSet</span><span class="p">,</span> <span class="n">backref</span><span class="o">=</span><span class="n">sao</span><span class="p">.</span><span class="nf">backref</span><span class="p">(</span><span class="sh">'</span><span class="s">link_set</span><span class="sh">'</span><span class="p">))</span>

	<span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">description</span><span class="p">,</span> <span class="n">template</span><span class="o">=</span><span class="bp">False</span><span class="p">,</span> <span class="n">show_in_list</span><span class="o">=</span><span class="bp">False</span><span class="p">):</span>
		<span class="n">self</span><span class="p">.</span><span class="n">description</span> <span class="o">=</span> <span class="n">description</span>
		<span class="n">self</span><span class="p">.</span><span class="n">template</span> <span class="o">=</span> <span class="n">template</span>
		<span class="n">self</span><span class="p">.</span><span class="n">show_in_list</span> <span class="o">=</span> <span class="n">show_in_list</span>

</code></pre></div></div>

<p>Although I have no migration capability, as <a href="#databaseorm-changes">I described earlier</a>, these are new tables and I do have a <a href="https://docs.sqlalchemy.org/en/20/core/metadata.html#creating-and-dropping-database-tables"><code class="language-plaintext highlighter-rouge">metadata.create_all(engine)</code> call to SQLAlchemy</a> which will create any missing tables (it just will not modify them if they exist) so I did not need to manually edit my database at all.</p>

<h4 id="class-utility-functions">Class utility functions</h4>

<p>For encapsulation and ease of use, I added a few methods for searching for link sets:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">TransactionLinkSet</span><span class="p">(</span><span class="n">Base</span><span class="p">):</span>
<span class="c1"># [...]
</span>	<span class="nd">@classmethod</span>
	<span class="k">def</span> <span class="nf">find_by_transaction_link_set</span><span class="p">(</span><span class="n">cls</span><span class="p">,</span> <span class="n">transaction</span><span class="p">,</span> <span class="n">link_set</span><span class="p">):</span>
		<span class="n">session</span> <span class="o">=</span> <span class="nc">Session</span><span class="p">()</span>
		<span class="n">query</span> <span class="o">=</span> <span class="n">session</span><span class="p">.</span><span class="nf">query</span><span class="p">(</span><span class="n">cls</span><span class="p">).</span><span class="nf">filter</span><span class="p">(</span><span class="n">cls</span><span class="p">.</span><span class="n">transaction</span><span class="o">==</span><span class="n">transaction</span><span class="p">,</span> <span class="n">cls</span><span class="p">.</span><span class="n">link_set</span><span class="o">==</span><span class="n">link_set</span><span class="p">)</span>
		<span class="k">return</span> <span class="n">query</span><span class="p">.</span><span class="nf">scalar</span><span class="p">()</span>

<span class="c1"># [...]
</span>
<span class="k">class</span> <span class="nc">LinkSet</span><span class="p">(</span><span class="n">Base</span><span class="p">):</span>
<span class="c1"># [...]
</span>	<span class="nd">@classmethod</span>
	<span class="k">def</span> <span class="nf">all</span><span class="p">(</span><span class="n">cls</span><span class="p">,</span> <span class="n">only_show_in_list</span><span class="o">=</span><span class="bp">True</span><span class="p">):</span>
		<span class="n">logger</span><span class="p">.</span><span class="nf">debug</span><span class="p">(</span><span class="sh">"</span><span class="s">Listing all link sets, %s those set to not show in lists</span><span class="sh">"</span><span class="p">,</span> <span class="sh">'</span><span class="s">not including</span><span class="sh">'</span> <span class="k">if</span> <span class="n">only_show_in_list</span> <span class="k">else</span> <span class="sh">'</span><span class="s">including</span><span class="sh">'</span><span class="p">)</span>
		<span class="n">session</span> <span class="o">=</span> <span class="nc">Session</span><span class="p">()</span>
		<span class="n">query</span> <span class="o">=</span> <span class="n">session</span><span class="p">.</span><span class="nf">query</span><span class="p">(</span><span class="n">cls</span><span class="p">)</span>
		<span class="k">if</span> <span class="n">only_show_in_list</span><span class="p">:</span>
			<span class="n">query</span> <span class="o">=</span> <span class="n">query</span><span class="p">.</span><span class="nf">filter</span><span class="p">(</span><span class="n">cls</span><span class="p">.</span><span class="n">show_in_list</span> <span class="o">==</span> <span class="bp">True</span><span class="p">)</span>
		<span class="k">return</span> <span class="n">query</span><span class="p">.</span><span class="nf">order_by</span><span class="p">(</span><span class="n">cls</span><span class="p">.</span><span class="n">description</span><span class="p">).</span><span class="nf">all</span><span class="p">()</span>

	<span class="nd">@classmethod</span>
	<span class="k">def</span> <span class="nf">find_by_description</span><span class="p">(</span><span class="n">cls</span><span class="p">,</span> <span class="n">description</span><span class="p">):</span>
		<span class="n">logger</span><span class="p">.</span><span class="nf">debug</span><span class="p">(</span><span class="sh">"</span><span class="s">Find link sets with description </span><span class="sh">'</span><span class="s">%s</span><span class="sh">'"</span><span class="p">,</span> <span class="n">description</span><span class="p">)</span>
		<span class="n">session</span><span class="o">=</span><span class="nc">Session</span><span class="p">()</span>
		<span class="n">query</span> <span class="o">=</span> <span class="n">session</span><span class="p">.</span><span class="nf">query</span><span class="p">(</span><span class="n">cls</span><span class="p">).</span><span class="nf">filter</span><span class="p">(</span><span class="n">cls</span><span class="p">.</span><span class="n">description</span><span class="o">==</span><span class="n">description</span><span class="p">)</span>
		<span class="k">return</span> <span class="n">query</span><span class="p">.</span><span class="nf">scalar</span><span class="p">()</span>

	<span class="nd">@classmethod</span>
	<span class="k">def</span> <span class="nf">search</span><span class="p">(</span><span class="n">cls</span><span class="p">,</span> <span class="n">description</span><span class="p">,</span> <span class="n">add_only_show_in_list</span><span class="o">=</span><span class="bp">False</span><span class="p">):</span>
		<span class="n">logger</span><span class="p">.</span><span class="nf">debug</span><span class="p">(</span><span class="sh">"</span><span class="s">Searching for links sets with </span><span class="sh">'</span><span class="s">%s</span><span class="sh">'</span><span class="s"> in the description, will %s include those set to show in list</span><span class="sh">"</span><span class="p">,</span> <span class="n">description</span><span class="p">,</span> <span class="sh">'</span><span class="s">also</span><span class="sh">'</span> <span class="k">if</span> <span class="n">add_only_show_in_list</span> <span class="k">else</span> <span class="sh">'</span><span class="s">not</span><span class="sh">'</span><span class="p">)</span>

		<span class="n">query</span> <span class="o">=</span> <span class="n">Session</span><span class="p">.</span><span class="nf">query</span><span class="p">(</span><span class="n">cls</span><span class="p">)</span>

		<span class="k">if</span> <span class="n">add_only_show_in_list</span><span class="p">:</span>
			<span class="n">query</span> <span class="o">=</span> <span class="n">query</span><span class="p">.</span><span class="nf">filter</span><span class="p">(</span><span class="nf">or_</span><span class="p">(</span><span class="n">cls</span><span class="p">.</span><span class="n">show_in_list</span> <span class="o">==</span> <span class="bp">True</span><span class="p">,</span> <span class="n">cls</span><span class="p">.</span><span class="n">description</span><span class="p">.</span><span class="nf">ilike</span><span class="p">(</span><span class="sh">'</span><span class="s">%{}%</span><span class="sh">'</span><span class="p">.</span><span class="nf">format</span><span class="p">(</span><span class="n">description</span><span class="p">))))</span>
		<span class="k">else</span><span class="p">:</span>
			<span class="n">query</span> <span class="o">=</span> <span class="n">query</span><span class="p">.</span><span class="nf">filter</span><span class="p">(</span><span class="n">cls</span><span class="p">.</span><span class="n">description</span><span class="p">.</span><span class="nf">ilike</span><span class="p">(</span><span class="sh">'</span><span class="s">%{}%</span><span class="sh">'</span><span class="p">.</span><span class="nf">format</span><span class="p">(</span><span class="n">description</span><span class="p">)))</span>

		<span class="n">query</span> <span class="o">=</span> <span class="n">query</span><span class="p">.</span><span class="nf">order_by</span><span class="p">(</span><span class="n">cls</span><span class="p">.</span><span class="n">description</span><span class="p">)</span>

		<span class="k">return</span> <span class="n">query</span><span class="p">.</span><span class="nf">all</span><span class="p">()</span>
</code></pre></div></div>

<h3 id="displaying-linked-transactions">Displaying linked transactions</h3>

<p>I decided the most logical, from the user interface, way to do this was to have the option to link transactions on the transaction display page.</p>

<p>Firstly, I added a link to the (as yet unwritten) new controller method that will start the link process to the list of options at the bottom of <code class="language-plaintext highlighter-rouge">templates/transaction/view.j2</code>:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;p&gt;</span>
	<span class="nt">&lt;a</span> <span class="na">href=</span><span class="s">"{{ request.script_name }}/transaction/{{ transaction.id }}/add_to_link_set"</span><span class="nt">&gt;</span>Add to link set<span class="nt">&lt;/a&gt;</span>
{%     if not transaction.ratified %}
	<span class="nt">&lt;a</span> <span class="na">href=</span><span class="s">"{{ request.script_name }}/transaction/{{ transaction.id }}/ratify"</span><span class="nt">&gt;</span>Ratify transaction<span class="nt">&lt;/a&gt;</span>
{%     else %}
	<span class="nt">&lt;a</span> <span class="na">href=</span><span class="s">"{{ request.script_name }}/transaction/{{ transaction.id }}/unratify"</span><span class="nt">&gt;</span>Unratify transaction<span class="nt">&lt;/a&gt;</span>
{%     endif %}
	<span class="nt">&lt;a</span> <span class="na">href=</span><span class="s">"{{ request.script_name }}/transaction/create?template={{ transaction.id }}"</span><span class="nt">&gt;</span>Clone transaction<span class="nt">&lt;/a&gt;</span>
<span class="nt">&lt;/p&gt;</span>
</code></pre></div></div>

<h4 id="listing-link-sets">Listing link sets</h4>

<p>After adding the capability to links transactions, I added the display of them to the bottom of my transaction display page (<code class="language-plaintext highlighter-rouge">templates/transaction/view.j2</code>). I also moved (but did not otherwise change) the links for adding to a link set, ratifying and cloning transactions to the top of the page.:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;br</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;table&gt;</span>
	<span class="nt">&lt;caption&gt;</span>Transaction Links<span class="nt">&lt;/caption&gt;</span>
	<span class="nt">&lt;tr&gt;</span>
		<span class="nt">&lt;th&gt;</span>Date<span class="nt">&lt;/th&gt;</span>
		<span class="nt">&lt;th&gt;</span>Description<span class="nt">&lt;/th&gt;</span>
		<span class="nt">&lt;th&gt;</span>Value<span class="nt">&lt;/th&gt;</span>
	<span class="nt">&lt;/tr&gt;</span>
{%     for transaction_link_sets in transaction.link_sets %}
	<span class="nt">&lt;tr&gt;</span>
		<span class="nt">&lt;th</span> <span class="na">colspan=</span><span class="s">"3"</span><span class="nt">&gt;</span>{{ transaction_link_sets.link_set.description }} (<span class="nt">&lt;a</span> <span class="na">href=</span><span class="s">"{{ request.script_name }}/transaction/{{ transaction.id }}/remove_from_link_set/{{ transaction_link_sets.link_set.id }}"</span><span class="nt">&gt;</span>Remove from link set<span class="nt">&lt;/a&gt;</span>)<span class="nt">&lt;/th&gt;</span>
	<span class="nt">&lt;/tr&gt;</span>
{%         for linked_transaction in transaction_link_sets.link_set.transactions | sort(reverse=True, attribute='transaction.date') %}
{%             if linked_transaction.transaction.id != transaction.id %}
	<span class="nt">&lt;tr&gt;</span>
		<span class="nt">&lt;td&gt;</span>{{ linked_transaction.transaction.date }}<span class="nt">&lt;/td&gt;</span>
		<span class="nt">&lt;td&gt;&lt;a</span> <span class="na">href=</span><span class="s">"{{ request.script_name }}/transaction/{{ linked_transaction.transaction.id }}"</span><span class="nt">&gt;</span>{{ linked_transaction.transaction.description }}<span class="nt">&lt;/a&gt;</span>{% if linked_transaction.transaction.notes %}<span class="nt">&lt;div</span> <span class="na">data-tooltip=</span><span class="s">"{{ linked_transaction.transaction.notes }}"</span><span class="nt">&gt;</span><span class="ni">&amp;#x1f5d2;</span><span class="nt">&lt;/div&gt;</span>{% endif %}<span class="nt">&lt;/td&gt;</span>
		{{ money.render_cell(linked_transaction.transaction.value) }}
	<span class="nt">&lt;/tr&gt;</span>
{%             endif %}
{%         endfor %}
{%     endfor %}
<span class="nt">&lt;/table&gt;</span>
</code></pre></div></div>

<h3 id="adding-transactions-to-link-sets">Adding transactions to link sets</h3>

<p>In order to avoid creating duplicate link sets, I set up the “add to link set” form to require searching for a link set before showing the option to add a new one.</p>

<p>The template for adding a transaction to a link set is fairly simple:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code>{% extends 'base.j2' %}
{% import 'macros.j2' as macros %}
{% block title %}Add to link set{% endblock %}
{% block content %}
<span class="nt">&lt;form</span> <span class="na">method=</span><span class="s">"get"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;label</span> <span class="na">for=</span><span class="s">"txtSearch"</span><span class="nt">&gt;</span>Search for more link sets:<span class="nt">&lt;/label&gt;</span> <span class="nt">&lt;input</span> <span class="na">type=</span><span class="s">"text"</span> <span class="na">id=</span><span class="s">"txtSearch"</span> <span class="na">name=</span><span class="s">"search_term"</span> <span class="err">{{</span> <span class="na">macros.value_if</span><span class="err">(</span><span class="na">search_term</span><span class="err">)</span> <span class="err">}}</span> <span class="nt">/&gt;</span>
    <span class="nt">&lt;input</span> <span class="na">type=</span><span class="s">"submit"</span> <span class="na">value=</span><span class="s">"Search"</span><span class="nt">/&gt;</span> <span class="nt">&lt;input</span> <span class="na">type=</span><span class="s">"reset"</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;/form&gt;</span>
<span class="nt">&lt;form</span> <span class="na">method=</span><span class="s">"post"</span> <span class="na">action=</span><span class="s">"{{ request.script_name }}/transaction/{{ transaction.id }}/do_add_to_link_set"</span><span class="nt">&gt;</span>

{%     for link_set in link_sets %}
{%         if transaction.id not in link_set.transactions | map(attribute='transaction_id') %}
	<span class="nt">&lt;p&gt;&lt;input</span> <span class="na">type=</span><span class="s">"checkbox"</span> <span class="na">id=</span><span class="s">"chkLinkSet{{ link_set.id }}"</span> <span class="na">name=</span><span class="s">"link_sets"</span> <span class="na">value=</span><span class="s">"{{ link_set.id }}"</span><span class="nt">/&gt;&lt;label</span> <span class="na">for=</span><span class="s">"chkLinkSet{{ link_set.id }}"</span><span class="nt">&gt;</span>{{ link_set.description }}<span class="nt">&lt;/label&gt;&lt;/p&gt;</span>
{%         endif %}
{%     endfor %}

<span class="nt">&lt;p&gt;&lt;input</span> <span class="na">type=</span><span class="s">"submit"</span> <span class="nt">/&gt;</span> <span class="nt">&lt;input</span> <span class="na">type=</span><span class="s">"reset"</span> <span class="nt">/&gt;&lt;/p&gt;</span>
<span class="nt">&lt;/form&gt;</span>
{%     if search_term %}
<span class="nt">&lt;form</span> <span class="na">method=</span><span class="s">"post"</span> <span class="na">action=</span><span class="s">"{{ request.script_name }}/manage/link_sets/add"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;input</span> <span class="na">type=</span><span class="s">"hidden"</span> <span class="na">name=</span><span class="s">"description"</span> <span class="na">value=</span><span class="s">"{{ search_term }}"</span> <span class="nt">/&gt;</span>
    <span class="nt">&lt;input</span> <span class="na">type=</span><span class="s">"hidden"</span> <span class="na">name=</span><span class="s">"return_url"</span> <span class="na">value=</span><span class="s">"{{ request.script_name }}/transaction/{{ transaction.id }}/add_to_link_set?search_term={{ search_term }}"</span> <span class="nt">/&gt;</span>
    <span class="nt">&lt;input</span> <span class="na">type=</span><span class="s">"submit"</span> <span class="na">value=</span><span class="s">"Add '{{ search_term }}' as new link set"</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;/form&gt;</span>
{%     endif %}
{% endblock %}
</code></pre></div></div>

<p>The controller methods for the form and doing the addition are also straight-forward:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">add_to_link_set</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="n">response</span><span class="p">):</span>
	<span class="n">transaction</span> <span class="o">=</span> <span class="nf">_get_transaction</span><span class="p">(</span><span class="n">request</span><span class="p">)</span>

	<span class="k">if</span> <span class="n">transaction</span> <span class="ow">is</span> <span class="bp">None</span><span class="p">:</span>
		<span class="k">raise</span> <span class="nc">Exception</span><span class="p">(</span><span class="sh">"</span><span class="s">No transaction found.</span><span class="sh">"</span><span class="p">)</span>
	
	<span class="k">if</span> <span class="n">request</span><span class="p">.</span><span class="n">params</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">'</span><span class="s">search_term</span><span class="sh">'</span><span class="p">,</span> <span class="bp">None</span><span class="p">)</span> <span class="ow">and</span> <span class="ow">not</span> <span class="n">request</span><span class="p">.</span><span class="n">params</span><span class="p">[</span><span class="sh">'</span><span class="s">search_term</span><span class="sh">'</span><span class="p">].</span><span class="nf">isspace</span><span class="p">():</span>
		<span class="n">search_term</span> <span class="o">=</span> <span class="n">request</span><span class="p">.</span><span class="n">params</span><span class="p">[</span><span class="sh">'</span><span class="s">search_term</span><span class="sh">'</span><span class="p">]</span>
		<span class="c1"># Search for the extra ones requested but also always include the link sets set to be listed by default
</span>		<span class="n">link_sets</span> <span class="o">=</span> <span class="n">entities</span><span class="p">.</span><span class="n">LinkSet</span><span class="p">.</span><span class="nf">search</span><span class="p">(</span><span class="n">description</span> <span class="o">=</span> <span class="n">search_term</span><span class="p">,</span> <span class="n">add_only_show_in_list</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
	<span class="k">else</span><span class="p">:</span>
		<span class="n">search_term</span> <span class="o">=</span> <span class="bp">None</span>
		<span class="n">link_sets</span> <span class="o">=</span> <span class="n">entities</span><span class="p">.</span><span class="n">LinkSet</span><span class="p">.</span><span class="nf">all</span><span class="p">()</span>
	
	<span class="k">return</span> <span class="p">{</span>
		<span class="sh">'</span><span class="s">transaction</span><span class="sh">'</span><span class="p">:</span> <span class="n">transaction</span><span class="p">,</span>
		<span class="sh">'</span><span class="s">link_sets</span><span class="sh">'</span><span class="p">:</span> <span class="n">link_sets</span><span class="p">,</span>
		<span class="sh">'</span><span class="s">search_term</span><span class="sh">'</span><span class="p">:</span> <span class="n">search_term</span><span class="p">,</span>
	<span class="p">}</span>

<span class="k">def</span> <span class="nf">do_add_to_link_set</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="n">response</span><span class="p">):</span>
	<span class="n">transaction</span> <span class="o">=</span> <span class="nf">_get_transaction</span><span class="p">(</span><span class="n">request</span><span class="p">)</span>

	<span class="k">if</span> <span class="n">transaction</span> <span class="ow">is</span> <span class="bp">None</span><span class="p">:</span>
		<span class="k">raise</span> <span class="nc">Exception</span><span class="p">(</span><span class="sh">"</span><span class="s">No transaction found.</span><span class="sh">"</span><span class="p">)</span>

	<span class="n">added_to_link_sets</span> <span class="o">=</span> <span class="p">[]</span>
	<span class="k">for</span> <span class="n">link_set_id</span> <span class="ow">in</span> <span class="n">request</span><span class="p">.</span><span class="n">params</span><span class="p">.</span><span class="nf">getall</span><span class="p">(</span><span class="sh">'</span><span class="s">link_sets</span><span class="sh">'</span><span class="p">):</span>
		<span class="n">link_set</span> <span class="o">=</span> <span class="n">entities</span><span class="p">.</span><span class="n">LinkSet</span><span class="p">.</span><span class="nf">find</span><span class="p">(</span><span class="n">link_set_id</span><span class="p">)</span>
		<span class="n">logger</span><span class="p">.</span><span class="nf">debug</span><span class="p">(</span><span class="sh">"</span><span class="s">Adding link set id %d (%s) to transaction id %s</span><span class="sh">"</span><span class="p">,</span> <span class="n">link_set</span><span class="p">.</span><span class="nb">id</span><span class="p">,</span> <span class="n">link_set</span><span class="p">.</span><span class="n">description</span><span class="p">,</span> <span class="n">transaction</span><span class="p">.</span><span class="nb">id</span><span class="p">)</span>
		<span class="n">new_link_set_link</span> <span class="o">=</span> <span class="n">entities</span><span class="p">.</span><span class="nc">TransactionLinkSet</span><span class="p">(</span><span class="n">transaction</span><span class="p">,</span> <span class="n">link_set</span><span class="p">)</span>
		<span class="n">new_link_set_link</span><span class="p">.</span><span class="nf">save</span><span class="p">()</span>	
		<span class="n">added_to_link_sets</span><span class="p">.</span><span class="nf">append</span><span class="p">(</span><span class="n">link_set</span><span class="p">)</span>

	<span class="k">return</span> <span class="p">{</span>
		<span class="sh">'</span><span class="s">transaction</span><span class="sh">'</span><span class="p">:</span> <span class="n">transaction</span><span class="p">,</span>
		<span class="sh">'</span><span class="s">added_to_link_sets</span><span class="sh">'</span><span class="p">:</span> <span class="n">added_to_link_sets</span><span class="p">,</span>
	<span class="p">}</span>
</code></pre></div></div>

<p>Finally, the feedback after adding the transactions to some link sets:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code>{% extends 'base.j2' %}
{% block content %}
<span class="nt">&lt;p&gt;</span>
    Transaction '{{ transaction.description }}' ({{ transaction.id }}) added to link sets:
    <span class="nt">&lt;ul&gt;</span>
{%     for link_set in added_to_link_sets %}
        <span class="nt">&lt;li&gt;</span>{{ link_set.description }}<span class="nt">&lt;/li&gt;</span>
{%     endfor %}
    <span class="nt">&lt;/ul&gt;</span>
<span class="nt">&lt;/p&gt;</span>
<span class="nt">&lt;p&gt;</span>
	<span class="nt">&lt;a</span> <span class="na">href=</span><span class="s">"{{ request.script_name }}/transaction/{{ transaction.id }}"</span><span class="nt">&gt;</span>Click here to go back to the transaction details<span class="nt">&lt;/a&gt;&lt;br</span> <span class="nt">/&gt;</span>
	or<span class="nt">&lt;br</span> <span class="nt">/&gt;</span>
	<span class="nt">&lt;a</span> <span class="na">href=</span><span class="s">"{{ request.script_name }}/"</span><span class="nt">&gt;</span>Click here to get back to account list<span class="nt">&lt;/a&gt;&lt;br</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;/p&gt;</span>
{% endblock %}
</code></pre></div></div>

<h3 id="adding-transactions-to-link-sets-at-creation">Adding transactions to link sets at creation</h3>

<p>I added the ability to link to sets set to show by default to the <code class="language-plaintext highlighter-rouge">templates/transaction/create.j2</code>. There is logic for displaying for existing transactions too, as the same form is used to ratify transactions:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code>	<span class="nt">&lt;p&gt;</span>
		<span class="nt">&lt;fieldset&gt;</span>
			<span class="nt">&lt;legend&gt;</span>Link sets:<span class="nt">&lt;/legend&gt;</span>
{%     for link_set in link_sets %}
			<span class="nt">&lt;p&gt;&lt;input</span> <span class="na">type=</span><span class="s">"checkbox"</span> <span class="na">id=</span><span class="s">"chkLinkSet{{ link_set.id }}"</span> <span class="na">name=</span><span class="s">"link_sets"</span> <span class="na">value=</span><span class="s">"{{ link_set.id }}"</span><span class="err">{%</span> <span class="na">if</span> <span class="na">transaction</span> <span class="na">and</span> <span class="na">transaction</span> <span class="na">in</span> <span class="err">(</span> <span class="na">link_set.transactions</span> <span class="err">|</span> <span class="na">map(attribute=</span><span class="s">'transaction'</span><span class="err">)</span> <span class="err">)</span> <span class="err">%}</span> <span class="na">checked=</span><span class="s">"checked"</span><span class="err">{%</span> <span class="na">elif</span> <span class="na">template</span> <span class="na">and</span> <span class="na">template</span> <span class="na">in</span> <span class="err">(</span> <span class="na">link_set.transactions</span> <span class="err">|</span> <span class="na">map(attribute=</span><span class="s">'transaction'</span><span class="err">)</span> <span class="err">)</span> <span class="err">%}</span> <span class="na">checked=</span><span class="s">"checked"</span><span class="err">{%</span> <span class="na">endif</span> <span class="err">%}</span> <span class="nt">/&gt;&lt;label</span> <span class="na">for=</span><span class="s">"chkLinkSet{{ link_set.id }}"</span><span class="nt">&gt;</span>{{ link_set.description }}<span class="nt">&lt;/label&gt;&lt;/p&gt;</span>
{%     endfor %}
{%     if transaction %}
{%         set additional_link_sets_links = transaction.link_sets %}
{%     elif template %}
{%         set additional_link_sets_links = template.link_sets %}
{%     endif %}
{%     for link_set_link in additional_link_sets_links | default([]) %}
{%         if link_set_link.link_set not in link_sets %}
			<span class="nt">&lt;input</span> <span class="na">type=</span><span class="s">"checkbox"</span> <span class="na">id=</span><span class="s">"chkLinkSet{{ link_set_link.link_set.id }}"</span> <span class="na">name=</span><span class="s">"link_sets"</span> <span class="na">value=</span><span class="s">"{{ link_set_link.link_set.id }}"</span> <span class="na">checked=</span><span class="s">"checked"</span> <span class="nt">/&gt;&lt;label</span> <span class="na">for=</span><span class="s">"chkLinkSet{{ link_set_link.link_set.id }}"</span><span class="nt">&gt;</span>{{ link_set_link.link_set.description }}<span class="nt">&lt;/label&gt;</span>
{%         endif %}
{%     endfor %}
			<span class="nt">&lt;p&gt;</span>
				(To link to a link set not displayed here, or create a new link set, create the transaction then use 'add to link set' to search and create all link sets.)
			<span class="nt">&lt;/p&gt;</span>
		<span class="nt">&lt;/fieldset&gt;</span>
	<span class="nt">&lt;/p&gt;</span>
</code></pre></div></div>

<p>The only change to the create controller was to add the default link sets to the data returned (for the view):</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">return</span> <span class="p">{</span>
    <span class="sh">'</span><span class="s">transaction</span><span class="sh">'</span><span class="p">:</span> <span class="nf">_get_transaction</span><span class="p">(</span><span class="n">request</span><span class="p">),</span>
    <span class="sh">'</span><span class="s">template</span><span class="sh">'</span><span class="p">:</span> <span class="n">template</span><span class="p">,</span>
    <span class="sh">'</span><span class="s">saved_postings</span><span class="sh">'</span><span class="p">:</span> <span class="n">entities</span><span class="p">.</span><span class="n">SavedBucketPosting</span><span class="p">.</span><span class="nf">all</span><span class="p">(),</span>
    <span class="sh">'</span><span class="s">link_sets</span><span class="sh">'</span><span class="p">:</span> <span class="n">entities</span><span class="p">.</span><span class="n">LinkSet</span><span class="p">.</span><span class="nf">all</span><span class="p">(),</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The subsequent steps of the transaction creation process, needed to pass through the link sets to be linked to the final step (where the transaction is created or updated). This required a change to the controller (<code class="language-plaintext highlighter-rouge">budget/controller/transaction.py</code>) to pass the links to the templates:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="sh">'</span><span class="s">link_sets</span><span class="sh">'</span> <span class="ow">in</span> <span class="n">request</span><span class="p">.</span><span class="n">params</span><span class="p">:</span>
    <span class="n">data</span><span class="p">[</span><span class="sh">'</span><span class="s">link_set_ids</span><span class="sh">'</span><span class="p">]</span> <span class="o">=</span> <span class="n">request</span><span class="p">.</span><span class="n">params</span><span class="p">.</span><span class="nf">getall</span><span class="p">(</span><span class="sh">'</span><span class="s">link_sets</span><span class="sh">'</span><span class="p">)</span>
</code></pre></div></div>

<p>And a change to the templates (<code class="language-plaintext highlighter-rouge">templates/transaction/allocated_accounts.j2</code> and <code class="language-plaintext highlighter-rouge">templates/transaction/allocated_buckets.j2</code>) to pass the values to the next step (<code class="language-plaintext highlighter-rouge">allocated_accounts.j2</code> uses a macro for the hidden field, but the logic is the same):</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code>{%     for link_set_id in link_set_ids %}
	<span class="nt">&lt;input</span> <span class="na">type=</span><span class="s">"hidden"</span> <span class="na">id=</span><span class="s">"hdnLinkSet{{ link_set_id }}"</span> <span class="na">name=</span><span class="s">"link_sets"</span> <span class="na">value=</span><span class="s">"{{ link_set_id }}"</span> <span class="nt">/&gt;</span>
{%     endfor %}
</code></pre></div></div>

<p>Finally, the logic to set the link sets on a transaction (so it works for adding, or changing them during ratification):</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># XXX Also should be in the DAO/model layer
</span><span class="k">def</span> <span class="nf">_set_transation_link_sets</span><span class="p">(</span><span class="n">transaction</span><span class="p">,</span> <span class="n">link_set_ids</span><span class="p">):</span>
	<span class="n">logger</span><span class="p">.</span><span class="nf">debug</span><span class="p">(</span><span class="sh">"</span><span class="s">Setting transaction link_sets on transaction id %d to %s</span><span class="sh">"</span><span class="p">,</span> <span class="n">transaction</span><span class="p">.</span><span class="nb">id</span><span class="p">,</span> <span class="n">link_set_ids</span><span class="p">)</span>

	<span class="n">logger</span><span class="p">.</span><span class="nf">debug</span><span class="p">(</span><span class="sh">"</span><span class="s">Existing link sets: %s</span><span class="sh">"</span><span class="p">,</span> <span class="n">transaction</span><span class="p">.</span><span class="n">link_sets</span><span class="p">)</span>
	<span class="c1"># Remove link sets that should not be present
</span>	<span class="k">for</span> <span class="n">link_set_link</span> <span class="ow">in</span> <span class="n">transaction</span><span class="p">.</span><span class="n">link_sets</span><span class="p">:</span>
		<span class="k">if</span> <span class="n">link_set_link</span><span class="p">.</span><span class="n">link_set_id</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">link_set_ids</span><span class="p">:</span>
			<span class="n">logger</span><span class="p">.</span><span class="nf">debug</span><span class="p">(</span><span class="sh">"</span><span class="s">Removing link_set %s (%d) from transaction id %d</span><span class="sh">"</span><span class="p">,</span> <span class="n">link_set_link</span><span class="p">.</span><span class="n">link_set</span><span class="p">.</span><span class="n">description</span><span class="p">,</span> <span class="n">link_set_link</span><span class="p">.</span><span class="n">link_set</span><span class="p">.</span><span class="nb">id</span><span class="p">,</span> <span class="n">transaction</span><span class="p">.</span><span class="nb">id</span><span class="p">)</span>
			<span class="n">link_set_link</span><span class="p">.</span><span class="nf">delete</span><span class="p">()</span>

	<span class="n">existing_link_set_ids</span> <span class="o">=</span> <span class="p">[</span><span class="n">link_set_link</span><span class="p">.</span><span class="n">link_set_id</span> <span class="k">for</span> <span class="n">link_set_link</span> <span class="ow">in</span> <span class="n">transaction</span><span class="p">.</span><span class="n">link_sets</span><span class="p">]</span>
	<span class="n">logger</span><span class="p">.</span><span class="nf">debug</span><span class="p">(</span><span class="sh">"</span><span class="s">Existing link set ids: %s</span><span class="sh">"</span><span class="p">,</span> <span class="n">existing_link_set_ids</span><span class="p">)</span>
	<span class="k">for</span> <span class="n">link_set_id</span> <span class="ow">in</span> <span class="n">link_set_ids</span><span class="p">:</span>
		<span class="k">if</span> <span class="n">link_set_id</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">existing_link_set_ids</span><span class="p">:</span>
			<span class="n">link_set_to_add</span> <span class="o">=</span> <span class="n">entities</span><span class="p">.</span><span class="n">LinkSet</span><span class="p">.</span><span class="nf">find</span><span class="p">(</span><span class="n">link_set_id</span><span class="p">)</span>
			<span class="n">logger</span><span class="p">.</span><span class="nf">debug</span><span class="p">(</span><span class="sh">"</span><span class="s">Adding link_set %s (%d) to transaction id %d</span><span class="sh">"</span><span class="p">,</span> <span class="n">link_set_to_add</span><span class="p">.</span><span class="n">description</span><span class="p">,</span> <span class="n">link_set_to_add</span><span class="p">.</span><span class="nb">id</span><span class="p">,</span> <span class="n">transaction</span><span class="p">.</span><span class="nb">id</span><span class="p">)</span>
			<span class="n">new_link_set_link</span> <span class="o">=</span> <span class="n">entities</span><span class="p">.</span><span class="nc">TransactionLinkSet</span><span class="p">(</span><span class="n">transaction</span><span class="p">,</span> <span class="n">link_set_to_add</span><span class="p">)</span>
			<span class="n">new_link_set_link</span><span class="p">.</span><span class="nf">save</span><span class="p">()</span>

<span class="c1"># [...]
</span>
<span class="k">def</span> <span class="nf">do_create</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="n">response</span><span class="p">):</span>
<span class="c1"># [...]
</span>	<span class="k">if</span> <span class="sh">'</span><span class="s">link_sets</span><span class="sh">'</span> <span class="ow">in</span> <span class="n">request</span><span class="p">.</span><span class="n">params</span><span class="p">:</span>
		<span class="nf">_set_transation_link_sets</span><span class="p">(</span><span class="n">transaction</span><span class="p">,</span> <span class="p">[</span><span class="nf">int</span><span class="p">(</span><span class="nb">id</span><span class="p">)</span> <span class="k">for</span> <span class="nb">id</span> <span class="ow">in</span> <span class="n">request</span><span class="p">.</span><span class="n">params</span><span class="p">.</span><span class="nf">getall</span><span class="p">(</span><span class="sh">'</span><span class="s">link_sets</span><span class="sh">'</span><span class="p">)])</span>
</code></pre></div></div>

<p>There was also the process by which I could bulk-add new transactions from template transactions, which also needed a call to the same function to ensure the link sets are also duplicated:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">bulk_add_template</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="n">response</span><span class="p">):</span>
	<span class="n">ids</span> <span class="o">=</span> <span class="n">request</span><span class="p">.</span><span class="n">params</span><span class="p">.</span><span class="nf">getall</span><span class="p">(</span><span class="sh">'</span><span class="s">selected_ids</span><span class="sh">'</span><span class="p">)</span>
	<span class="c1"># [...]
</span>	<span class="k">for</span> <span class="nb">id</span> <span class="ow">in</span> <span class="n">ids</span><span class="p">:</span>
		<span class="n">transaction_date</span> <span class="o">=</span> <span class="n">request</span><span class="p">.</span><span class="n">params</span><span class="p">[</span><span class="sh">'</span><span class="s">transaction_date_%s</span><span class="sh">'</span> <span class="o">%</span> <span class="nb">id</span><span class="p">]</span>
		<span class="n">template_transaction</span> <span class="o">=</span> <span class="n">entities</span><span class="p">.</span><span class="n">Transaction</span><span class="p">.</span><span class="nf">find</span><span class="p">(</span><span class="nb">id</span><span class="p">)</span>
		<span class="c1"># [...]
</span>
		<span class="nf">_set_transation_link_sets</span><span class="p">(</span><span class="n">new_transaction</span><span class="p">,</span> <span class="p">[</span><span class="n">link_set_link</span><span class="p">.</span><span class="n">link_set_id</span> <span class="k">for</span> <span class="n">link_set_link</span> <span class="ow">in</span> <span class="n">template_transaction</span><span class="p">.</span><span class="n">link_sets</span><span class="p">])</span>
</code></pre></div></div>

<h3 id="removing-transactions-from-link-sets">Removing transactions from link sets</h3>

<p>The remove from link set controller was also quite simple:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">remove_from_link_set</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="n">response</span><span class="p">):</span>
	<span class="n">transaction</span> <span class="o">=</span> <span class="nf">_get_transaction</span><span class="p">(</span><span class="n">request</span><span class="p">)</span>

	<span class="k">if</span> <span class="n">transaction</span> <span class="ow">is</span> <span class="bp">None</span><span class="p">:</span>
		<span class="k">raise</span> <span class="nc">Exception</span><span class="p">(</span><span class="sh">"</span><span class="s">No transaction found.</span><span class="sh">"</span><span class="p">)</span>
	
	<span class="n">remove_from_link_set</span> <span class="o">=</span> <span class="n">entities</span><span class="p">.</span><span class="n">LinkSet</span><span class="p">.</span><span class="nf">find</span><span class="p">(</span><span class="n">request</span><span class="p">.</span><span class="n">url_captures</span><span class="p">[</span><span class="sh">'</span><span class="s">link_set_id</span><span class="sh">'</span><span class="p">])</span>

	<span class="k">if</span> <span class="n">remove_from_link_set</span> <span class="ow">is</span> <span class="bp">None</span><span class="p">:</span>
		<span class="k">raise</span> <span class="nc">Exception</span><span class="p">(</span><span class="sh">"</span><span class="s">No link set found.</span><span class="sh">"</span><span class="p">)</span>
	
	<span class="n">entities</span><span class="p">.</span><span class="n">TransactionLinkSet</span><span class="p">.</span><span class="nf">find_by_transaction_link_set</span><span class="p">(</span><span class="n">transaction</span><span class="p">,</span> <span class="n">remove_from_link_set</span><span class="p">).</span><span class="nf">delete</span><span class="p">()</span>
	
	<span class="k">return</span> <span class="p">{</span>
		<span class="sh">'</span><span class="s">transaction</span><span class="sh">'</span><span class="p">:</span> <span class="n">transaction</span><span class="p">,</span>
		<span class="sh">'</span><span class="s">link_set</span><span class="sh">'</span><span class="p">:</span> <span class="n">remove_from_link_set</span><span class="p">,</span>
	<span class="p">}</span>
</code></pre></div></div>

<p>And the feedback template reports what has happened:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code>{% extends 'base.j2' %}
{% block content %}
<span class="nt">&lt;p&gt;</span>
    Transaction '{{ transaction.description }}' ({{ transaction.id }}) removed from link set '{{ link_set.description }}' ({{ link_set.id }}).
<span class="nt">&lt;/p&gt;</span>
<span class="nt">&lt;p&gt;</span>
	<span class="nt">&lt;a</span> <span class="na">href=</span><span class="s">"{{ request.script_name }}/transaction/{{ transaction.id }}"</span><span class="nt">&gt;</span>Click here to go back to the transaction details<span class="nt">&lt;/a&gt;&lt;br</span> <span class="nt">/&gt;</span>
	or<span class="nt">&lt;br</span> <span class="nt">/&gt;</span>
	<span class="nt">&lt;a</span> <span class="na">href=</span><span class="s">"{{ request.script_name }}/"</span><span class="nt">&gt;</span>Click here to get back to account list<span class="nt">&lt;/a&gt;&lt;br</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;/p&gt;</span>
{% endblock %}
</code></pre></div></div>

<h3 id="managing-link-sets">Managing link sets</h3>

<p>Now that I can add link sets and add/remove transactions from them, I need to be able to manage them - rename and change whether they can be used as templates or are shown by default.</p>

<p>I began by adding link sets to the list of things that can be managed in <code class="language-plaintext highlighter-rouge">templates/manage/index.j2</code>:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;li&gt;&lt;a</span> <span class="na">href=</span><span class="s">"{{ request.path }}/link_sets"</span><span class="nt">&gt;</span>Link Sets<span class="nt">&lt;/a&gt;&lt;/li&gt;</span>
</code></pre></div></div>

<h4 id="listing-all-link-sets">Listing all link sets</h4>

<p>The start of the new <code class="language-plaintext highlighter-rouge">budget/controller/manage/link_sets.py</code> controller is just a method that returns all of the link sets (so they can be displayed):</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="n">logging</span>

<span class="kn">import</span> <span class="n">budget.entities</span> <span class="k">as</span> <span class="n">entities</span>

<span class="n">logger</span> <span class="o">=</span> <span class="n">logging</span><span class="p">.</span><span class="nf">getLogger</span><span class="p">(</span><span class="n">__name__</span><span class="p">)</span>

<span class="k">def</span> <span class="nf">list</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="n">response</span><span class="p">):</span>
	<span class="k">return</span> <span class="p">{</span> <span class="sh">'</span><span class="s">link_sets</span><span class="sh">'</span><span class="p">:</span> <span class="n">entities</span><span class="p">.</span><span class="n">LinkSet</span><span class="p">.</span><span class="nf">all</span><span class="p">(</span><span class="n">only_show_in_list</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span> <span class="p">}</span>
</code></pre></div></div>

<p>And the template, <code class="language-plaintext highlighter-rouge">templates/manage/link_sets/list.j2</code>, that shows the list:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code>{% extends 'base.j2' %}
{% import 'macros.j2' as macros %}
{% block title %}Manage link sets{% endblock %}
{% block content %}
<span class="nt">&lt;ul&gt;</span>
{%     for link_set in link_sets %}
    <span class="nt">&lt;li&gt;</span>
        <span class="nt">&lt;a</span> <span class="na">href=</span><span class="s">"{{ request.script_name }}/link_set/{{ link_set.id }}/view"</span><span class="nt">&gt;</span>{{ link_set.description }}<span class="nt">&lt;/a&gt;</span>
        {% if link_set.show_in_list %}D{% endif %}
        {% if link_set.template %}T{% endif %}
        <span class="nt">&lt;a</span> <span class="na">href=</span><span class="s">"{{ request.path }}/{{ link_set.id }}/edit"</span><span class="nt">&gt;</span>edit<span class="nt">&lt;/a&gt;</span>
    <span class="nt">&lt;/li&gt;</span>
{%     endfor %}
<span class="nt">&lt;/ul&gt;</span>
<span class="nt">&lt;p&gt;&lt;a</span> <span class="na">href=</span><span class="s">"{{ request.path }}/add"</span><span class="nt">&gt;</span>Add new<span class="nt">&lt;/a&gt;&lt;/p&gt;</span>
<span class="nt">&lt;p&gt;</span>(D = show in list by default, T = template link set)<span class="nt">&lt;/p&gt;</span>
{% endblock %}
</code></pre></div></div>

<h4 id="viewing-a-link-set">Viewing a link set</h4>

<p>This went into a new link sets controller, although at the moment it is only accessed via the management interface. The only method in this controller is to view a link set, so this is the new <code class="language-plaintext highlighter-rouge">budget/controller/link_set.py</code> in its entirety:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="n">logging</span>

<span class="kn">import</span> <span class="n">budget.entities</span> <span class="k">as</span> <span class="n">entities</span>

<span class="k">def</span> <span class="nf">view</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="n">response</span><span class="p">):</span>
    <span class="n">link_set</span> <span class="o">=</span> <span class="n">entities</span><span class="p">.</span><span class="n">LinkSet</span><span class="p">.</span><span class="nf">find</span><span class="p">(</span><span class="n">request</span><span class="p">.</span><span class="n">url_captures</span><span class="p">[</span><span class="sh">'</span><span class="s">link_set_id</span><span class="sh">'</span><span class="p">])</span>

    <span class="k">return</span> <span class="p">{</span>
        <span class="sh">'</span><span class="s">link_set</span><span class="sh">'</span><span class="p">:</span> <span class="n">link_set</span><span class="p">,</span>
    <span class="p">}</span>
</code></pre></div></div>

<p>The template to display the link set’s transactions, <code class="language-plaintext highlighter-rouge">templates/link_set/view.j2</code>, which links to each transaction’s view page:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code>{% extends 'base.j2' %}
{% import 'macros/money.j2' as money %}
{% block title %}View Link Set{% endblock %}
{% block content %}
<span class="nt">&lt;h1&gt;</span>{{ link_set.description }}<span class="nt">&lt;/h1&gt;</span>
<span class="nt">&lt;table&gt;</span>
        <span class="nt">&lt;caption&gt;</span>Transactions<span class="nt">&lt;/caption&gt;</span>
        <span class="nt">&lt;tr&gt;</span>
                <span class="nt">&lt;th&gt;</span>Date<span class="nt">&lt;/th&gt;</span>
                <span class="nt">&lt;th&gt;</span>Description<span class="nt">&lt;/th&gt;</span>
                <span class="nt">&lt;th&gt;</span>Value<span class="nt">&lt;/th&gt;</span>
        <span class="nt">&lt;/tr&gt;</span>
{%     for linked_transaction in link_set.transactions | sort(reverse=True, attribute='transaction.date') %}
        <span class="nt">&lt;tr&gt;</span>
                <span class="nt">&lt;td&gt;</span>{{ linked_transaction.transaction.date }}<span class="nt">&lt;/td&gt;</span>
                <span class="nt">&lt;td&gt;&lt;a</span> <span class="na">href=</span><span class="s">"{{ request.script_name }}/transaction/{{ linked_transaction.transaction.id }}"</span><span class="nt">&gt;</span>{{ linked_transaction.transaction.description }}<span class="nt">&lt;/a&gt;</span>{% if linked_transaction.transaction.notes %}<span class="nt">&lt;div</span> <span class="na">data-tooltip=</span><span class="s">"{{ linked_transaction.transaction.notes }}"</span><span class="nt">&gt;</span><span class="ni">&amp;#x1f5d2;</span><span class="nt">&lt;/div&gt;</span>{% endif %}<span class="nt">&lt;/td&gt;</span>
                {{ money.render_cell(linked_transaction.transaction.value) }}
        <span class="nt">&lt;/tr&gt;</span>
{%     endfor %}
<span class="nt">&lt;/table&gt;</span>
</code></pre></div></div>

<h4 id="adding-and-editing-link-sets">Adding and editing link sets</h4>

<p>Like with transactions, the same forms and processes are used for adding new link sets and editing existing ones.</p>

<p>The first controller method, in the new <code class="language-plaintext highlighter-rouge">budget/controller/manage/link_set.py</code> controller, retrieves the link set being edited (if any) and sets values from the request for if the add was instigated from the add transaction process:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">add</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="n">response</span><span class="p">):</span>
	<span class="k">if</span> <span class="sh">'</span><span class="s">link_set_id</span><span class="sh">'</span> <span class="ow">in</span> <span class="n">request</span><span class="p">.</span><span class="n">url_captures</span><span class="p">:</span>
		<span class="n">link_set</span> <span class="o">=</span> <span class="n">entities</span><span class="p">.</span><span class="n">LinkSet</span><span class="p">.</span><span class="nf">find</span><span class="p">(</span><span class="n">request</span><span class="p">.</span><span class="n">url_captures</span><span class="p">[</span><span class="sh">'</span><span class="s">link_set_id</span><span class="sh">'</span><span class="p">])</span>
	<span class="k">else</span><span class="p">:</span>
		<span class="n">link_set</span> <span class="o">=</span> <span class="bp">None</span>

	<span class="k">return</span> <span class="p">{</span>
		<span class="sh">'</span><span class="s">link_set</span><span class="sh">'</span><span class="p">:</span> <span class="n">link_set</span><span class="p">,</span>
		<span class="sh">'</span><span class="s">return_url</span><span class="sh">'</span><span class="p">:</span> <span class="n">request</span><span class="p">.</span><span class="n">params</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">'</span><span class="s">return_url</span><span class="sh">'</span><span class="p">,</span> <span class="bp">None</span><span class="p">),</span>
		<span class="sh">'</span><span class="s">description</span><span class="sh">'</span><span class="p">:</span> <span class="n">request</span><span class="p">.</span><span class="n">params</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">'</span><span class="s">description</span><span class="sh">'</span><span class="p">,</span> <span class="bp">None</span><span class="p">),</span>
	<span class="p">}</span>
</code></pre></div></div>

<p>The template for adding/editing just allows setting the description and whether this is a default and/or template link set:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code>{% extends 'base.j2' %}
{% import 'macros.j2' as macros %}
{% block title %}Add new link set{% endblock %}
{% block content %}
<span class="nt">&lt;form</span> <span class="na">method=</span><span class="s">"post"</span> <span class="na">action=</span><span class="s">"{{ request.script_name }}/manage/link_sets/do_add"</span><span class="nt">&gt;</span>
{%     if return_url %}
    <span class="nt">&lt;input</span> <span class="na">type=</span><span class="s">"hidden"</span> <span class="na">id=</span><span class="s">"hdnReturnUrl"</span> <span class="na">name=</span><span class="s">"return_url"</span> <span class="na">value=</span><span class="s">"{{ return_url }}"</span> <span class="nt">/&gt;</span>
{%    endif %}
{%     if link_set %}
    <span class="nt">&lt;input</span> <span class="na">type=</span><span class="s">"hidden"</span> <span class="na">id=</span><span class="s">"hdnLinkeSetId"</span> <span class="na">name=</span><span class="s">"link_set_id"</span> <span class="na">value=</span><span class="s">"{{ link_set.id }}"</span> <span class="nt">/&gt;</span>
{%     endif %}
    <span class="nt">&lt;p&gt;&lt;label</span> <span class="na">for=</span><span class="s">"txtDescription"</span><span class="nt">&gt;</span>Description: <span class="nt">&lt;/label&gt;&lt;input</span> <span class="na">type=</span><span class="s">"text"</span> <span class="na">id=</span><span class="s">"txtDescription"</span> <span class="na">name=</span><span class="s">"description"</span> <span class="err">{{</span> <span class="na">macros.value_if</span><span class="err">(</span><span class="na">link_set</span><span class="err">,</span> <span class="err">'</span><span class="na">description</span><span class="err">')</span> <span class="na">or</span> <span class="na">macros.value_if</span><span class="err">(</span><span class="na">description</span><span class="err">)</span> <span class="err">}}</span> <span class="nt">/&gt;&lt;/p&gt;</span>
    <span class="nt">&lt;p&gt;&lt;input</span> <span class="na">type=</span><span class="s">"checkbox"</span> <span class="na">id=</span><span class="s">"chkShowInlist"</span> <span class="na">name=</span><span class="s">"show_in_list"</span> <span class="err">{%</span> <span class="na">if</span> <span class="na">link_set</span> <span class="na">and</span> <span class="na">link_set.show_in_list</span> <span class="err">%}</span><span class="na">checked=</span><span class="s">"checked"</span> <span class="err">{%</span> <span class="na">endif</span> <span class="err">%}</span><span class="nt">/&gt;&lt;label</span> <span class="na">for=</span><span class="s">"chkShowInlist"</span><span class="nt">&gt;</span>Show in lists by default<span class="nt">&lt;/label&gt;&lt;/p&gt;</span>
    <span class="nt">&lt;p&gt;&lt;input</span> <span class="na">type=</span><span class="s">"checkbox"</span> <span class="na">id=</span><span class="s">"chkTemplate"</span> <span class="na">name=</span><span class="s">"template"</span> <span class="err">{%</span> <span class="na">if</span> <span class="na">link_set</span> <span class="na">and</span> <span class="na">link_set.template</span> <span class="err">%}</span><span class="na">checked=</span><span class="s">"checked"</span> <span class="err">{%</span> <span class="na">endif</span> <span class="err">%}</span><span class="nt">/&gt;&lt;label</span> <span class="na">for=</span><span class="s">"chkTemplate"</span><span class="nt">&gt;</span>Template list set<span class="nt">&lt;/label&gt;&lt;/p&gt;</span>

    <span class="nt">&lt;p&gt;&lt;input</span> <span class="na">type=</span><span class="s">"submit"</span> <span class="nt">/&gt;</span> <span class="nt">&lt;input</span> <span class="na">type=</span><span class="s">"reset"</span> <span class="nt">/&gt;&lt;/p&gt;</span>
<span class="nt">&lt;/form&gt;</span>
{% endblock %}
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">do_add</code> method, in the same controller, adds or updates the link set - with logic to ensure that exact duplicates are not created (but it would still be easy to, for example, create a typo’d version of an existing link set):</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">do_add</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="n">response</span><span class="p">):</span>

	<span class="n">data</span> <span class="o">=</span> <span class="p">{</span>
		<span class="sh">'</span><span class="s">success</span><span class="sh">'</span><span class="p">:</span> <span class="bp">False</span><span class="p">,</span>
		<span class="sh">'</span><span class="s">return_url</span><span class="sh">'</span><span class="p">:</span> <span class="n">request</span><span class="p">.</span><span class="n">params</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">'</span><span class="s">return_url</span><span class="sh">'</span><span class="p">,</span> <span class="bp">None</span><span class="p">),</span>
	<span class="p">}</span>

	<span class="k">if</span> <span class="sh">'</span><span class="s">list_set_id</span><span class="sh">'</span> <span class="ow">in</span> <span class="n">request</span><span class="p">.</span><span class="n">params</span><span class="p">:</span>
		<span class="n">data</span><span class="p">[</span><span class="sh">'</span><span class="s">action</span><span class="sh">'</span><span class="p">]</span> <span class="o">=</span> <span class="sh">'</span><span class="s">edit</span><span class="sh">'</span>
	<span class="k">else</span><span class="p">:</span>
		<span class="n">data</span><span class="p">[</span><span class="sh">'</span><span class="s">action</span><span class="sh">'</span><span class="p">]</span> <span class="o">=</span> <span class="sh">'</span><span class="s">add</span><span class="sh">'</span>
		
	<span class="k">if</span> <span class="n">request</span><span class="p">.</span><span class="n">params</span><span class="p">[</span><span class="sh">'</span><span class="s">description</span><span class="sh">'</span><span class="p">]</span> <span class="o">==</span> <span class="sh">''</span> <span class="ow">or</span> <span class="n">request</span><span class="p">.</span><span class="n">params</span><span class="p">[</span><span class="sh">'</span><span class="s">description</span><span class="sh">'</span><span class="p">].</span><span class="nf">isspace</span><span class="p">():</span>
		<span class="n">data</span><span class="p">[</span><span class="sh">'</span><span class="s">error</span><span class="sh">'</span><span class="p">]</span> <span class="o">=</span> <span class="sh">'</span><span class="s">no description</span><span class="sh">'</span>
	<span class="k">else</span><span class="p">:</span>
		<span class="n">link_set</span> <span class="o">=</span> <span class="bp">None</span>
		<span class="k">if</span> <span class="sh">'</span><span class="s">link_set_id</span><span class="sh">'</span> <span class="ow">in</span> <span class="n">request</span><span class="p">.</span><span class="n">params</span><span class="p">:</span>
			<span class="n">link_set</span> <span class="o">=</span> <span class="n">entities</span><span class="p">.</span><span class="n">LinkSet</span><span class="p">.</span><span class="nf">find</span><span class="p">(</span><span class="n">request</span><span class="p">.</span><span class="n">params</span><span class="p">[</span><span class="sh">'</span><span class="s">link_set_id</span><span class="sh">'</span><span class="p">])</span>
			<span class="n">link_set</span><span class="p">.</span><span class="n">description</span> <span class="o">=</span> <span class="n">request</span><span class="p">.</span><span class="n">params</span><span class="p">[</span><span class="sh">'</span><span class="s">description</span><span class="sh">'</span><span class="p">]</span>
		<span class="k">elif</span> <span class="n">entities</span><span class="p">.</span><span class="n">LinkSet</span><span class="p">.</span><span class="nf">find_by_description</span><span class="p">(</span><span class="n">request</span><span class="p">.</span><span class="n">params</span><span class="p">[</span><span class="sh">'</span><span class="s">description</span><span class="sh">'</span><span class="p">])</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span><span class="p">:</span>
			<span class="n">data</span><span class="p">[</span><span class="sh">'</span><span class="s">error</span><span class="sh">'</span><span class="p">]</span> <span class="o">=</span> <span class="sh">'</span><span class="s">link set with that name already exists</span><span class="sh">'</span>			
		<span class="k">else</span><span class="p">:</span>
			<span class="n">link_set</span> <span class="o">=</span> <span class="n">entities</span><span class="p">.</span><span class="nc">LinkSet</span><span class="p">(</span><span class="n">request</span><span class="p">.</span><span class="n">params</span><span class="p">[</span><span class="sh">'</span><span class="s">description</span><span class="sh">'</span><span class="p">])</span>
		
		<span class="k">if</span> <span class="n">link_set</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span><span class="p">:</span>
			<span class="k">if</span> <span class="n">request</span><span class="p">.</span><span class="n">params</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">'</span><span class="s">show_in_list</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">off</span><span class="sh">'</span><span class="p">)</span> <span class="o">==</span> <span class="sh">'</span><span class="s">on</span><span class="sh">'</span><span class="p">:</span>
				<span class="n">link_set</span><span class="p">.</span><span class="n">show_in_list</span> <span class="o">=</span> <span class="bp">True</span>
			<span class="k">else</span><span class="p">:</span>
				<span class="n">link_set</span><span class="p">.</span><span class="n">show_in_list</span> <span class="o">=</span> <span class="bp">False</span>

			<span class="k">if</span> <span class="n">request</span><span class="p">.</span><span class="n">params</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">'</span><span class="s">template</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">off</span><span class="sh">'</span><span class="p">)</span> <span class="o">==</span> <span class="sh">'</span><span class="s">on</span><span class="sh">'</span><span class="p">:</span>
				<span class="n">link_set</span><span class="p">.</span><span class="n">template</span> <span class="o">=</span> <span class="bp">True</span>
			<span class="k">else</span><span class="p">:</span>
				<span class="n">link_set</span><span class="p">.</span><span class="n">template</span> <span class="o">=</span> <span class="bp">False</span>

			<span class="n">link_set</span><span class="p">.</span><span class="nf">save</span><span class="p">()</span>
			<span class="n">data</span><span class="p">[</span><span class="sh">'</span><span class="s">success</span><span class="sh">'</span><span class="p">]</span> <span class="o">=</span> <span class="bp">True</span>
			<span class="n">data</span><span class="p">[</span><span class="sh">'</span><span class="s">link_set</span><span class="sh">'</span><span class="p">]</span> <span class="o">=</span> <span class="n">link_set</span>

	<span class="k">return</span> <span class="n">data</span>
</code></pre></div></div>

<p>The feedback template just reports if the link set was added or updated, or any errors if not, and links back to the management list or the specified return path (if provided):</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code>{% extends 'base.j2' %}
{% block content %}
{% if success %}
<span class="nt">&lt;p&gt;</span>Link set, {{ link_set.description }}, {{ action }} successful (id: {{ link_set.id }}).<span class="nt">&lt;/p&gt;</span>
{% else %}
<span class="nt">&lt;p&gt;&lt;b&gt;</span>Error:<span class="nt">&lt;/b&gt;</span> Unable to {{ action }} link set{% if link_set is defined %}, {{ link_set.description }} (id: {{ link_set.id }}){% endif %}, {{ error }}!<span class="nt">&lt;/p&gt;</span>
{% endif %}
{% if return_url %}
<span class="nt">&lt;p&gt;&lt;a</span> <span class="na">href=</span><span class="s">"{{ return_url }}"</span><span class="nt">&gt;</span>Click here to return<span class="nt">&lt;/a&gt;&lt;/p&gt;</span>
{% else %}
<span class="nt">&lt;p&gt;&lt;a</span> <span class="na">href=</span><span class="s">"{{ request.script_name }}/manage/link_sets"</span><span class="nt">&gt;</span>Click here to get back to manage link sets list<span class="nt">&lt;/a&gt;&lt;/p&gt;</span>
{% endif %}
{% endblock %}
</code></pre></div></div>

<h3 id="dispatcher-configuration">Dispatcher configuration</h3>

<p>All of these new controllers and views needed hooking up via my budget application’s <code class="language-plaintext highlighter-rouge">config.json</code> (I have only listed the new ones, the actual configuration file has more sections a many more urls, in the <code class="language-plaintext highlighter-rouge">urls</code> section):</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
    </span><span class="nl">"urls"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"/link_set/&lt;int:link_set_id&gt;/view"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"controller"</span><span class="p">:</span><span class="w"> </span><span class="s2">"budget.controller.link_set.view"</span><span class="p">,</span><span class="w"> </span><span class="nl">"view"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"engine"</span><span class="p">:</span><span class="w"> </span><span class="s2">"jinja2"</span><span class="p">,</span><span class="w"> </span><span class="nl">"template"</span><span class="p">:</span><span class="w"> </span><span class="s2">"link_set/view.j2"</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="p">},</span><span class="w">
        </span><span class="nl">"/manage/link_sets"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"controller"</span><span class="p">:</span><span class="w"> </span><span class="s2">"budget.controller.manage.link_sets.list"</span><span class="p">,</span><span class="w"> </span><span class="nl">"view"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"engine"</span><span class="p">:</span><span class="w"> </span><span class="s2">"jinja2"</span><span class="p">,</span><span class="w"> </span><span class="nl">"template"</span><span class="p">:</span><span class="w"> </span><span class="s2">"manage/link_sets/list.j2"</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="p">},</span><span class="w">
        </span><span class="nl">"/manage/link_sets/&lt;int:link_set_id&gt;/edit"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"controller"</span><span class="p">:</span><span class="w"> </span><span class="s2">"budget.controller.manage.link_sets.add"</span><span class="p">,</span><span class="w"> </span><span class="nl">"view"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"engine"</span><span class="p">:</span><span class="w"> </span><span class="s2">"jinja2"</span><span class="p">,</span><span class="w"> </span><span class="nl">"template"</span><span class="p">:</span><span class="w"> </span><span class="s2">"manage/link_sets/add.j2"</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="p">},</span><span class="w">
        </span><span class="nl">"/manage/link_sets/add"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"controller"</span><span class="p">:</span><span class="w"> </span><span class="s2">"budget.controller.manage.link_sets.add"</span><span class="p">,</span><span class="w"> </span><span class="nl">"view"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"engine"</span><span class="p">:</span><span class="w"> </span><span class="s2">"jinja2"</span><span class="p">,</span><span class="w"> </span><span class="nl">"template"</span><span class="p">:</span><span class="w"> </span><span class="s2">"manage/link_sets/add.j2"</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="p">},</span><span class="w">
        </span><span class="nl">"/manage/link_sets/do_add"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"controller"</span><span class="p">:</span><span class="w"> </span><span class="s2">"budget.controller.manage.link_sets.do_add"</span><span class="p">,</span><span class="w"> </span><span class="nl">"view"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"engine"</span><span class="p">:</span><span class="w"> </span><span class="s2">"jinja2"</span><span class="p">,</span><span class="w"> </span><span class="nl">"template"</span><span class="p">:</span><span class="w"> </span><span class="s2">"manage/link_sets/added.j2"</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="p">},</span><span class="w">
        </span><span class="nl">"/transaction/&lt;int:transaction_id&gt;/add_to_link_set"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"controller"</span><span class="p">:</span><span class="w"> </span><span class="s2">"budget.controller.transaction.add_to_link_set"</span><span class="p">,</span><span class="w"> </span><span class="nl">"view"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"engine"</span><span class="p">:</span><span class="w"> </span><span class="s2">"jinja2"</span><span class="p">,</span><span class="w"> </span><span class="nl">"template"</span><span class="p">:</span><span class="w"> </span><span class="s2">"transaction/add_to_link_set.j2"</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="p">},</span><span class="w">
        </span><span class="nl">"/transaction/&lt;int:transaction_id&gt;/remove_from_link_set/&lt;int:link_set_id&gt;"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"controller"</span><span class="p">:</span><span class="w"> </span><span class="s2">"budget.controller.transaction.remove_from_link_set"</span><span class="p">,</span><span class="w"> </span><span class="nl">"view"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"engine"</span><span class="p">:</span><span class="w"> </span><span class="s2">"jinja2"</span><span class="p">,</span><span class="w"> </span><span class="nl">"template"</span><span class="p">:</span><span class="w"> </span><span class="s2">"transaction/remove_from_link_set.j2"</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="p">},</span><span class="w">
        </span><span class="nl">"/transaction/&lt;int:transaction_id&gt;/do_add_to_link_set"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"controller"</span><span class="p">:</span><span class="w"> </span><span class="s2">"budget.controller.transaction.do_add_to_link_set"</span><span class="p">,</span><span class="w"> </span><span class="nl">"view"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"engine"</span><span class="p">:</span><span class="w"> </span><span class="s2">"jinja2"</span><span class="p">,</span><span class="w"> </span><span class="nl">"template"</span><span class="p">:</span><span class="w"> </span><span class="s2">"transaction/added_to_link_set.j2"</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="p">},</span><span class="w">
    </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h3 id="more-work-to-do">More work to do</h3>

<p>The template flag will be used to select which link sets can be cloned, and there is no provision for deleting link sets currently. Having spent over a month on this project so far, due to other pressures meaning I’ve not had time to finish it, I have committed the code for the completed features and am publishing this post at this stage but these pieces still need finishing off.</p>

<h3 id="linking-transactions-epilogue">Linking transactions epilogue</h3>

<p>It occurred to me, somewhere after creating the database tables and before adding the ability to create link sets, that “link sets” could also just be “tags” and the “linked transactions” other transactions with the same tag. Other than the entity names (database tables, class names etc.), the code and logic would be exactly the same.</p>

<p>I also considered whether link sets could replace my buckets concept, by (e.g.) linking all transactions that, at present, go to a bucket. However, these links are at the transaction level and I often split transactions between several buckets - for example, my expenses for mileage and specific out of pocket expenses (e.g. food, travel tickets) are paid together but I split the reimbursement between “car fuel”, “car maintenance” and “out of pocket work expenses” buckets (although individual expenses incurred will usually be allocated in full to one of these). Likewise, if a bucket has insufficient funds to cover a cost I may split a transaction between it and, for example, my savings bucket. Aside: I also allow buckets to go negative, if I know I have enough to cover it in other buckets that won’t need to spend from for a while (e.g. for annual bills) and recoup the overspend over the next few months - something the physical envelope method doesn’t readily allow, although I suppose the same effect could be achieved by “borrowing” money from another physical envelope and leaving an “I owe you” from the overspending envelope in there.</p>]]></content><author><name>Laurence</name></author><category term="notes" /><category term="budgeting" /><category term="chrome" /><category term="css" /><category term="development" /><category term="django" /><category term="github" /><category term="html" /><category term="jinja" /><category term="life" /><category term="perl" /><category term="perl-catalyst" /><category term="python" /><category term="unicode" /><category term="unicodeplus" /><category term="uwsgi" /><category term="web" /><category term="sqlalchemy" /><summary type="html"><![CDATA[I have a budgeting system that I first wrote in Perl using Catalyst in 2009, and last re-wrote in Python using my own web application framework, linawf (which is a recursive nonsense acronym for “Linawf is not a web framework”) and SQLAlchemy in 2012. It implements a digital version of the envelope budgeting method (although I call them “buckets” rather than “envelopes”) where I allocate money from my income to specific purposes, such as car fuel, energy bills, holiday, food, etc. This post is about updating that to enable adding notes to transactions and then linking related transactions.]]></summary></entry><entry><title type="html">Unsealing HashiCorp Vault cluster with a Ansible Playbook</title><link href="https://blog.entek.org.uk/notes/2025/12/29/unsealing-hashicorp-vault-cluster-with-a-ansible-playbook.html" rel="alternate" type="text/html" title="Unsealing HashiCorp Vault cluster with a Ansible Playbook" /><published>2025-12-29T18:23:02+00:00</published><updated>2025-12-29T18:23:02+00:00</updated><id>https://blog.entek.org.uk/notes/2025/12/29/unsealing-hashicorp-vault-cluster-with-a-ansible-playbook</id><content type="html" xml:base="https://blog.entek.org.uk/notes/2025/12/29/unsealing-hashicorp-vault-cluster-with-a-ansible-playbook.html"><![CDATA[<p>One of the downsides with having a clustered <a href="https://www.vaultproject.io/">HashiCorp Vault</a>, like <a href="/notes/2024/06/24/clustering-hashicorp-vault-and-ssl-ansible-role-improvements.html#clustering-vault">I set up last year</a>, is that each host has to be unsealed individually. Doing this from the command line requires specifying the URL of each host to the <code class="language-plaintext highlighter-rouge">vault</code> command, sending each of the 3 keys needed to unseal the vault then moving on to the next one. I made an <a href="https://www.ansible.com/">Ansible</a> playbook to do it with an API call, targeting the hosts from the inventory and reading the keys once as variables to unlock all of the hosts.</p>

<p>The playbook itself is actually really, really simple (so much so, I don’t think it needs any explanation!):</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Unseal the vault on all nodes in HA cluster</span>
  <span class="na">hosts</span><span class="pi">:</span> <span class="s">hashicorp_vault_servers</span>
  <span class="na">vars_prompt</span><span class="pi">:</span>
    <span class="c1"># Assumes 3 keys needed to unseal - this might not be the case...</span>
    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">unseal_key_1</span>
      <span class="na">prompt</span><span class="pi">:</span> <span class="s">Enter unseal key </span><span class="m">1</span>
      <span class="na">private</span><span class="pi">:</span> <span class="kc">true</span>
    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">unseal_key_2</span>
      <span class="na">prompt</span><span class="pi">:</span> <span class="s">Enter unseal key </span><span class="m">2</span>
      <span class="na">private</span><span class="pi">:</span> <span class="kc">true</span>
    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">unseal_key_3</span>
      <span class="na">prompt</span><span class="pi">:</span> <span class="s">Enter unseal key </span><span class="m">3</span>
      <span class="na">private</span><span class="pi">:</span> <span class="kc">true</span>
  <span class="na">tasks</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Enter unseal keys</span>
      <span class="na">delegate_to</span><span class="pi">:</span> <span class="s">localhost</span>
      <span class="na">ansible.builtin.uri</span><span class="pi">:</span>
        <span class="na">body</span><span class="pi">:</span>
          <span class="na">key</span><span class="pi">:</span> <span class="s2">"</span><span class="s">{{</span><span class="nv"> </span><span class="s">item</span><span class="nv"> </span><span class="s">}}"</span>
        <span class="na">body_format</span><span class="pi">:</span> <span class="s">json</span>
        <span class="na">force</span><span class="pi">:</span> <span class="kc">true</span>
        <span class="na">method</span><span class="pi">:</span> <span class="s">PUT</span>
        <span class="na">url</span><span class="pi">:</span> <span class="s2">"</span><span class="s">https://{{</span><span class="nv"> </span><span class="s">ansible_facts.fqdn</span><span class="nv"> </span><span class="s">}}:8200/v1/sys/unseal"</span>
      <span class="na">loop</span><span class="pi">:</span>
       <span class="pi">-</span> <span class="s1">'</span><span class="s">{{</span><span class="nv"> </span><span class="s">unseal_key_1</span><span class="nv"> </span><span class="s">}}'</span>
       <span class="pi">-</span> <span class="s1">'</span><span class="s">{{</span><span class="nv"> </span><span class="s">unseal_key_2</span><span class="nv"> </span><span class="s">}}'</span>
       <span class="pi">-</span> <span class="s1">'</span><span class="s">{{</span><span class="nv"> </span><span class="s">unseal_key_3</span><span class="nv"> </span><span class="s">}}'</span>
      <span class="na">loop_control</span><span class="pi">:</span>
        <span class="na">extended</span><span class="pi">:</span> <span class="kc">true</span>
        <span class="na">label</span><span class="pi">:</span> <span class="s2">"</span><span class="s">key</span><span class="nv"> </span><span class="s">{{</span><span class="nv"> </span><span class="s">ansible_loop.index</span><span class="nv"> </span><span class="s">}}/{{</span><span class="nv"> </span><span class="s">ansible_loop.length</span><span class="nv"> </span><span class="s">}}"</span>

</code></pre></div></div>

<p>Only unlocking a subset of hosts, e.g. if one gets rebooted, can be done by passing <code class="language-plaintext highlighter-rouge">-l</code> to <code class="language-plaintext highlighter-rouge">ansible-playbook</code> to limit the hosts being targetted.</p>

<p>Simple, convenient and so far reliable. I like this playbook and am mildly proud of it!</p>]]></content><author><name>Laurence</name></author><category term="notes" /><category term="api" /><category term="automation" /><category term="ansible" /><category term="devops" /><category term="hashicorp" /><category term="vault" /><summary type="html"><![CDATA[One of the downsides with having a clustered HashiCorp Vault, like I set up last year, is that each host has to be unsealed individually. Doing this from the command line requires specifying the URL of each host to the vault command, sending each of the 3 keys needed to unseal the vault then moving on to the next one. I made an Ansible playbook to do it with an API call, targeting the hosts from the inventory and reading the keys once as variables to unlock all of the hosts.]]></summary></entry><entry><title type="html">Setting up new Debian desktop with rootless container support</title><link href="https://blog.entek.org.uk/notes/2025/12/29/setting-up-new-debian-desktop-with-rootless-container-support.html" rel="alternate" type="text/html" title="Setting up new Debian desktop with rootless container support" /><published>2025-12-29T14:56:39+00:00</published><updated>2025-12-29T14:56:39+00:00</updated><id>https://blog.entek.org.uk/notes/2025/12/29/setting-up-new-debian-desktop-with-rootless-container-support</id><content type="html" xml:base="https://blog.entek.org.uk/notes/2025/12/29/setting-up-new-debian-desktop-with-rootless-container-support.html"><![CDATA[<p>This post is about setting up my “new” desktop, an old <a href="/notes/2023/10/17/resetting-firmware-passwords-on-hp-elitedesk-600-g4.html">HP 600 G4 I’ve had since 2023 but not got around to setting up</a>, as a replacement for a 1st generation P4 desktop that my brother-in-law gave me but draws more power than everything else on my desk combined. This system will inherit the name Galaxy. I already have <a href="/notes/2024/01/12/orchestrating-debian-install-and-automated-post-install-configuration-with-ansible.html">a fully automated process for (re)installing systems</a>, although it needed some tweaks to work for dynamic IP addressed hosts. It was developed for my servers, and although I want to make them more dynamic the current version makes some assumptions about the host being in DNS (which my dynamic hosts are not, currently). Fortunately a lot of the groundwork for this was already done, just not backported to the (re)installation playbooks.</p>

<h2 id="fixing-reinstall-process-for-dynamic-hosts">Fixing reinstall process for dynamic hosts</h2>

<p>The Desktop does not have a static IP, so I had to make some changes to the (re)installation process that has previously only been used for hosts with static IP assignments and DNS entries. Thanks to other work to dynamically connect to hosts there were actually only two changes needed to support this, but I’ll explain the full reinstall process to show why only two little changes were needed…</p>

<p>The reinstall process uses three playbooks that are imported in sequence:</p>

<ol>
  <li>
    <p><code class="language-plaintext highlighter-rouge">reinstall.yaml</code></p>

    <p>This playbook destroys the existing partition table (on all disks with partitions), reboots the system and removes existing keys from the local (as in the host Ansible is running on) user’s SSH <code class="language-plaintext highlighter-rouge">known_hosts</code> file (as these will be invalid when the install has generated new ones). It then imports the <code class="language-plaintext highlighter-rouge">install.yaml</code> playbook as it’s last item.</p>

    <p>Removing the partitions forces the host to fall-through to the next boot method (as disk boot will fail) with the system’s disks in a state that will be seen as uninitialised, and hence available, by the OS installer.</p>

    <p>The only change to this playbook was to add the existing host discovery playbook (which also gets the SUDO password for the <code class="language-plaintext highlighter-rouge">ansible</code> user) that is already used by most other playbooks for finding the address of dynamic hosts with no DNS entry:</p>

    <div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code> <span class="c1"># Get connection information for dynamic hosts</span>
 <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Host connection information is available</span>
   <span class="na">import_playbook</span><span class="pi">:</span> <span class="s">discover-hosts.yaml</span>
</code></pre></div>    </div>

    <p>The reinstall playbook uses the existing credentials to login to the host and prepare it for reinstallation, which are valid (as the old OS is still in place) so this all worked fine as-is.</p>
  </li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">install.yaml</code></p>

    <p>This playbook configures the <code class="language-plaintext highlighter-rouge">auto-install.ipxe</code> configuration file for iPXE and sets up a host-specific symlink using the host’s MAC address, which it checks is specified as a variable (e.g. via the inventory) with an assert at the start, to ensure the host boots from it.</p>

    <p>Next, it configures the DHCP server to temporarily ignore the client ID when handing out leases to the host’s MAC address (so the IP doesn’t keep changing between the EFI PXE/iPXE/installer/installed system) and clears out all but the latest existing lease for the host’s MAC. It then waits until SSH is available on the host’s current IP (indicating the install has completed).</p>

    <p>Once SSH is up, it gets the host’s new SSH keys and puts them into the local (as in the host Ansible is being run on) user’s SSH <code class="language-plaintext highlighter-rouge">known_hosts</code> before removing the host-specific iPXE configuration symlink. It then imports the bootstrap playbook (<code class="language-plaintext highlighter-rouge">bootstrap.yaml</code>, see below) before, finally, removing the DHCP configuration to ignore client ID and the dynamic IP from the known_hosts (which may change after the DHCP configuration is restored).</p>

    <p>The install playbook doesn’t actually login to the host being installed at all (<code class="language-plaintext highlighter-rouge">bootstrap.yaml</code> does), it logs into the DHCP and PXE servers, so no changes were needed at all.</p>
  </li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">bootstrap.yaml</code></p>

    <p>This is the one that does the initial post-install setup on the newly installed host. This one does need to login to the host, so it also needs to include the host discovery playbook. However, the discover playbook uses host facts (specifically, <code class="language-plaintext highlighter-rouge">user_id</code> to determine the username Ansible is logging in as) but at this stage of the (re)installation process it cannot gather facts - <a href="https://www.python.org/">Python</a> has not yet been installed and even if it had, the default login settings will probably use a username that has not yet been created (which happens later in the bootstrap playbook). It uses the fact as the most reliable way to determine the user, as there are so many ways (within and outside of Ansible’s configuration, e.g. via inventory variables, environment variables, SSH client configuration files etc.) the remote user might be set locally.</p>

    <p>To work around this, I added a new variable to the <code class="language-plaintext highlighter-rouge">discover-hosts.yaml</code> playbook (<code class="language-plaintext highlighter-rouge">DISCOVER_SUDO_HOSTS</code>) to allow overriding the set of hosts to lookup SUDO passwords for in that play:</p>

    <div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code> <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">sudo password is set</span>
   <span class="c1"># This requires fact gathering, and it's not guaranteed that we can connect</span>
   <span class="c1"># in some playbook workflows so allow separate skipping of this step with a</span>
   <span class="c1"># different hosts variable.</span>
   <span class="na">hosts</span><span class="pi">:</span> <span class="s2">"</span><span class="s">{{</span><span class="nv"> </span><span class="s">DISCOVER_SUDO_HOSTS</span><span class="nv"> </span><span class="s">|</span><span class="nv"> </span><span class="s">default(DISCOVER_HOSTS)</span><span class="nv"> </span><span class="s">|</span><span class="nv"> </span><span class="s">default('all')</span><span class="nv"> </span><span class="s">}}:!dummy"</span>
</code></pre></div>    </div>

    <p>Then, I just needed to set that variable to “not all” hosts (i.e. no hosts) when the discover playbook is imported in <code class="language-plaintext highlighter-rouge">bootstrap.yaml</code>:</p>

    <div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code> <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Host connection information is available</span>
   <span class="na">import_playbook</span><span class="pi">:</span> <span class="s">discover-hosts.yaml</span>
   <span class="na">vars</span><span class="pi">:</span>
     <span class="c1"># Ansible user won't be setup to discover facts yet, which is required</span>
     <span class="c1"># for sudo password lookup.</span>
     <span class="na">DISCOVER_SUDO_HOSTS</span><span class="pi">:</span> <span class="s1">'</span><span class="s">!all'</span>
</code></pre></div>    </div>
  </li>
</ol>

<h2 id="installing-the-new-host">Installing the new host</h2>

<p>My previous host was setup mostly manually.</p>

<p>I created <code class="language-plaintext highlighter-rouge">host_vars/galaxy.yaml</code>, moved existing settings (which consisted of just the <code class="language-plaintext highlighter-rouge">interfaces</code> that had the one entry with the MAC address of the network interface), added some desktop profiles (copied from my old laptop’s variables), SSH hostkey and temporarily hardcoded the DHCP IP for backup purposes:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">interfaces</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">mac_address</span><span class="pi">:</span> <span class="s">aa:bb:cc:dd:ee:ff</span>  <span class="c1"># Real MAC redacted</span>
<span class="c1"># Copied and pasted from defiant</span>
<span class="na">desktop_profiles</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">administration_tools</span>
  <span class="pi">-</span> <span class="s">audio-visual</span>
  <span class="pi">-</span> <span class="s">base</span>
  <span class="pi">-</span> <span class="s">development</span>
  <span class="pi">-</span> <span class="s">office</span>
  <span class="pi">-</span> <span class="s">remote-desktop-client</span>
  <span class="pi">-</span> <span class="s">virtualisation</span>
  <span class="pi">-</span> <span class="s">linux_desktop_environment_awesome</span>
<span class="na">backuppc_host_settings</span><span class="pi">:</span>
  <span class="c1"># Hardcoded IP until some sort of DNS arrangement sorted out</span>
  <span class="na">ClientNameAlias</span><span class="pi">:</span> <span class="s2">"</span><span class="s">'192.168.20.158'"</span>
</code></pre></div></div>

<p><a href="/notes/2022/11/03/upgrade-tpm-to-2.0-on-dell-xps-13-9370-and-installing-windows-11-and-debian-linux.html#post-install-configuration">My desktop profile was originally developed to be run directly as my “normal” user, on my Dell XPS 13 laptop in 2022.</a>. As a result it, naïvely, used <code class="language-plaintext highlighter-rouge">ansible_facts['env']</code> to lookup up various environment variables (like <code class="language-plaintext highlighter-rouge">HOME</code> and <code class="language-plaintext highlighter-rouge">XDG_CONFIG_HOME</code>). The problem is that this reports the environment variable’s value at the time the facts were gathered - if (as is the case now) the facts were gathered as a different user to the one we are setting up a desktop for, then the values will be incorrect - in the case of these examples, the <code class="language-plaintext highlighter-rouge">HOME</code> and <code class="language-plaintext highlighter-rouge">XDG_CONFIG_HOME</code> of the <code class="language-plaintext highlighter-rouge">ansible</code> user. To get around this, I added a command to find the <code class="language-plaintext highlighter-rouge">XDG_CONFIG_HOME</code> of each desktop user being configured and populate a fact that holds a dictionary mapping user to their directory in my <code class="language-plaintext highlighter-rouge">desktop</code> role’s <code class="language-plaintext highlighter-rouge">tasks/Linux_base.yaml</code> task file:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">XDG config directory is known</span>
  <span class="na">become</span><span class="pi">:</span> <span class="s">yes</span>
  <span class="na">become_user</span><span class="pi">:</span> <span class="s1">'</span><span class="s">{{</span><span class="nv"> </span><span class="s">desktop_user</span><span class="nv"> </span><span class="s">}}'</span>
  <span class="na">ansible.builtin.shell</span><span class="pi">:</span> <span class="s1">'</span><span class="s">[</span><span class="nv"> </span><span class="s">-z</span><span class="nv"> </span><span class="s">"$XDG_CONFIG_HOME"</span><span class="nv"> </span><span class="s">]</span><span class="nv"> </span><span class="s">&amp;&amp;</span><span class="nv"> </span><span class="s">echo</span><span class="nv"> </span><span class="s">$HOME/.config</span><span class="nv"> </span><span class="s">||</span><span class="nv"> </span><span class="s">echo</span><span class="nv"> </span><span class="s">$XDG_CONFIG_HOME'</span>
  <span class="na">register</span><span class="pi">:</span> <span class="s">xdg_config_dir_output</span>
  <span class="na">loop</span><span class="pi">:</span> <span class="s1">'</span><span class="s">{{</span><span class="nv"> </span><span class="s">desktop_users</span><span class="nv"> </span><span class="s">|</span><span class="nv"> </span><span class="s">default([])</span><span class="nv"> </span><span class="s">}}'</span>
  <span class="na">loop_control</span><span class="pi">:</span>
    <span class="na">loop_var</span><span class="pi">:</span> <span class="s">desktop_user</span>
  <span class="na">changed_when</span><span class="pi">:</span> <span class="kc">false</span> <span class="c1"># Read operation</span>
  <span class="na">check_mode</span><span class="pi">:</span> <span class="s">no</span> <span class="c1"># Run even when in check mode</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Dictionary of XDG config directories is initialised</span>
  <span class="na">ansible.builtin.set_fact</span><span class="pi">:</span>
    <span class="na">desktop_xdg_config_directories</span><span class="pi">:</span> <span class="pi">{}</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Dictionary of XDG config directories is populated</span>
  <span class="na">ansible.builtin.set_fact</span><span class="pi">:</span>
    <span class="na">desktop_xdg_config_directories</span><span class="pi">:</span> <span class="pi">&gt;-</span>
      <span class="s">{{</span>
        <span class="s">desktop_xdg_config_directories</span>
        <span class="s">|</span>
        <span class="s">combine({</span>
          <span class="s">desktop_user: xdg_config_dir_output.results | selectattr('desktop_user', 'eq', desktop_user) | map(attribute='stdout') | first</span>
        <span class="s">})</span>
      <span class="s">}}</span>
  <span class="na">loop</span><span class="pi">:</span> <span class="s1">'</span><span class="s">{{</span><span class="nv"> </span><span class="s">desktop_users</span><span class="nv"> </span><span class="s">|</span><span class="nv"> </span><span class="s">default([])</span><span class="nv"> </span><span class="s">}}'</span>
  <span class="na">loop_control</span><span class="pi">:</span>
    <span class="na">loop_var</span><span class="pi">:</span> <span class="s">desktop_user</span>
</code></pre></div></div>

<p>The remaining changes were all typos, a few URLs that had changed for source packages/repositories and using this new fact. The most complete example is pushing out my <a href="https://awesomewm.org/">awesome window manager</a> configuration files in <code class="language-plaintext highlighter-rouge">tasks/linux_desktop_environment_awesome.yaml</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s2">"</span><span class="s">my</span><span class="nv"> </span><span class="s">awesome</span><span class="nv"> </span><span class="s">configuration</span><span class="nv"> </span><span class="s">files</span><span class="nv"> </span><span class="s">are</span><span class="nv"> </span><span class="s">deployed"</span>
  <span class="na">become</span><span class="pi">:</span> <span class="kc">true</span>
  <span class="na">become_user</span><span class="pi">:</span> <span class="s1">'</span><span class="s">{{</span><span class="nv"> </span><span class="s">desktop_user</span><span class="nv"> </span><span class="s">}}'</span>
  <span class="na">ansible.builtin.copy</span><span class="pi">:</span>
    <span class="na">dest</span><span class="pi">:</span> <span class="s2">"</span><span class="s">{{</span><span class="nv"> </span><span class="s">desktop_xdg_config_directories[desktop_user]</span><span class="nv"> </span><span class="s">}}/awesome/"</span>
    <span class="na">src</span><span class="pi">:</span> <span class="s">awesome/config/</span>
    <span class="na">force</span><span class="pi">:</span> <span class="s">yes</span> <span class="c1"># Overwrite existing files with server copies</span>
  <span class="na">tags</span><span class="pi">:</span> <span class="pi">[</span><span class="s1">'</span><span class="s">awesome-rc'</span><span class="pi">]</span>
  <span class="na">loop</span><span class="pi">:</span> <span class="s1">'</span><span class="s">{{</span><span class="nv"> </span><span class="s">desktop_users</span><span class="nv"> </span><span class="s">|</span><span class="nv"> </span><span class="s">default([])</span><span class="nv"> </span><span class="s">}}'</span>
  <span class="na">loop_control</span><span class="pi">:</span>
    <span class="na">loop_var</span><span class="pi">:</span> <span class="s">desktop_user</span>
</code></pre></div></div>

<p>For replacing the lookup of <code class="language-plaintext highlighter-rouge">HOME</code>, I did not have anywhere where <code class="language-plaintext highlighter-rouge">~</code> did not work as an alternative - for example (from <code class="language-plaintext highlighter-rouge">tasks/linux_lightweight_desktop_utils.yaml</code>):</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Set urxvt configuration options</span>
  <span class="na">become</span><span class="pi">:</span> <span class="kc">true</span>
  <span class="na">become_user</span><span class="pi">:</span> <span class="s1">'</span><span class="s">{{</span><span class="nv"> </span><span class="s">desktop_user</span><span class="nv"> </span><span class="s">}}'</span>
  <span class="na">ansible.builtin.copy</span><span class="pi">:</span>
    <span class="na">dest</span><span class="pi">:</span> <span class="s2">"</span><span class="s">~/.Xresources"</span>
    <span class="na">content</span><span class="pi">:</span> <span class="pi">|</span>
      <span class="s">URxvt*font: xft:Terminus (TTF):size=10</span>
      <span class="s">! See salt-home/states/linux/desktop/utils/urxvt for transparancy options</span>
      <span class="s">URxvt*background: black</span>
      <span class="s">URxvt*foreground: white</span>
      <span class="s">URxvt*fading: 40</span>
      <span class="s">URxvt*faceColor: black</span>
      <span class="s">URxvt*scrollstyle: plain</span>
      <span class="s">URxvt*visualBell: True</span>
      <span class="s">URxvt.saveLines: 2000</span>
      <span class="s">URxvt.loginShell: True</span>
    <span class="na">mode</span><span class="pi">:</span> <span class="s">0o400</span>
  <span class="na">loop</span><span class="pi">:</span> <span class="s1">'</span><span class="s">{{</span><span class="nv"> </span><span class="s">desktop_users</span><span class="nv"> </span><span class="s">|</span><span class="nv"> </span><span class="s">default([])</span><span class="nv"> </span><span class="s">}}'</span>
  <span class="na">loop_control</span><span class="pi">:</span>
    <span class="na">loop_var</span><span class="pi">:</span> <span class="s">desktop_user</span>
</code></pre></div></div>

<p>I am aware there’s an inconsistency between the capitalisation of <code class="language-plaintext highlighter-rouge">linux</code> and <code class="language-plaintext highlighter-rouge">Linux</code> (e.g. <code class="language-plaintext highlighter-rouge">linux_desktop_environment_awesome.yaml</code> and <code class="language-plaintext highlighter-rouge">Linux_base.yaml</code>) - the original task files were all lowercase, then I started using (e.g.) <code class="language-plaintext highlighter-rouge">ansible.builtin.include_tasks: '{{ ansible_facts.system }}_base.yaml'</code> to dynamically include <code class="language-plaintext highlighter-rouge">Linux_base.yaml</code> and <code class="language-plaintext highlighter-rouge">Win32NT_base.yaml</code> on Linux and Windows systems respectively and <code class="language-plaintext highlighter-rouge">ansible_facts.system</code> has the first letter capitalised.</p>

<h2 id="containers">Containers</h2>

<p>I have a number of <a href="https://www.docker.com/">Docker</a> containers, which replaced manually building and installing software outside the system’s package manager, particularly for software that updated frequently. My intention was to migrate all of this to <a href="https://podman.io/">Podman</a>, however I encountered some problems <a href="/notes/2021/04/08/finding-scsi-generic-device-names.html">passing through DVD drives</a> (I think it’s related to Podman’s more robust security model) so I ended up with a mixed Podman/Docker setup.</p>

<p>Building containers requires a large <code class="language-plaintext highlighter-rouge">/var</code> partition, so I added it to the list of <code class="language-plaintext highlighter-rouge">logical_volumes</code> overrides in <code class="language-plaintext highlighter-rouge">group_vars/desktops.yaml</code>. As the comment notes, I think I should restructure this variable - as the <code class="language-plaintext highlighter-rouge">/var</code> override is specific to containers (and so really should be in a specific group for machines expecting to be used to build containers) but the <code class="language-plaintext highlighter-rouge">desktops</code> group already has an override to ensure a large <code class="language-plaintext highlighter-rouge">/home</code> filesystem:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">filesystems_lvm_volume_groups</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">vg_{{ inventory_hostname }}</span>
    <span class="na">logical_volumes</span><span class="pi">:</span>
      <span class="c1"># [...]</span>
      <span class="c1"># XXX this is specifically for a large /var/tmp for podman builds - should be applied to that group only?</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">var</span>
        <span class="na">size</span><span class="pi">:</span> <span class="s">30G</span>
</code></pre></div></div>

<p>In order to manage Docker containers, I added <code class="language-plaintext highlighter-rouge">community.docker</code> collection to the <code class="language-plaintext highlighter-rouge">requirements.yaml</code> (<code class="language-plaintext highlighter-rouge">containers.podman</code> was already present):</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">collections</span><span class="pi">:</span>
  <span class="c1"># [...]</span>
  <span class="pi">-</span> <span class="s">community.docker</span>
  <span class="c1"># [...]</span>
</code></pre></div></div>

<h3 id="docker-role">Docker role</h3>

<p><a href="https://github.com/loz-hurst/ansible-role-podman">My existing Podman role is published on GitHub</a> but my new Docker role involves adding repositories, which creates a dependency on one of my other roles for managing Debian repositories that I have not yet published - so for now, it is not published somewhere publicly.</p>

<p>It is, however, very straight-forward.</p>

<h4 id="argument-specs-and-defaults">Argument specs and defaults</h4>

<p>It’s only argument is the mirror to use, as my <a href="/notes/2022/02/15/setting-up-cisco-catalyst-switch-for-home-lab.html">air-gapped lab</a> requires using a local mirror so this needs to be configurable. So, the <code class="language-plaintext highlighter-rouge">meta/argument_specs.yaml</code> is very simple:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">---</span>
<span class="na">argument_specs</span><span class="pi">:</span>
  <span class="na">main</span><span class="pi">:</span>
    <span class="na">short_description</span><span class="pi">:</span> <span class="s">Install and setup Docker</span>
    <span class="na">author</span><span class="pi">:</span> <span class="s">Laurence Alexander Hurst</span>
    <span class="na">options</span><span class="pi">:</span>
      <span class="na">docker_mirror_debian_url_base</span><span class="pi">:</span>
        <span class="na">description</span><span class="pi">:</span> <span class="s">Url to use as the base for Docker Debian repositories</span>
        <span class="na">type</span><span class="pi">:</span> <span class="s">str</span>
        <span class="na">default</span><span class="pi">:</span> <span class="s">https://download.docker.com/linux/debian</span>
<span class="nn">...</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">defaults/main.yaml</code> is trivial:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">---</span>
<span class="na">docker_mirror_debian_url_base</span><span class="pi">:</span> <span class="s">https://download.docker.com/linux/debian</span>
<span class="nn">...</span>
</code></pre></div></div>

<h4 id="dependency---apt-source">Dependency - apt-source</h4>

<p>Configuring the <code class="language-plaintext highlighter-rouge">meta/main.yaml</code> to include my existing Debian repository role (<a href="/notes/2025/12/01/adding-dedicated-os-drives-to-proxmox-cluster.html#repository-format-changes">recently updated to use the newer deb822 format</a>), which will configure the repository including fetching and de-armouring the GPG key for validation, worked like this:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">---</span>
<span class="na">dependencies</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">role</span><span class="pi">:</span> <span class="s">apt-source</span>
    <span class="na">vars</span><span class="pi">:</span>
      <span class="na">name</span><span class="pi">:</span> <span class="s">docker</span>
      <span class="na">uri</span><span class="pi">:</span> <span class="s2">"</span><span class="s">{{</span><span class="nv"> </span><span class="s">docker_mirror_debian_url_base</span><span class="nv"> </span><span class="s">}}"</span>
      <span class="na">gpg_key</span><span class="pi">:</span>
        <span class="na">url</span><span class="pi">:</span> <span class="s2">"</span><span class="s">{{</span><span class="nv"> </span><span class="s">docker_mirror_debian_url_base</span><span class="nv"> </span><span class="s">}}/gpg"</span>
      <span class="na">suite</span><span class="pi">:</span> <span class="s1">'</span><span class="s">{{</span><span class="nv"> </span><span class="s">ansible_facts.distribution_release</span><span class="nv"> </span><span class="s">}}'</span>
      <span class="na">components</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="s">stable</span>
      <span class="na">src</span><span class="pi">:</span>
        <span class="na">no_src</span><span class="pi">:</span> <span class="s">yes</span>
    <span class="na">when</span><span class="pi">:</span> <span class="s">ansible_facts['os_family'] == 'Debian'</span>
<span class="nn">...</span>
</code></pre></div></div>

<h4 id="task-file">Task file</h4>

<p>The <code class="language-plaintext highlighter-rouge">tasks/main.yaml</code> file is very straight-forward, just installing Docker. Unlike Podman, I have not configured it properly for rootless use (as I’m using it for one rootful container that I am having difficulty getting Podman to run, as root or not). This is very lazy of me, really, since it should be straight-forward to port setting up the subuid/subgid from my Podman role:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">---</span>
<span class="c1"># Currently only sets up rootful Docker - see podman role for setting up subuid/subgids for rootless access</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Handlers are flushed (so any new repos are synced)</span>
  <span class="na">meta</span><span class="pi">:</span> <span class="s">flush_handlers</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Docker is installed</span>
  <span class="na">become</span><span class="pi">:</span> <span class="kc">true</span>
  <span class="na">ansible.builtin.package</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">docker-ce</span>
      <span class="pi">-</span> <span class="s">docker-ce-cli</span>
      <span class="pi">-</span> <span class="s">containerd.io</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">containers-storage is installed on Debian</span>
  <span class="na">become</span><span class="pi">:</span> <span class="kc">true</span>
  <span class="na">ansible.builtin.package</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">containers-storage</span>
  <span class="na">when</span><span class="pi">:</span> <span class="s">ansible_facts.distribution == 'Debian'</span>
<span class="nn">...</span>
</code></pre></div></div>

<h3 id="container-building-tasks">Container building tasks</h3>

<p>I created Podman and Docker versions of a tasks file for building images, called <code class="language-plaintext highlighter-rouge">build-image.yaml</code>, that pulls the base image if it is missing (unless told not to) and builds the image if the image is missing, tha base image is pulled or the base image is newer than the existing image. These were originally placed in a role that used the generated containers, but I extracted and generalised them so I could place them in the respective roles. The Podman version I added to my <a href="https://github.com/loz-hurst/ansible-role-podman">existing role on GitHub</a>, the Docker version was added to <a href="#docker-role">the new role I just created</a>.</p>

<p>You’ll see Podman tasks file has a copyright and licence header missing from my Docker version, I will add that when I publish it - it is released under the same licence (GPLv3). I also, but have not reproduced here, updated the <code class="language-plaintext highlighter-rouge">README.md</code> in my Podman role to document the new entry point and variables.</p>

<h4 id="argument-specs-and-defaults-1">Argument specs and defaults</h4>

<p>The <code class="language-plaintext highlighter-rouge">meta/argument_specs.yaml</code> and <code class="language-plaintext highlighter-rouge">defaults/main.yaml</code> files were updated for each with the arguments for these entry points. These are almost identical to give me a nice consistent interface to use regardless of which I need to use, despite (as you will see) the tasks being different:</p>

<h5 id="docker">Docker</h5>

<p>Extra arguments that needed adding to the <code class="language-plaintext highlighter-rouge">defaults</code> file:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">docker_image_build_user</span><span class="pi">:</span> <span class="s">root</span>
<span class="na">docker_image_fetch_base</span><span class="pi">:</span> <span class="kc">true</span>
<span class="na">docker_image_build_args</span><span class="pi">:</span> <span class="pi">[]</span>
</code></pre></div></div>

<p>and the additional section for this new entry-point to <code class="language-plaintext highlighter-rouge">argument_specs.yaml</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">build-image</span><span class="pi">:</span>
  <span class="na">short_description</span><span class="pi">:</span> <span class="s">Build images for use by docker</span>
  <span class="na">author</span><span class="pi">:</span> <span class="s">Laurence Alexander Hurst</span>
  <span class="na">options</span><span class="pi">:</span>
    <span class="na">docker_image_build_user</span><span class="pi">:</span>
      <span class="na">description</span><span class="pi">:</span> <span class="s">User to build container as</span>
      <span class="na">type</span><span class="pi">:</span> <span class="s">str</span>
      <span class="na">default</span><span class="pi">:</span> <span class="s">root</span>
    <span class="na">docker_image_base_image</span><span class="pi">:</span>
      <span class="na">description</span><span class="pi">:</span> <span class="s">Name of the base image for building from</span>
      <span class="na">type</span><span class="pi">:</span> <span class="s">str</span>
      <span class="na">required</span><span class="pi">:</span> <span class="kc">true</span>
    <span class="na">docker_image_fetch_base</span><span class="pi">:</span>
      <span class="na">description</span><span class="pi">:</span> <span class="s">Fetch the base image if it does not exist (set to </span><span class="kc">false</span><span class="s"> if base image should have been previously built locally)</span>
      <span class="na">type</span><span class="pi">:</span> <span class="s">bool</span>
      <span class="na">default</span><span class="pi">:</span> <span class="kc">true</span>
    <span class="na">docker_image_name</span><span class="pi">:</span>
      <span class="na">description</span><span class="pi">:</span> <span class="s">Name of the image to build</span>
      <span class="na">type</span><span class="pi">:</span> <span class="s">str</span>
      <span class="na">required</span><span class="pi">:</span> <span class="kc">true</span>
    <span class="na">docker_image_container_file</span><span class="pi">:</span>
      <span class="na">description</span><span class="pi">:</span> <span class="s">Dockerfile for building the image</span>
      <span class="na">type</span><span class="pi">:</span> <span class="s">str</span>
      <span class="na">required</span><span class="pi">:</span> <span class="kc">true</span>
    <span class="na">docker_image_build_args</span><span class="pi">:</span>
      <span class="na">description</span><span class="pi">:</span> <span class="s">List of build args to pass to the build command with `--build-arg`</span>
      <span class="na">type</span><span class="pi">:</span> <span class="s">list</span>
      <span class="na">elements</span><span class="pi">:</span> <span class="s">dict</span>
      <span class="na">options</span><span class="pi">:</span>
        <span class="na">name</span><span class="pi">:</span>
          <span class="na">description</span><span class="pi">:</span> <span class="s">The argument name</span>
          <span class="na">type</span><span class="pi">:</span> <span class="s">str</span>
          <span class="na">required</span><span class="pi">:</span> <span class="kc">true</span>
        <span class="na">value</span><span class="pi">:</span>
          <span class="na">description</span><span class="pi">:</span> <span class="s">The argument value</span>
          <span class="na">type</span><span class="pi">:</span> <span class="s">str</span>
          <span class="na">required</span><span class="pi">:</span> <span class="kc">true</span>
      <span class="na">default</span><span class="pi">:</span> <span class="pi">[]</span>
</code></pre></div></div>

<h5 id="podman">Podman</h5>

<p>Extra arguments that needed adding to the <code class="language-plaintext highlighter-rouge">defaults</code> file:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">podman_image_fetch_base</span><span class="pi">:</span> <span class="kc">true</span>
<span class="na">podman_image_build_args</span><span class="pi">:</span> <span class="pi">[]</span>
</code></pre></div></div>

<p>and the additional section for this new entry-point to <code class="language-plaintext highlighter-rouge">argument_specs.yaml</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">build-image</span><span class="pi">:</span>
  <span class="na">short_description</span><span class="pi">:</span> <span class="s">Build images for use by Podman</span>
  <span class="na">author</span><span class="pi">:</span> <span class="s">Laurence Alexander Hurst</span>
  <span class="na">options</span><span class="pi">:</span>
    <span class="na">podman_image_build_user</span><span class="pi">:</span>
      <span class="na">description</span><span class="pi">:</span> <span class="s">User to build container as</span>
      <span class="na">type</span><span class="pi">:</span> <span class="s">str</span>
      <span class="na">required</span><span class="pi">:</span> <span class="kc">true</span>
    <span class="na">podman_image_base_image</span><span class="pi">:</span>
      <span class="na">description</span><span class="pi">:</span> <span class="s">Name of the base image for building from</span>
      <span class="na">type</span><span class="pi">:</span> <span class="s">str</span>
      <span class="na">required</span><span class="pi">:</span> <span class="kc">true</span>
    <span class="na">podman_image_fetch_base</span><span class="pi">:</span>
      <span class="na">description</span><span class="pi">:</span> <span class="s">Fetch the base image if it does not exist (set to </span><span class="kc">false</span><span class="s"> if base image should have been previously built locally)</span>
      <span class="na">type</span><span class="pi">:</span> <span class="s">bool</span>
      <span class="na">default</span><span class="pi">:</span> <span class="kc">true</span>
    <span class="na">podman_image_name</span><span class="pi">:</span>
      <span class="na">description</span><span class="pi">:</span> <span class="s">Name of the image to build</span>
      <span class="na">type</span><span class="pi">:</span> <span class="s">str</span>
      <span class="na">required</span><span class="pi">:</span> <span class="kc">true</span>
    <span class="na">podman_image_container_file</span><span class="pi">:</span>
      <span class="na">description</span><span class="pi">:</span> <span class="s">Container for building the image</span>
      <span class="na">type</span><span class="pi">:</span> <span class="s">str</span>
      <span class="na">required</span><span class="pi">:</span> <span class="kc">true</span>
    <span class="na">podman_image_build_args</span><span class="pi">:</span>
      <span class="na">description</span><span class="pi">:</span> <span class="s">List of build args to pass to the build command with `--build-arg`</span>
      <span class="na">type</span><span class="pi">:</span> <span class="s">list</span>
      <span class="na">elements</span><span class="pi">:</span> <span class="s">dict</span>
      <span class="na">options</span><span class="pi">:</span>
        <span class="na">name</span><span class="pi">:</span>
          <span class="na">description</span><span class="pi">:</span> <span class="s">The argument name</span>
          <span class="na">type</span><span class="pi">:</span> <span class="s">str</span>
          <span class="na">required</span><span class="pi">:</span> <span class="kc">true</span>
        <span class="na">value</span><span class="pi">:</span>
          <span class="na">description</span><span class="pi">:</span> <span class="s">The argument value</span>
          <span class="na">type</span><span class="pi">:</span> <span class="s">str</span>
          <span class="na">required</span><span class="pi">:</span> <span class="kc">true</span>
      <span class="na">default</span><span class="pi">:</span> <span class="pi">[]</span>
</code></pre></div></div>

<h4 id="build-imageyaml-tasks-files"><code class="language-plaintext highlighter-rouge">build-image.yaml</code> tasks files</h4>

<p>These were different, as the <a href="https://docs.ansible.com/projects/ansible/latest/collections/community/docker/index.html"><code class="language-plaintext highlighter-rouge">community.docker</code> collection</a> and <a href="https://docs.ansible.com/projects/ansible/latest/collections/containers/podman/index.html"><code class="language-plaintext highlighter-rouge">containers.podman</code> collection</a> have very different interfaces - particularly when it comes to building images.</p>

<p>However, they work the same way:</p>

<ol>
  <li>Check if the base image exists</li>
  <li>If the base image does not exit, fetch it (unless <code class="language-plaintext highlighter-rouge">&lt;docker|podman&gt;_image_fetch_base</code> variable is set to false)</li>
  <li>
    <p>(re)build the image if any of these are true:</p>

    <ul>
      <li>It is missing</li>
      <li>The base image was fetched</li>
      <li>The base image is newer than the existing image</li>
    </ul>
  </li>
</ol>

<h5 id="docker-1">Docker</h5>

<p>The Docker version of <code class="language-plaintext highlighter-rouge">tasks/build-image.yaml</code>, to implement the above logic using my interface (variables):</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">---</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Info is gathered on base image {{ docker_image_base_image }}</span>
  <span class="na">become</span><span class="pi">:</span> <span class="kc">true</span>
  <span class="na">become_user</span><span class="pi">:</span> <span class="s1">'</span><span class="s">{{</span><span class="nv"> </span><span class="s">docker_image_build_user</span><span class="nv"> </span><span class="s">}}'</span>
  <span class="na">community.docker.docker_image_info</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s1">'</span><span class="s">{{</span><span class="nv"> </span><span class="s">docker_image_base_image</span><span class="nv"> </span><span class="s">}}'</span>
  <span class="na">register</span><span class="pi">:</span> <span class="s">docker_image_base_info</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Base image {{ docker_image_base_image }} is fetched if missing</span>
  <span class="na">become</span><span class="pi">:</span> <span class="kc">true</span>
  <span class="na">become_user</span><span class="pi">:</span> <span class="s1">'</span><span class="s">{{</span><span class="nv"> </span><span class="s">docker_image_build_user</span><span class="nv"> </span><span class="s">}}'</span>
  <span class="na">community.docker.docker_image_pull</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s1">'</span><span class="s">{{</span><span class="nv"> </span><span class="s">docker_image_base_image</span><span class="nv"> </span><span class="s">}}'</span>
    <span class="na">pull</span><span class="pi">:</span> <span class="s">always</span>
  <span class="na">when</span><span class="pi">:</span> <span class="pi">&gt;-</span>
    <span class="s">docker_image_fetch_base</span>
    <span class="s">and</span>
    <span class="s">docker_image_base_info.images | length == 0</span>
  <span class="na">register</span><span class="pi">:</span> <span class="s">docker_image_base_image_updated</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Info is gathered on base image {{ docker_image_base_image }} again (in case it was fetched)</span>
  <span class="na">become</span><span class="pi">:</span> <span class="kc">true</span>
  <span class="na">become_user</span><span class="pi">:</span> <span class="s1">'</span><span class="s">{{</span><span class="nv"> </span><span class="s">docker_image_build_user</span><span class="nv"> </span><span class="s">}}'</span>
  <span class="na">community.docker.docker_image_info</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s1">'</span><span class="s">{{</span><span class="nv"> </span><span class="s">docker_image_base_image</span><span class="nv"> </span><span class="s">}}'</span>
  <span class="na">register</span><span class="pi">:</span> <span class="s">docker_image_base_info</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Base image {{ docker_image_base_image }} exists</span>
  <span class="na">ansible.builtin.assert</span><span class="pi">:</span>
    <span class="na">that</span><span class="pi">:</span> <span class="s">docker_image_base_info.images | length &gt; </span><span class="m">0</span>
    <span class="na">fail_msg</span><span class="pi">:</span> <span class="s">Base image must exist to check if we need to build a new one!</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Info is gathered on image {{ docker_image_name }}</span>
  <span class="na">become</span><span class="pi">:</span> <span class="kc">true</span>
  <span class="na">become_user</span><span class="pi">:</span> <span class="s1">'</span><span class="s">{{</span><span class="nv"> </span><span class="s">docker_image_build_user</span><span class="nv"> </span><span class="s">}}'</span>
  <span class="na">community.docker.docker_image_info</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s1">'</span><span class="s">{{</span><span class="nv"> </span><span class="s">docker_image_name</span><span class="nv"> </span><span class="s">}}'</span>
  <span class="na">register</span><span class="pi">:</span> <span class="s">docker_image_info</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Image {{ docker_image_name }} is correct</span>
  <span class="na">block</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Context folder exists</span>
      <span class="na">become</span><span class="pi">:</span> <span class="kc">true</span>
      <span class="na">become_user</span><span class="pi">:</span> <span class="s1">'</span><span class="s">{{</span><span class="nv"> </span><span class="s">docker_image_build_user</span><span class="nv"> </span><span class="s">}}'</span>
      <span class="na">ansible.builtin.tempfile</span><span class="pi">:</span>
        <span class="na">state</span><span class="pi">:</span> <span class="s">directory</span>
      <span class="na">register</span><span class="pi">:</span> <span class="s">build_context_path</span>
    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Dockerfile exists</span>
      <span class="na">become</span><span class="pi">:</span> <span class="kc">true</span>
      <span class="na">become_user</span><span class="pi">:</span> <span class="s1">'</span><span class="s">{{</span><span class="nv"> </span><span class="s">docker_image_build_user</span><span class="nv"> </span><span class="s">}}'</span>
      <span class="na">ansible.builtin.copy</span><span class="pi">:</span>
        <span class="na">dest</span><span class="pi">:</span> <span class="s1">'</span><span class="s">{{</span><span class="nv"> </span><span class="s">build_context_path.path</span><span class="nv"> </span><span class="s">}}/Dockerfile'</span>
        <span class="na">content</span><span class="pi">:</span> <span class="s1">'</span><span class="s">{{</span><span class="nv"> </span><span class="s">docker_image_container_file</span><span class="nv"> </span><span class="s">}}'</span>
        <span class="na">mode</span><span class="pi">:</span> <span class="s1">'</span><span class="s">0600'</span>
    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">New {{ docker_image_name }} image is built</span>
      <span class="na">become</span><span class="pi">:</span> <span class="kc">true</span>
      <span class="na">become_user</span><span class="pi">:</span> <span class="s1">'</span><span class="s">{{</span><span class="nv"> </span><span class="s">docker_image_build_user</span><span class="nv"> </span><span class="s">}}'</span>
      <span class="na">community.docker.docker_image_build</span><span class="pi">:</span>
        <span class="na">name</span><span class="pi">:</span> <span class="s1">'</span><span class="s">{{</span><span class="nv"> </span><span class="s">docker_image_name</span><span class="nv"> </span><span class="s">}}'</span>
        <span class="na">path</span><span class="pi">:</span> <span class="s1">'</span><span class="s">{{</span><span class="nv"> </span><span class="s">build_context_path.path</span><span class="nv"> </span><span class="s">}}'</span>
        <span class="na">rebuild</span><span class="pi">:</span> <span class="s">always</span>
        <span class="na">nocache</span><span class="pi">:</span> <span class="kc">true</span>
        <span class="na">args</span><span class="pi">:</span> <span class="s2">"</span><span class="s">{{</span><span class="nv"> </span><span class="s">dict(</span><span class="nv"> </span><span class="s">docker_image_build_args</span><span class="nv"> </span><span class="s">|</span><span class="nv"> </span><span class="s">map(attribute='name')</span><span class="nv"> </span><span class="s">|</span><span class="nv"> </span><span class="s">zip(</span><span class="nv"> </span><span class="s">docker_image_build_args</span><span class="nv"> </span><span class="s">|</span><span class="nv"> </span><span class="s">map(attribute='value')</span><span class="nv"> </span><span class="s">)</span><span class="nv"> </span><span class="s">)</span><span class="nv"> </span><span class="s">}}"</span>
      <span class="na">register</span><span class="pi">:</span> <span class="s">docker_image_new_image_built</span>
  <span class="na">always</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Build context is absent</span>
      <span class="na">become</span><span class="pi">:</span> <span class="kc">true</span>
      <span class="na">become_user</span><span class="pi">:</span> <span class="s1">'</span><span class="s">{{</span><span class="nv"> </span><span class="s">docker_image_build_user</span><span class="nv"> </span><span class="s">}}'</span>
      <span class="na">ansible.builtin.file</span><span class="pi">:</span>
        <span class="na">path</span><span class="pi">:</span> <span class="s1">'</span><span class="s">{{</span><span class="nv"> </span><span class="s">build_context_path.path</span><span class="nv"> </span><span class="s">}}'</span>
        <span class="na">state</span><span class="pi">:</span> <span class="s">absent</span>
      <span class="na">when</span><span class="pi">:</span> <span class="s">build_context_path is defined</span>
  <span class="c1"># In some edge cases (e.g. changing the base image), the base might be</span>
  <span class="c1"># changed but older then the previously built custom image.</span>
  <span class="na">when</span><span class="pi">:</span> <span class="pi">&gt;-</span>
    <span class="s">docker_image_base_image_updated.changed | default(False)</span>
    <span class="s">or</span>
    <span class="s">docker_image_info.images | length == 0</span>
    <span class="s">or</span>
    <span class="s">base_created_short | ansible.builtin.to_datetime(iso8601format) &gt; image_created_short | ansible.builtin.to_datetime(iso8601format)</span>
  <span class="na">vars</span><span class="pi">:</span>
    <span class="c1"># Taken from the example at:</span>
    <span class="c1"># https://docs.ansible.com/ansible/latest/collections/ansible/builtin/to_datetime_filter.html</span>
    <span class="na">iso8601format</span><span class="pi">:</span> <span class="s1">'</span><span class="s">%Y-%m-%dT%H:%M:%S.%fZ'</span>
    <span class="c1"># shorten to microseconds, make sure there's a fractional</span>
    <span class="c1"># component to match format string.</span>
    <span class="na">base_created_short</span><span class="pi">:</span> <span class="pi">&gt;-</span>
      <span class="s">{{</span>
        <span class="s">docker_image_base_info.images[0].Created</span>
        <span class="s">| regex_replace("([^.]+)(\.\d{6})(\d*)(.+)", "\1\2\4")</span>
        <span class="s">| regex_replace("(:[0-9]+)Z$", "\1.0Z")</span>
      <span class="s">}}</span>
    <span class="na">image_created_short</span><span class="pi">:</span> <span class="pi">&gt;-</span>
      <span class="s">{{</span>
        <span class="s">docker_image_info.images[0].Created</span>
        <span class="s">| regex_replace("([^.]+)(\.\d{6})(\d*)(.+)", "\1\2\4")</span>
        <span class="s">| regex_replace("(:[0-9]+)Z$", "\1.0Z")</span>
      <span class="s">}}</span>
<span class="s">...</span>
</code></pre></div></div>

<h5 id="podman-1">Podman</h5>

<p>The Podman version of <code class="language-plaintext highlighter-rouge">tasks/build-image.yaml</code>, to implement the above logic using my interface (variables):</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">---</span>
<span class="c1"># Copyright 2025 Laurence Alexander Hurst</span>
<span class="c1">#</span>
<span class="c1"># This program is free software: you can redistribute it and/or modify</span>
<span class="c1"># it under the terms of the GNU General Public License as published by</span>
<span class="c1"># the Free Software Foundation, either version 3 of the License, or</span>
<span class="c1"># (at your option) any later version.</span>
<span class="c1">#</span>
<span class="c1"># This program is distributed in the hope that it will be useful,</span>
<span class="c1"># but WITHOUT ANY WARRANTY; without even the implied warranty of</span>
<span class="c1"># MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the</span>
<span class="c1"># GNU General Public License for more details.</span>
<span class="c1">#</span>
<span class="c1"># You should have received a copy of the GNU General Public License</span>
<span class="c1"># along with this program.  If not, see &lt;https://www.gnu.org/licenses/&gt;.</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Info is gathered on base image {{ podman_image_base_image }}</span>
  <span class="na">become</span><span class="pi">:</span> <span class="kc">true</span>
  <span class="na">become_user</span><span class="pi">:</span> <span class="s1">'</span><span class="s">{{</span><span class="nv"> </span><span class="s">podman_image_build_user</span><span class="nv"> </span><span class="s">}}'</span>
  <span class="na">containers.podman.podman_image_info</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s1">'</span><span class="s">{{</span><span class="nv"> </span><span class="s">podman_image_base_image</span><span class="nv"> </span><span class="s">}}'</span>
  <span class="na">register</span><span class="pi">:</span> <span class="s">podman_image_base_info</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Base image {{ podman_image_base_image }} is fetched if missing</span>
  <span class="na">become</span><span class="pi">:</span> <span class="kc">true</span>
  <span class="na">become_user</span><span class="pi">:</span> <span class="s1">'</span><span class="s">{{</span><span class="nv"> </span><span class="s">podman_image_build_user</span><span class="nv"> </span><span class="s">}}'</span>
  <span class="na">containers.podman.podman_image</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s1">'</span><span class="s">{{</span><span class="nv"> </span><span class="s">podman_image_base_image</span><span class="nv"> </span><span class="s">}}'</span>
    <span class="na">force</span><span class="pi">:</span> <span class="kc">true</span>
  <span class="na">when</span><span class="pi">:</span> <span class="pi">&gt;-</span>
    <span class="s">podman_image_fetch_base</span>
    <span class="s">and</span>
    <span class="s">podman_image_base_info.images | length == 0</span>
  <span class="na">register</span><span class="pi">:</span> <span class="s">podman_image_base_image_updated</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Info is gathered on base image {{ podman_image_base_image }} again (in case it was fetched)</span>
  <span class="na">become</span><span class="pi">:</span> <span class="kc">true</span>
  <span class="na">become_user</span><span class="pi">:</span> <span class="s1">'</span><span class="s">{{</span><span class="nv"> </span><span class="s">podman_image_build_user</span><span class="nv"> </span><span class="s">}}'</span>
  <span class="na">containers.podman.podman_image_info</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s1">'</span><span class="s">{{</span><span class="nv"> </span><span class="s">podman_image_base_image</span><span class="nv"> </span><span class="s">}}'</span>
  <span class="na">register</span><span class="pi">:</span> <span class="s">podman_image_base_info</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Base image {{ podman_image_base_image }} exists</span>
  <span class="na">ansible.builtin.assert</span><span class="pi">:</span>
    <span class="na">that</span><span class="pi">:</span> <span class="s">podman_image_base_info.images | length &gt; </span><span class="m">0</span>
    <span class="na">fail_msg</span><span class="pi">:</span> <span class="s">Base image must exist to check if we need to build a new one!</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Info is gathered on image {{ podman_image_name }}</span>
  <span class="na">become</span><span class="pi">:</span> <span class="kc">true</span>
  <span class="na">become_user</span><span class="pi">:</span> <span class="s1">'</span><span class="s">{{</span><span class="nv"> </span><span class="s">podman_image_build_user</span><span class="nv"> </span><span class="s">}}'</span>
  <span class="na">containers.podman.podman_image_info</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s1">'</span><span class="s">{{</span><span class="nv"> </span><span class="s">podman_image_name</span><span class="nv"> </span><span class="s">}}'</span>
  <span class="na">register</span><span class="pi">:</span> <span class="s">podman_image_info</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Image {{ podman_image_name }} is correct</span>
  <span class="na">block</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Context folder exists</span>
      <span class="na">become</span><span class="pi">:</span> <span class="kc">true</span>
      <span class="na">become_user</span><span class="pi">:</span> <span class="s1">'</span><span class="s">{{</span><span class="nv"> </span><span class="s">podman_image_build_user</span><span class="nv"> </span><span class="s">}}'</span>
      <span class="na">ansible.builtin.tempfile</span><span class="pi">:</span>
        <span class="na">state</span><span class="pi">:</span> <span class="s">directory</span>
      <span class="na">register</span><span class="pi">:</span> <span class="s">build_context_path</span>
    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">New {{ podman_image_name }} image is built</span>
      <span class="na">become</span><span class="pi">:</span> <span class="kc">true</span>
      <span class="na">become_user</span><span class="pi">:</span> <span class="s1">'</span><span class="s">{{</span><span class="nv"> </span><span class="s">podman_image_build_user</span><span class="nv"> </span><span class="s">}}'</span>
      <span class="na">containers.podman.podman_image</span><span class="pi">:</span>
        <span class="na">name</span><span class="pi">:</span> <span class="s1">'</span><span class="s">{{</span><span class="nv"> </span><span class="s">podman_image_name</span><span class="nv"> </span><span class="s">}}'</span>
        <span class="na">path</span><span class="pi">:</span> <span class="s1">'</span><span class="s">{{</span><span class="nv"> </span><span class="s">build_context_path.path</span><span class="nv"> </span><span class="s">}}'</span>
        <span class="na">state</span><span class="pi">:</span> <span class="s">build</span>
        <span class="na">force</span><span class="pi">:</span> <span class="kc">true</span>
        <span class="na">build</span><span class="pi">:</span>
          <span class="na">cache</span><span class="pi">:</span> <span class="kc">false</span>
          <span class="na">force_rm</span><span class="pi">:</span> <span class="kc">true</span>
          <span class="na">container_file</span><span class="pi">:</span> <span class="s1">'</span><span class="s">{{</span><span class="nv"> </span><span class="s">podman_image_container_file</span><span class="nv"> </span><span class="s">}}'</span>
          <span class="na">extra_args</span><span class="pi">:</span> <span class="s2">"</span><span class="s">{%</span><span class="nv"> </span><span class="s">for</span><span class="nv"> </span><span class="s">item</span><span class="nv"> </span><span class="s">in</span><span class="nv"> </span><span class="s">podman_image_build_args</span><span class="nv"> </span><span class="s">%}</span><span class="se">\
</span>            <span class="s">--build-arg</span><span class="nv"> </span><span class="se">\
</span>            <span class="s">{{</span><span class="nv"> </span><span class="s">item.name</span><span class="nv"> </span><span class="s">|</span><span class="nv"> </span><span class="s">ansible.builtin.quote</span><span class="nv"> </span><span class="s">}}</span><span class="se">\
</span>            <span class="s">=</span><span class="se">\
</span>            <span class="s">{{</span><span class="nv"> </span><span class="s">item.value</span><span class="nv"> </span><span class="s">|</span><span class="nv"> </span><span class="s">ansible.builtin.quote</span><span class="nv"> </span><span class="s">}}</span><span class="se">\
</span>            <span class="s">{%</span><span class="nv"> </span><span class="s">if</span><span class="nv"> </span><span class="s">not</span><span class="nv"> </span><span class="s">loop.last</span><span class="nv"> </span><span class="s">%}</span><span class="nv"> </span><span class="s">{%</span><span class="nv"> </span><span class="s">endif</span><span class="nv"> </span><span class="s">%}</span><span class="se">\
</span>            <span class="s">{%</span><span class="nv"> </span><span class="s">endfor</span><span class="nv"> </span><span class="s">%}"</span>
      <span class="na">register</span><span class="pi">:</span> <span class="s">podman_image_new_image_built</span>
  <span class="na">always</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Build context is absent</span>
      <span class="na">become</span><span class="pi">:</span> <span class="kc">true</span>
      <span class="na">become_user</span><span class="pi">:</span> <span class="s1">'</span><span class="s">{{</span><span class="nv"> </span><span class="s">podman_image_build_user</span><span class="nv"> </span><span class="s">}}'</span>
      <span class="na">ansible.builtin.file</span><span class="pi">:</span>
        <span class="na">path</span><span class="pi">:</span> <span class="s1">'</span><span class="s">{{</span><span class="nv"> </span><span class="s">build_context_path.path</span><span class="nv"> </span><span class="s">}}'</span>
        <span class="na">state</span><span class="pi">:</span> <span class="s">absent</span>
      <span class="na">when</span><span class="pi">:</span> <span class="s">build_context_path is defined</span>
  <span class="c1"># In some edge cases (e.g. changing the base image), the base might be</span>
  <span class="c1"># changed but older then the previously built custom image.</span>
  <span class="na">when</span><span class="pi">:</span> <span class="pi">&gt;-</span>
    <span class="s">podman_image_base_image_updated.changed | default(False)</span>
    <span class="s">or</span>
    <span class="s">podman_image_info.images | length == 0</span>
    <span class="s">or</span>
    <span class="s">base_created_short | ansible.builtin.to_datetime(iso8601format) &gt; image_created_short | ansible.builtin.to_datetime(iso8601format)</span>
  <span class="na">vars</span><span class="pi">:</span>
    <span class="c1"># Taken from the example at:</span>
    <span class="c1"># https://docs.ansible.com/ansible/latest/collections/ansible/builtin/to_datetime_filter.html</span>
    <span class="na">iso8601format</span><span class="pi">:</span> <span class="s1">'</span><span class="s">%Y-%m-%dT%H:%M:%S.%fZ'</span>
    <span class="c1"># shorten to microseconds, make sure there's a fractional</span>
    <span class="c1"># component to match format string.</span>
    <span class="na">base_created_short</span><span class="pi">:</span> <span class="pi">&gt;-</span>
      <span class="s">{{</span>
        <span class="s">podman_image_base_info.images[0].Created</span>
        <span class="s">| regex_replace("([^.]+)(\.\d{6})(\d*)(.+)", "\1\2\4")</span>
        <span class="s">| regex_replace("(:[0-9]+)Z$", "\1.0Z")</span>
      <span class="s">}}</span>
    <span class="na">image_created_short</span><span class="pi">:</span> <span class="pi">&gt;-</span>
      <span class="s">{{</span>
        <span class="s">podman_image_info.images[0].Created</span>
        <span class="s">| regex_replace("([^.]+)(\.\d{6})(\d*)(.+)", "\1\2\4")</span>
        <span class="s">| regex_replace("(:[0-9]+)Z$", "\1.0Z")</span>
      <span class="s">}}</span>
<span class="s">...</span>
</code></pre></div></div>]]></content><author><name>Laurence</name></author><category term="notes" /><category term="automation" /><category term="ansible" /><category term="containers" /><category term="debian" /><category term="dell" /><category term="desktop" /><category term="docker" /><category term="github" /><category term="hardware" /><category term="hp-elitedesk-600-g4" /><category term="ipxe" /><category term="laptop" /><category term="podman" /><category term="proxmox" /><category term="pxe" /><category term="python" /><category term="sudo" /><category term="xps-13-9370" /><summary type="html"><![CDATA[This post is about setting up my “new” desktop, an old HP 600 G4 I’ve had since 2023 but not got around to setting up, as a replacement for a 1st generation P4 desktop that my brother-in-law gave me but draws more power than everything else on my desk combined. This system will inherit the name Galaxy. I already have a fully automated process for (re)installing systems, although it needed some tweaks to work for dynamic IP addressed hosts. It was developed for my servers, and although I want to make them more dynamic the current version makes some assumptions about the host being in DNS (which my dynamic hosts are not, currently). Fortunately a lot of the groundwork for this was already done, just not backported to the (re)installation playbooks.]]></summary></entry><entry><title type="html">Computing Insight United Kingdom (CIUK) 2025</title><link href="https://blog.entek.org.uk/notes/2025/12/02/computing-insight-united-kingdom-ciuk-2025.html" rel="alternate" type="text/html" title="Computing Insight United Kingdom (CIUK) 2025" /><published>2025-12-02T15:49:20+00:00</published><updated>2025-12-02T15:49:20+00:00</updated><id>https://blog.entek.org.uk/notes/2025/12/02/computing-insight-united-kingdom-ciuk-2025</id><content type="html" xml:base="https://blog.entek.org.uk/notes/2025/12/02/computing-insight-united-kingdom-ciuk-2025.html"><![CDATA[<p><a href="https://www.sc.stfc.ac.uk/computing-insight-uk/">CIUK</a> <a href="https://www.sc.stfc.ac.uk/ciuk-2025/">2025</a> is now upon us, beginning with day-zero tomorrow. I cannot remember exactly when I first went to the Machine Evaluation Workshop (MEW), as this <a href="https://en.wikipedia.org/wiki/High-performance_computing">High Performance Computing</a> event was formerly known from 1990 to 2015, but I know I was there when the name changed. Although I cannot remember when, I clearly remember the first time I walked out of <a href="https://www.nationalrail.co.uk/stations/liverpool-lime-street/">Liverpool Lime Street Station</a>, up the hill to meet my colleague at the venue at the time, the <a href="https://www.britanniahotels.com/hotels/the-adelphi-hotel-liverpool">Adelphi Hotel</a>, whose sign you can see from the door of the station.</p>

<p><img src="/assets/posts/2025-12-02-computing-insight-united-kingdom-ciuk-2025/Screenshot%202025-12-02%20145545.png" alt="Changing names of GPFS (to Spectrum Scale) and MEW  (to CIUK) in 2015" /></p>

<p>This will be my first time attending as part of the contingent of a vendor, my new employer <a href="https://ocf.co.uk/">OCF Limited</a>, exhibiting at the conference. I am organising, or helping organise, several events; the OCF Steel Stack User Forum, OCF Steel Stack is OCF’s HPC management platform which I now lead the development of, and the final on-site challenge for this year’s Student Cluster Challenge which OCF, and specifically my Research and Development team, are setting. The students will undertake our challenge on Friday morning, before the winning team is crowned in the afternoon - not giving us a huge window for marking but I do tend to perform well under pressure so I am not too worried.</p>

<p>Amongst these students is, I hope, the potential next generation of HPC experts and it is a privilege to have a role in trying to inspire them to consider working in this fascinating area of technology. I am immensely proud of my team and the hard work that has gone into OCF Steel Stack and the challenge this year, and I hope that seeing it come to fruition in the next few days is rewarding for them as well. Finally, I am looking forward to seeing friends, old and new, again this year in a community I have been part of for most of my career, now.</p>

<p><img src="/assets/posts/2025-12-02-computing-insight-united-kingdom-ciuk-2025/L%20Hurst.png" alt="Laurence Hurst - Research and Development Lead banner" /></p>

<p>I am also getting very anxious in the run-up to travelling to this event. In September, I went to and spoke at <a href="https://www.linkedin.com/posts/dave-mcdonnell-7271a4_ibmstorage-fusion-ibmfusion-activity-7376613260571136000-owwM/">The 3rd IBM Fusion EMEA User Group in Amsterdam</a> (with a very unflattering picture taken of me in full flow being shared by the hosts) and I had been intending to write a blog post about that experience. Public speaking I’m generally good at - by the time I have to speak I will have it well scripted and rehearsed and the speaking itself I think went well. I tried to “ad-lib” some additional content I wrote during the earlier talks (which is why I’m holding a digital notebook in the picture, with those additional lines) but I ended up just sticking to my original script.</p>

<p><img src="/assets/posts/2025-12-02-computing-insight-united-kingdom-ciuk-2025/1758724776925.png" alt="me in full flow at the IBM Fusion EMEA User Group meeting" /></p>

<p>That trip to Amsterdam was my first time flying since I was diagnosed with autism in January (which upset me greatly, I am still struggling with it which is why I have not written a post about that diagnosis or followed up <a href="/notes/2024/09/20/adhd-take-2-part-1-welcome-to-the-adhder-family.html">the post about my ADHD diagnosis</a> as I promised). It was also my first time <strong>ever</strong> travelling internationally on my own.</p>

<p>Knowing why I find certain things very difficult was helpful to managing my experience on that trip, however despite doing what I could to self-regulate it was extremely stressful and a couple of problems (related to travelling through <a href="https://www.birminghamairport.co.uk/">Birmingham International Airport</a>, navigating around on foot and travelling as a passenger on public transport, particular difficulties of mine) did push me beyond my coping ability. I survived, with my wife and boss remotely helping me, but I think this work trip I am finding particularly hard - despite a venue and event I have attended several times before - because it’s so soon, and the next one, after that experience.</p>]]></content><author><name>Laurence</name></author><category term="notes" /><category term="adhd" /><category term="autism" /><category term="ciuk" /><category term="disability" /><category term="gpfs" /><category term="mew" /><category term="neurodiversity" /><category term="ocf" /><category term="spectrum-scale" /><category term="travel" /><category term="work" /><summary type="html"><![CDATA[CIUK 2025 is now upon us, beginning with day-zero tomorrow. I cannot remember exactly when I first went to the Machine Evaluation Workshop (MEW), as this High Performance Computing event was formerly known from 1990 to 2015, but I know I was there when the name changed. Although I cannot remember when, I clearly remember the first time I walked out of Liverpool Lime Street Station, up the hill to meet my colleague at the venue at the time, the Adelphi Hotel, whose sign you can see from the door of the station.]]></summary></entry><entry><title type="html">column tool for printing lists in columns</title><link href="https://blog.entek.org.uk/notes/2025/12/02/column-tool-for-printing-lists-in-columns.html" rel="alternate" type="text/html" title="column tool for printing lists in columns" /><published>2025-12-02T15:49:09+00:00</published><updated>2025-12-02T15:49:09+00:00</updated><id>https://blog.entek.org.uk/notes/2025/12/02/column-tool-for-printing-lists-in-columns</id><content type="html" xml:base="https://blog.entek.org.uk/notes/2025/12/02/column-tool-for-printing-lists-in-columns.html"><![CDATA[<p>Illustrating that no matter how long one has been doing something, there’s always something new to learn; in August I discovered the <a href="https://dyn.manpages.debian.org/trixie/bsdextrautils/column.1.en.html">column</a> command, which Debian packages in the <a href="https://packages.debian.org/stable/bsdextrautils"><code class="language-plaintext highlighter-rouge">bsdextrautils</code> package</a>. It has been over 20 years after I started using Linux regularly, yet this really useful little tool has somehow never entered my awareness before.</p>

<p>I have a little script, which I wrote a while ago, that finds the existing tag and category names from my published posts to help me avoid duplication of tags via subtle variants (e.g. using the tag <code class="language-plaintext highlighter-rouge">networking</code> instead of <code class="language-plaintext highlighter-rouge">network</code>). Originally this script used tabs (<code class="language-plaintext highlighter-rouge">\t</code>) to separate the names it found, however as the list of tags (in particular) has grown this has become difficult to read. In looking for a solution, I came across the <code class="language-plaintext highlighter-rouge">column</code> command which I’d not seen before. In typical “do one thing and do it well” style (which I am huge fan of - simple building blocks that we can use, and reuse, to make complex systems), it does what it says on the tin and formats the list of things it receives on <code class="language-plaintext highlighter-rouge">STDIN</code> into columns on <code class="language-plaintext highlighter-rouge">STDOUT</code>.</p>

<p>I present the revised <code class="language-plaintext highlighter-rouge">find-tags-and-categories.bash</code> in full:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/bin/bash</span>

heading<span class="o">()</span> <span class="o">{</span>
        <span class="nb">echo</span> <span class="s2">"-------------------------------"</span>
        <span class="nb">echo</span> <span class="nv">$@</span>
        <span class="nb">echo</span> <span class="s2">"-------------------------------"</span>
<span class="o">}</span>

grep_posts<span class="o">()</span> <span class="o">{</span>
        <span class="nb">grep</span> <span class="nt">-h</span> <span class="s2">"^</span><span class="nv">$1</span><span class="s2">: "</span> _posts/<span class="k">*</span> | <span class="nb">sed</span> <span class="s2">"s/^</span><span class="nv">$1</span><span class="s2">: //; s/ /</span><span class="se">\n</span><span class="s2">/g"</span> | <span class="nb">sort</span> <span class="nt">-u</span>
<span class="o">}</span>

heading tags
grep_posts tags | column
<span class="nb">echo

</span>heading categories
grep_posts categories | column
<span class="nb">echo</span>
</code></pre></div></div>]]></content><author><name>Laurence</name></author><category term="notes" /><category term="bash" /><category term="blog" /><category term="bsd" /><category term="column" /><category term="debian" /><category term="linux" /><category term="scripting" /><summary type="html"><![CDATA[Illustrating that no matter how long one has been doing something, there’s always something new to learn; in August I discovered the column command, which Debian packages in the bsdextrautils package. It has been over 20 years after I started using Linux regularly, yet this really useful little tool has somehow never entered my awareness before.]]></summary></entry></feed>