<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="http://localhost:4000/feed.xml" rel="self" type="application/atom+xml" /><link href="http://localhost:4000/" rel="alternate" type="text/html" /><updated>2026-04-11T13:17:32-07:00</updated><id>http://localhost:4000/feed.xml</id><title type="html">ontowhee</title><subtitle>Welcome to my site.</subtitle><entry><title type="html">psycopg2.errors.InFailedSqlTransaction</title><link href="http://localhost:4000/2026/01/28/pyscopg2-errors-InFailedSqlTransaction.html" rel="alternate" type="text/html" title="psycopg2.errors.InFailedSqlTransaction" /><published>2026-01-28T00:00:00-08:00</published><updated>2026-01-28T00:00:00-08:00</updated><id>http://localhost:4000/2026/01/28/pyscopg2-errors-InFailedSqlTransaction</id><content type="html" xml:base="http://localhost:4000/2026/01/28/pyscopg2-errors-InFailedSqlTransaction.html"><![CDATA[<p>Today my co-worker asked me to help troubleshoot an error they were encountering in one of the existing tests:</p>

<blockquote>
  <p>psycopg2.errors.InFailedSqlTransaction: current transaction is aborted, commands ignored until end of transaction block</p>
</blockquote>

<p>My first thought went to <code class="language-plaintext highlighter-rouge">@transaction.atomic</code>. Which blocks of code were wrapped in this decorator, and which were not? The one that wasn’t wrapped in a transaction is likely failing.</p>

<p>I ran the test on my local machine and inspected the traceback.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">======================================================================</span>
ERROR: test_order_template <span class="o">(</span>order.tests.test_views.OrderTestCase.test_order_template<span class="o">)</span>
<span class="nt">----------------------------------------------------------------------</span>
Traceback <span class="o">(</span>most recent call last<span class="o">)</span>:
    File <span class="s2">"/myapp/venv/lib/python3.12/site-packages/django/db/backends/utils.py"</span>, line 108, <span class="k">in </span>_execute
    <span class="k">return </span>self.cursor.execute<span class="o">(</span>sql, params<span class="o">)</span>
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
psycopg2.errors.InFailedSqlTransaction: current transaction is aborted, commands ignored <span class="k">until </span>end of transaction block

...

    File <span class="s2">"/myapp/context_processors.py"</span>, line 61, <span class="k">in </span>locations_processor
    model <span class="o">=</span> MyModel.objects.select_related<span class="o">(</span><span class="s1">'related_field'</span><span class="o">)</span>.first<span class="o">()</span>
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...

    File <span class="s2">"/myapp/order/views.py"</span>, line 552, <span class="k">in </span>get_order
    <span class="k">return </span>render<span class="o">(</span>request, <span class="s1">'order/order_template.html'</span>,
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...

django.db.utils.InternalError: current transaction is aborted, commands ignored <span class="k">until </span>end of transaction block

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

<p>
<details>
  <summary>Full traceback</summary>
  <pre>
======================================================================
ERROR: test_order_template (order.tests.test_views.OrderTestCase.test_order_template)
----------------------------------------------------------------------
Traceback (most recent call last):
    File "/myapp/venv/lib/python3.12/site-packages/django/db/backends/utils.py", line 108, in _execute
    return self.cursor.execute(sql, params)
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
psycopg2.errors.InFailedSqlTransaction: current transaction is aborted, commands ignored until end of transaction block

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
    File "/myapp/order/tests/test_views.py", line 223, in test_order_template
    response = self.client.get(reverse('order:get_order', kwargs={'pk': pk}))
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    File "/myapp/venv/lib/python3.12/site-packages/django/test/client.py", line 1124, in get
    response = super().get(
                ^^^^^^^^^^^^
    File "/myapp/venv/lib/python3.12/site-packages/django/test/client.py", line 475, in get
    return self.generic(
            ^^^^^^^^^^^^^
    File "/myapp/venv/lib/python3.12/site-packages/django/test/client.py", line 671, in generic
    return self.request(**r)
            ^^^^^^^^^^^^^^^^^
    File "/myapp/venv/lib/python3.12/site-packages/django/test/client.py", line 1087, in request
    self.check_exception(response)
    File "/myapp/venv/lib/python3.12/site-packages/django/test/client.py", line 802, in check_exception
    raise exc_value
    File "/myapp/venv/lib/python3.12/site-packages/django/core/handlers/exception.py", line 55, in inner
    response = get_response(request)
                ^^^^^^^^^^^^^^^^^^^^^
    File "/myapp/venv/lib/python3.12/site-packages/django/core/handlers/base.py", line 198, in _get_response
    response = wrapped_callback(request, *callback_args, **callback_kwargs)
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    File "/myapp/venv/lib/python3.12/site-packages/django/contrib/auth/decorators.py", line 59, in _view_wrapper
    return view_func(request, *args, **kwargs)
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    File "/myapp/order/views.py", line 552, in get_order
    return render(request, 'order/order_template.html',
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    File "/myapp/venv/lib/python3.12/site-packages/django/shortcuts.py", line 25, in render
    content = loader.render_to_string(template_name, context, request, using=using)
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    File "/myapp/venv/lib/python3.12/site-packages/django/template/loader.py", line 62, in render_to_string
    return template.render(context, request)
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    File "/myapp/venv/lib/python3.12/site-packages/django/template/backends/django.py", line 107, in render
    return self.template.render(context)
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    File "/myapp/venv/lib/python3.12/site-packages/django/template/base.py", line 172, in render
    with context.bind_template(self):
    File "/usr/lib/python3.12/contextlib.py", line 137, in __enter__
    return next(self.gen)
            ^^^^^^^^^^^^^^
    File "/myapp/venv/lib/python3.12/site-packages/django/template/context.py", line 263, in bind_template
    context = processor(self.request)
                ^^^^^^^^^^^^^^^^^^^^^^^
    File "/myapp/context_processors.py", line 61, in locations_processor
    model = MyModel.objects.select_related('related_field').first()
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    File "/myapp/venv/lib/python3.12/site-packages/django/db/models/query.py", line 1145, in first
    for obj in queryset[:1]:
    File "/myapp/venv/lib/python3.12/site-packages/django/db/models/query.py", line 390, in __iter__
    self._fetch_all()
    File "/myapp/venv/lib/python3.12/site-packages/django/db/models/query.py", line 2000, in _fetch_all
    self._result_cache = list(self._iterable_class(self))
                            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    File "/myapp/venv/lib/python3.12/site-packages/django/db/models/query.py", line 95, in __iter__
    results = compiler.execute_sql(
                ^^^^^^^^^^^^^^^^^^^^^
    File "/myapp/venv/lib/python3.12/site-packages/django/db/models/sql/compiler.py", line 1624, in execute_sql
    cursor.execute(sql, params)
    File "/myapp/venv/lib/python3.12/site-packages/django/db/backends/utils.py", line 79, in execute
    return self._execute_with_wrappers(
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    File "/myapp/venv/lib/python3.12/site-packages/django/db/backends/utils.py", line 92, in _execute_with_wrappers
    return executor(sql, params, many, context)
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    File "/myapp/venv/lib/python3.12/site-packages/django/db/backends/utils.py", line 100, in _execute
    with self.db.wrap_database_errors:
    File "/myapp/venv/lib/python3.12/site-packages/django/db/utils.py", line 94, in __exit__
    raise dj_exc_value.with_traceback(traceback) from exc_value
    File "/myapp/venv/lib/python3.12/site-packages/django/db/backends/utils.py", line 105, in _execute
    return self.cursor.execute(sql, params)
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
django.db.utils.InternalError: current transaction is aborted, commands ignored until end of transaction block

----------------------------------------------------------------------
Ran 1 test in 0.308s

FAILED (errors=1)
  </pre>
</details>
</p>

<p>This line in the traceback,</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>    File <span class="s2">"/myapp/context_processors.py"</span>, line 61, <span class="k">in </span>locations_processor
    model <span class="o">=</span> MyModel.objects.select_related<span class="o">(</span><span class="s1">'related_field'</span><span class="o">)</span>.first<span class="o">()</span>
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
</code></pre></div></div>

<p>tells us the error was raised when a query was made in the Django context processor, and this line,</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>    File <span class="s2">"/myapp/order/views.py"</span>, line 552, <span class="k">in </span>get_order
    <span class="k">return </span>render<span class="o">(</span>request, <span class="s1">'order/order_template.html'</span>,
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
</code></pre></div></div>

<p>tells us the context processor was called during the rendering of the template. However, the problem was neither in the context processor itself, nor was it in the template.</p>

<p>A closer look at <strong>InFailedSqlTransaction</strong> tells us that a previous SQL statement had been executed and failed. An explanation can be found in the <a href="https://www.psycopg.org/psycopg3/docs/basic/transactions.html#:~:text=InFailedSqlTransaction">transaction management documentation for psycopg3</a>:</p>

<blockquote>
  <p>If a database operation fails with an error message such as <strong>InFailedSqlTransaction</strong>: current transaction is aborted, commands ignored until end of transaction block, it means that <strong>a previous operation failed</strong> and the database session is in a state of error. You need to call <a href="https://www.psycopg.org/psycopg3/docs/api/connections.html#psycopg.Connection.rollback">rollback()</a> if you want to keep on using the same connection.</p>
</blockquote>

<p>This means, while the error was raise on the context processor during the template render, the real failure happened before that.</p>

<p>How did I troubleshoot this? There were a lot of try-except statements in the code base. I started by removing the try-except clause from the block of code that contained call to <code class="language-plaintext highlighter-rouge">render(request, 'order/order_template.html', ...)</code>. I ran the test again and continued to see the error. I walked backwards to find a preceding function that was executing SQL queries and was also wrapped in a try-except statement. I removed the try-except statement, and reran the test. It was like peeling back a layer of an onion, one at a time until I started to cry with joy. Ok, not that dramatic. It only took me two blocks of code to uncover the failure point.</p>

<p>The new traceback revealed a more meaningful error message pointing at raw SQL in the code base.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>psycopg2.errors.UndefinedTable: relation <span class="s2">"product_inventory"</span> does not exist LINE 1: ...sku, origin, price from product_in...
</code></pre></div></div>

<p>
<details>
  <summary>Full traceback</summary>
  <pre>
======================================================================
ERROR: test_order_template (order.tests.test_views.OrderTestCase.test_order_template)
----------------------------------------------------------------------
Traceback (most recent call last):
    File "/myapp/venv/lib/python3.12/site-packages/django/db/backends/utils.py", line 103, in _execute
    return self.cursor.execute(sql)
            ^^^^^^^^^^^^^^^^^^^^^^^^
psycopg2.errors.UndefinedTable: relation "product_inventory" does not exist
LINE 1: ...sku, origin, price from product_in...
                                                                ^

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
    File "/myapp/order/tests/test_views.py", line 319, in test_order_template
    response = self.client.get(reverse('order:get_order', kwargs={'pk': pk}))
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    File "/myapp/venv/lib/python3.12/site-packages/django/test/client.py", line 1124, in get
    response = super().get(
                ^^^^^^^^^^^^
    File "/myapp/venv/lib/python3.12/site-packages/django/test/client.py", line 475, in get
    return self.generic(
            ^^^^^^^^^^^^^
    File "/myapp/venv/lib/python3.12/site-packages/django/test/client.py", line 671, in generic
    return self.request(**r)
            ^^^^^^^^^^^^^^^^^
    File "/myapp/venv/lib/python3.12/site-packages/django/test/client.py", line 1087, in request
    self.check_exception(response)
    File "/myapp/venv/lib/python3.12/site-packages/django/test/client.py", line 802, in check_exception
    raise exc_value
    File "/myapp/venv/lib/python3.12/site-packages/django/core/handlers/exception.py", line 55, in inner
    response = get_response(request)
                ^^^^^^^^^^^^^^^^^^^^^
    File "/myapp/venv/lib/python3.12/site-packages/django/core/handlers/base.py", line 198, in _get_response
    response = wrapped_callback(request, *callback_args, **callback_kwargs)
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    File "/myapp/venv/lib/python3.12/site-packages/django/contrib/auth/decorators.py", line 59, in _view_wrapper
    return view_func(request, *args, **kwargs)
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    File "/myapp/order/views.py", line 460, in get_order
    medic_prod_list = get_product_inventory_list(
                        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    File "/myapp/common/product_sorting.py", line 130, in get_product_inventory_list
    cursor.execute("""select product_id, sku, origin, price from product_inventory
    File "/myapp/venv/lib/python3.12/site-packages/django/db/backends/utils.py", line 79, in execute
    return self._execute_with_wrappers(
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    File "/myapp/venv/lib/python3.12/site-packages/django/db/backends/utils.py", line 92, in _execute_with_wrappers
    return executor(sql, params, many, context)
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    File "/myapp/venv/lib/python3.12/site-packages/django/db/backends/utils.py", line 100, in _execute
    with self.db.wrap_database_errors:
    File "/myapp/venv/lib/python3.12/site-packages/django/db/utils.py", line 94, in __exit__
    raise dj_exc_value.with_traceback(traceback) from exc_value
    File "/myapp/venv/lib/python3.12/site-packages/django/db/backends/utils.py", line 103, in _execute
    return self.cursor.execute(sql)
            ^^^^^^^^^^^^^^^^^^^^^^^^
django.db.utils.ProgrammingError: relation "product_inventory" does not exist
LINE 1: ...sku, origin, price from product_in...
                                                                ^

----------------------------------------------------------------------
Ran 1 test in 0.262s

FAILED (errors=1)
  </pre>
</details>
</p>

<p>Perhaps the models and raw SQL had become out of sync. I reported my findings to my co-worker and left them with the remaining investigation work.</p>

<p>I also advised them to check that the migration files on their local machine is in sync with the models in their working branch. The project does not track the migration files in the repository, unfortunately. Having mismatching migration files has bitten me many times.</p>

<p>Could or did my co-worker ask the LLM to figure out this problem? I didn’t think to try it myself in the moment, given that I had a pretty good gut instinct on how to troubleshoot this, but now I’m curious…</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Today my co-worker asked me to help troubleshoot an error they were encountering in one of the existing tests:]]></summary></entry><entry><title type="html">Year 2025 (Abridged)</title><link href="http://localhost:4000/2026/01/18/year-2025-abridged.html" rel="alternate" type="text/html" title="Year 2025 (Abridged)" /><published>2026-01-18T00:00:00-08:00</published><updated>2026-01-18T00:00:00-08:00</updated><id>http://localhost:4000/2026/01/18/year-2025-abridged</id><content type="html" xml:base="http://localhost:4000/2026/01/18/year-2025-abridged.html"><![CDATA[<blockquote>
  <p>“It was the best of times, it was the worst of times…”</p>

  <p>– A Tale Of Two Cities, Charles Dickens</p>
</blockquote>

<h2 id="contents">Contents</h2>
<ul>
  <li><a href="#4-books">4 Books</a></li>
  <li><a href="#3-conferences">3 Conferences</a></li>
  <li><a href="#2-prs">2 PRs</a></li>
  <li><a href="#a-shifting-mindset">A Shifting Mindset</a></li>
  <li><a href="#djangonaut-space">Djangonaut Space</a></li>
  <li><a href="#arts--wine-festival">Arts &amp; Wine Festival</a></li>
  <li><a href="#radio-station">Radio Station</a></li>
  <li><a href="#newsletter">Newsletter</a></li>
  <li><a href="#reflecting-on-writing-a-year-in-review">Reflecting On Writing A “Year In Review”</a></li>
</ul>

<h2 id="4-books">4 Books</h2>

<p>These books stood out for the voices, experiences, and resilience they portrayed:</p>
<ul>
  <li><em>Crying In H-Mart</em> - Michelle Zautner, Alfred A. Knopf, 2021</li>
  <li><em>The World Of Nancy Kwan</em> - Nancy Kwan, Legacy Lit, 2025</li>
  <li><em>Where Rivers Part: A Story Of My Mother’s Life</em> - Kao Kalia Yang, Atria Books, 2024</li>
  <li><em>Melt Down: The Making And Breaking Of A Field Scientist</em> - Sarah Boon, University Of Alberta Press, 2025</li>
</ul>

<h2 id="3-conferences">3 Conferences</h2>

<h3 id="north-bay-python">North Bay Python</h3>

<p>This two-day, one-track conference is oriented around community. I presented a talk on Djangonaut Space, met several people in-person whom I had only known from online, reconnected with people I met last year, and enjoyed the rainy albeit cold weather along with the views (and smells) from the barn.</p>

<h3 id="djangocon-us">DjangoCon US</h3>

<p>This was pivotal for me. I felt more like myself than I had in the past two decades. I volunteered to stuff swag bags, helped out at the registration desk, said yes to delivering a morning announcement as well as the Sprint kickoff, reconnected with people from the previous year’s conference, presented a talk about Djangonaut Space, met new people and had many discussions. Seeing my mentors in person was a huge highlight. Helping out at the conference made me feel I was more part of the community.</p>

<p>I also had the chance to roam Chicago, enjoy the River Walk (and the river waft ;P), take in the morning sun at the lake, and experience the transportation system, which I think is superior to the system where I live in terms of the frequency of trains and affordability.</p>

<p>It’s crazy to think that I was having second thoughts about attending the conference just a month before. I’m glad I booked my flight and room and took on challenges that came my way. There were still moments of deep insecurity, but sharing my insights and experiences shaped my sense of place and purpose. There were lots of different aspects about participating in the conference and exploring a new city that allowed me to grow.</p>

<h3 id="pybay">PyBay</h3>

<p>I volunteered at the registration desk, met new people and also reconnected with people from previous conferences. The talk I found most interesting that day was Mason Egger’s <a href="https://www.youtube.com/watch?v=fN4P0zH2LWU">Events are the Wrong Abstraction</a>. It reminds me of the argument for monoliths and against microservices.</p>

<p>It was a long and fun day, starting with an early morning drive in the dark into the city for volunteer duty, good conversations during lunch and break times, followed by dinner in the evening with the volunteer team and speakers.</p>

<h2 id="2-prs">2 PRs</h2>

<p>Most proud code contributions to Django so far:</p>
<ul>
  <li><a href="https://github.com/django/code.djangoproject.com/pull/266">Queue links</a></li>
  <li><a href="https://github.com/django/django/pull/19846">Contribution workflow diagram</a></li>
</ul>

<p>It’s always a joy to bring clarity to something that can feel nebulous at times. While the code changes are small, the bulk of the work was in scouring the forum, repository issues, looking into existing examples in other communities, scraping data instead of relying on a data dump, taking on different aspects of the contribution process first hand (triaging, bug fixing, reviewing, etc.) and learning the nuances of the existing tools and workflows. Even then, I was just exposed to a small sliver of all the work that goes into maintaining the web framework. I did have fun (as well as frustrations) exploring <a href="https://trac.edgewall.org">Trac</a>, and its configurable design is quite fascinating, despite not having data integrity. It relies a lot on string search.</p>

<p>The hardest part for me was presenting a logical argument for the importance of these initial changes and bigger changes down the line, especially to those who have this information so ingrained in them that they don’t see the need for the changes. Many people guided me along the way, from opening an issue to starting a forum discussion. I was able to demonstrate the value for these changes. The Django Fellows reviewed the PRs and helped get the changes merged in.</p>

<p>The change to the contribution workflow diagram <a href="https://code.djangoproject.com/ticket/36789">broke the PDF docs builds</a> in the 6.0 release. A community member filed the report, and another quickly submitted a PR fix for it.</p>

<p>I’m proud of these changes because I know there is at least one person who uses them very often: me. I reference the diagram whenever I talk to new contributors. I use the queue links to navigate to the tickets all the time. I’m also proud of these changes, because establishing a clear mental model of a “queue” versus a “triage stage” paves the way for more improvements. This is the tip of the iceberg.</p>

<h2 id="a-shifting-mindset">A Shifting Mindset</h2>

<p><strong>Software engineering interview funnel:</strong></p>
<ul>
  <li>9 invitations</li>
  <li>6 screening rounds</li>
  <li>3 final rounds</li>
  <li>2 offers</li>
  <li>1 accepted (1 rescinded)</li>
  <li>3 invitations turned down after accepting an offer</li>
</ul>

<p>Of the 9 invitations, 7 were through recruiters or referrals, 2 were cold applications. I lost track of the total applications.</p>

<p><strong>Time spans for the two offers:</strong></p>
<ul>
  <li>Longest: 6 weeks</li>
  <li>Shortest: 11 business days</li>
</ul>

<p><strong>Non-software engineering role:</strong></p>
<ul>
  <li>1 Sales Associate role</li>
</ul>

<p>When things didn’t seem to materialize, I explored non-software engineering roles that would allow me to acquire customer-facing skills. I applied and was accepted for a Sales Associate role. I took the job… and walked out after 5 hours. Funny story, it was Louis Rossman’s video from a decade ago that inspired me to apply and take the job. It was also another one of Rossmann’s videos that helped me <a href="https://youtu.be/41mZfuuhJ6g?si=cpV4moVg81PeByHd&amp;t=804">“flip the switch”</a>.</p>

<p><strong>Mail-in applications:</strong></p>
<ul>
  <li>1</li>
</ul>

<p>Yes, I sent my resume via snail mail for a software engineering role at a big food delivery company. Initially, I questioned it’s legitimacy, did a quick search on the mailing address, and then took a chance. Several months later their recruiter emailed with an invitation to schedule the screening interview. At first, I was very confused, because I didn’t remember applying to this company, and I couldn’t find any references to it in my computer files. Eventually, I did find a screenshot of the job post. I turned down the invitation as I had already accepted an offer.</p>

<p>–</p>

<p>It was a rocky journey, and I was hesitant to share this. However, my experience here is not unique, and it dominated the year. Besides, what’s the point of pretending the track record is pristine? The bumps and how they were handled are more interesting.</p>

<p>I have many people to thank for helping me along the way. Everything from encouraging me to create an online profile, to reviewing my resume, to tips on having social media activity (social proof), to encouraging me to negotiate (because negotiation IS part of the process and part of everyday life), to the many referrals. The most important part was having people believe in me when I didn’t believe in myself.</p>

<p>At the beginning of the process, I was afraid of letting people know that I was looking for opportunities, and I was afraid to ask for help. By the end, I was scheduling the earliest available interview slot for every invitation. I was excited to meet new people, learn about their busienss needs, share my experiences, and be myself. If I’m perceived as a Type-A personality by one of the interviewers, I know it’s temporary. Interviews can be awkward and exaggerations of reality.</p>

<p>The search process made me understand that there’s nothing to be ashamed of. A layoff, multiple rejections, and even a rescinded offer are nothing in the grand scheme of things. The fact that I hadn’t had more rejections shows that I hadn’t been reaching for more opportunities and aiming higher.</p>

<p>A few years ago during Salesforce Dreamforce, I met a high performing salesperson from Marketo. We were discussing the mentality of salespeople and what drives them to hit their goals in the context of gamification. This person told me, “Salespeople get a thrill when they see their numbers go up. They don’t care how many deals they lose. They care about how many deals they win.”</p>

<h2 id="djangonaut-space">Djangonaut Space</h2>

<p>I was a Navigator for Session 5. In my role as a mentor, I curated a list of tickets to help the Djangonauts get started. I exposed them to other ways of contributing, by showing them examples of triage work and PR reviews. When I was a Djangonaut, my Navigator had shared his own experiences of a ticket that he was working on at the time. I did similarly by sharing my work and experiences.</p>

<p>The Djangonauts not only got their PRs merged, but they also contributed by writing for Django News Updates, writing for the Django web blog, writing their own personal blogs, and posting on the forum. I introduced them to <a href="https://youtube.com/playlist?list=PLMg7ba4NN6N86bKijkhq42jA-SHBmCEAe&amp;si=ArUgBR6s0bHTN__e">Space Reviewers</a>, and the team participated in a live episode to review a PR. It was amazing to see how much the Djangonauts accomplished in the 8-week time frame.</p>

<p>The best part of any good mentorship is that we are all mentors to one another. I learned a lot from the Djangonauts and the Captain. It wasn’t always smooth sailing. There were moments where I wondered if I was being effective as a mentor. In the end, it was very satisfying to see that the Djangonauts have developed confidence as well as a growing appetite for making contributions. I look forward to seeing more of their work and hearing more about their stories.</p>

<h2 id="arts--wine-festival">Arts &amp; Wine Festival</h2>

<p>At an Arts &amp; Wine Festival one summer weekend, I worked the ticket booth on Saturday and the beer booth on Sunday. For the ticket booth, it was challenging trying to keep the line moving while also trying to improve the setup. This reminds me of finding the balance between building new features and addressing technical debt on software projects.</p>

<p>My co-volunteer was very smart about handling the new ticket packaging system that was introduced this year. The festival attendees were very confused by it, but my co-volunteer would start by asking them how many drinks they expected to have, then worked backwards to show them the package that would get the best deal.</p>

<p>My co-volunteer and I also coordinated such that one person handled the payments and the other person handled the tickets, wrist bands, and beer/wine glasses. We still needed the festival organizer to observe from a distance to see the bigger picture of the bottleneck though.</p>

<p>The beer booth on Sunday was less hectic. I learned to pour beer (with the glass at an angle to reduce foam) and greeted the festival attendees who were excited to get their drinks. I also got to know my co-volunteers, a mother-daughter duo who live in my neighborhood.</p>

<h2 id="radio-station">Radio Station</h2>

<p>I took a radio station training course, passed the exams, and completed my studio training sessions. I also volunteered at a few station-hosted events, including a live mic and a film festival.</p>

<p>For the live mic, I showed up unannounced. I had no idea what to expect. I bet I confused people when I showed up, too. I was just curious and wanted to help out at the studio before taking the exams or starting the training. The staff members were very welcoming. One of them patiently showed me how to operate the controls for the 4 video cameras. I ended up operating the controls that evening, trying to decide what shots looked interesting, when to apply the panning and zooming effects, and how to pace the transitions between the cameras. I also had the chance to meet the surf bands. They were very friendly. One was The Frigidaires, hailing from Atlanta. The other was LHD, traveling all the way from Croatia.</p>

<p>For the film festival, I helped put up vintage film posters to decorate the auditorium and set up the merch table. I managed the ticket admissions at the opening of the event. I applied a similar setup that I learned from volunteering at the ticket booth at the Arts &amp; Wine Festival: one staff member handles the payments, while the other handles the tickets. During intermission, I helped people find their t-shirt sizes at the merch table.</p>

<p>It’s always fun looking for ways to improve the process ever so slightly, so attendees don’t have to wait too long in line in the freezing night. It was fun greeting the attendees and helping them buy t-shirts. I met more staff members, many who shared stories of their connections to the station pre-dating my existence. They, too, were excited to meet me and welcome new members to the station.</p>

<h2 id="newsletter">Newsletter</h2>

<p>I attempted to write a weekly newsletter. I haven’t quite figured out the format and type of content. Do I share about my projects or dive into details of topics? Do I share more news-y content about other people’s works and events? At what frequency? Is a newsletter the right format for my interests? I’ll see…</p>

<h2 id="reflecting-on-writing-a-year-in-review">Reflecting On Writing A “Year In Review”</h2>

<p>I was on the fence between writing lists and having deeper analyses. <a href="https://treyhunner.com/2025/12/my-favorite-reads-of-2025/">Trey Hunner’s “My Favorite Reads Of 2025”</a> inspired me to get going with a books section. For certain topics I found myself diving deep. <a href="https://fosstodon.org/@paulox/115815352466307504">Paolo Melchiorre took the approach of giving each topic its own dedicated blog entry</a>, but my problem was less about writing, and more about sharing. I mulled over 5 different drafts across 5 days, and delayed publication several times over the period of a month. After working through those thoughts, I realized I don’t have to share them if I don’t want to. And yet, I want to!</p>]]></content><author><name></name></author><summary type="html"><![CDATA[“It was the best of times, it was the worst of times…” – A Tale Of Two Cities, Charles Dickens]]></summary></entry><entry><title type="html">Reflecting On Speaking</title><link href="http://localhost:4000/2025/10/24/reflecting-on-speaking.html" rel="alternate" type="text/html" title="Reflecting On Speaking" /><published>2025-10-24T00:00:00-07:00</published><updated>2025-10-24T00:00:00-07:00</updated><id>http://localhost:4000/2025/10/24/reflecting-on-speaking</id><content type="html" xml:base="http://localhost:4000/2025/10/24/reflecting-on-speaking.html"><![CDATA[<p>DjangoCon US 2025 was in Chicago from September 7th-11th. Four months earlier, I had delivered a talk at North Bay Python (NBPython) titled, “Djangonaut Space: A Mentorship Program For Open Source”. The proposal I submitted for DjangoCon US was the same as for NBPython, but I was very dissatisfied. I didn’t want to give the same talk. I didn’t like the content, and I didn’t like my delivery.</p>

<p>In preparing for DjangoCon US, I reflected on my contributions and experiences in the time since NBPython, and I was able to include new material. However, as I was practicing my speech, I would clock in at 35 or 40 or 45 minutes, instead of the allotted 25 minutes. I didn’t want to cut out the new material and water it down to the original talk. I tend to speak slowly and softly. It’s a reflection of my uneasiness. I realized if I could speed up my speech, perhaps I can get all the content in, and I can also appear more lively. So, instead of cutting out the material, I challenged myself to speak faster and more energetically. As I was practicing, I was able to clock in closer to 25 minutes.</p>

<p>In the actual talk, I spent the first 3 minutes of the time slot trying to update my computer settings to show my notes. I couldn’t deliver without my notes, because I was reading it verbatim, so I knew I really had to speed up my speech. There was no time to slack. I remember feeling very out of breath around the 9 minute mark, but I kept going. My speaking time clocked in at about 20 minutes! I even had remaining time for one question in the end.</p>

<p>Being able to share my experiences from the Djangonaut Space program to a room filled with my mentors, as well as strangers turned acquaintances, was amazing. I’m glad it worked out. I’m glad I was able to portray how much the program means to me. I liked how the <a href="https://codeberg.org/ontowhee/presentations/src/branch/main/DjangoConUS2025-DjangonautSpace.pdf">slides</a> turned out, especially the chart on slide 15. The original slide was just bullet points of text. I’m glad I was able to challenge myself and grow even more.</p>

<p>It was wonderful afterwards when people came up and told me they enjoyed my talk. Some asked how they can apply to be a mentor. It is amazing to see that several of those people are now participating in the program. I don’t think my talk alone was the major reason for people getting involved, but I imagine it played a role in bringing awareness to the program and revealing perspectives and raw emotions.</p>

<p>After challenging myself to speak physically differently than I normally do, I feel I have opened myself up to the world. I’ve shown myself that I can take up space. I can project my voice. I don’t have to feel apologetic for any of it. I don’t have to default to thinking I am inferior to anybody else. Quite the opposite. I think people have wanted me to take up space and speak up for a long time. Someone with a big name in the community who I admire told me they noticed a huge difference in me, and they are very happy to see it.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[DjangoCon US 2025 was in Chicago from September 7th-11th. Four months earlier, I had delivered a talk at North Bay Python (NBPython) titled, “Djangonaut Space: A Mentorship Program For Open Source”. The proposal I submitted for DjangoCon US was the same as for NBPython, but I was very dissatisfied. I didn’t want to give the same talk. I didn’t like the content, and I didn’t like my delivery.]]></summary></entry><entry><title type="html">Tutorials, Testathons, Sprints, Space Reviewers</title><link href="http://localhost:4000/2025/07/29/tutorials-testathons-sprints-space-reviewers.html" rel="alternate" type="text/html" title="Tutorials, Testathons, Sprints, Space Reviewers" /><published>2025-07-29T00:00:00-07:00</published><updated>2025-07-29T00:00:00-07:00</updated><id>http://localhost:4000/2025/07/29/tutorials-testathons-sprints-space-reviewers</id><content type="html" xml:base="http://localhost:4000/2025/07/29/tutorials-testathons-sprints-space-reviewers.html"><![CDATA[<p>Two weeks ago, I joined <a href="https://mastodon.social/@raffaellasuardini">Raffaella</a> and <a href="https://laymonage.com/">Sage</a> for <a href="https://www.youtube.com/watch?v=vuehKySWKxc">Space Reviewers Episode 6</a>, where we reviewed a Django PR during a live stream. This was a fun event. I won’t get into the technical aspects of the review, and I won’t point out the many mistakes I made. Instead, I want to revisit several “getting started with open source” community events and reflect on my personal growth since I first got involved with open source.</p>

<p>My first open source contribution happened accidentally during DjangoCon US 2023. I volunteered to host office hours to help tutorial attendees set up their development environments. I went through the tutorial projects, found a missing dependency in one of them, and reported it on the Slack channel. The conference organizer, <a href="https://www.better-simple.com">Tim Schilling</a>, responded and suggested that I open a PR to the project. I remember thinking to myself, “Really? I can do that?”</p>

<p>During the Sprint Days of the conference, I participated and opened two PRs to address accessibility issues. I didn’t know much about accessibility at that point, and I would not have known how to navigate the contribution process and pick out issues on my own. Thankfully, the project leaders were there to guide new contributors, and I was able to gain hands-on experience with these first few PRs.</p>

<p>There was another event that took place during the Sprint Days called Testathon. I had heard of hackathons before, but I had not heard of testathons. I attended and found out they were like live stream coding or group pair programming. One person shared their screen and the group chimed in on strategies. The purpose of the testathon was to show people how to test open source projects against Django’s beta release. The code driver (or anyone else in the group) would point out what files to look for, how to run tests, and how to open PRs. Every project is slightly different, from project setup to contributing etiquette, and I learned several different things from attending 2 testathons. I loved the interactive and intimate nature of the event. It exposed me to another aspect of open source projects and contributions. I also thought it was very brave of people to share their screens and work through code together in a group. My brain would have short circuited if I were put on the spot like that!</p>

<p>From DjangoCon US 2023, I participated in 3 different types of events where I got hands-on experience with open source contributions, and I wanted more! I was curious about the live stream coding and group pair programming opportunities, too. This was definitely outside of my comfort zone, and I wanted to know how I could overcome my own inhibitions and participate more actively. I wanted to be able to jump into events so nonchalantly as everyone else seemed to do. (Of course, that’s the perception. Now, I know that most people feel some level of nervousness or anxiety when they are hosting or attending such events, and that’s absolutely normal.)</p>

<p>In March 2024, when the first Djangonaut Space session came to a close, Tim, a program organizer, asked if anyone was interested in hosting a “Getting Started With Contributing” event. I expressed my interest, and Tim suggested a ticket to work on. Unfortunately, I didn’t follow through. How could I host a “Getting Started With Contributing” event? First, I wasn’t sure if I even knew how to get started. Second, I wasn’t ready to lead an event and the discussions while simultaneously sharing my screen and thinking out loud. Finally, I wasn’t ready to be on camera in the public eye. Even though I had just finished the Djangonaut Space program, I hadn’t overcome my own inhibitions. I didn’t ask for guidance, and the event never materialized.</p>

<p>About 8 months later in November 2024, Space Reviewers launches its very first episode. I thought it was such a creative format. I wanted to be a part of it. By this time, I was getting a lot of training with event organizing through my role as the Session Organizer for Djangonaut Space, but I wasn’t sure how to ask about joining the Space Reviewers crew, and maybe it was too early in the formation of the group to bring on another member.</p>

<p>It wasn’t until June 2025 that I finally asked if I could help out with Space Reviewers. The crew welcomed me as a new member. I started out by making a pre-recorded video, a <a href="https://www.youtube.com/watch?v=mPndbdezvJw">PR Review Deep Dive</a>, that was uploaded to the <a href="https://www.youtube.com/@djangonautspace">Djangonaut Space YouTube channel</a>. I had a lot of fun recording and editing the video.</p>

<p>A month later in July 2025 (that is, two weeks ago), the crew members planned for the next episode. Raffaella scheduled time for the event and created the show notes, and I was taking on Tim’s role as a co-organizer. Because I would be managing the video stream and sharing my screen, I realized that I could be the single point of failure during the event. There was no safety element that a pre-recorded video offers. If my internet went down, or if my computer crashed, or if I stupidly clicked the wrong button, the live stream could come to a halt. It was a terrifying thought, but I took on the risks and pushed forward.</p>

<p>On the day of the event, there was a delay to the start time and some fumbles on my end, but overall, it was very fun and productive. People joined and shared their tips and tricks in the live chat. By the end, we were able to walk through the review process and post our comments on the PR. Looking back, I think making the pre-recorded video was a great stepping stone towards hosting the live stream.</p>

<p>I’m so glad I had the opportunity to work with Raffaella and Sage as part of Space Reviewers. They have a lot of insights and perspectives that I didn’t have. I had a lot of fun taking on the new challenges that came with organizing this event. Initially, I struggled internally as I tried to face some of my fears. There were moments leading up to the event where I thought to myself, “Why did I volunteer to do this???” In the end, I’m glad I did.</p>

<p>Some of the challenges I overcame might not seem like a big deal, but when I compare myself to where I was at the beginning of DjangoCon US 2023, I can see my personal growth quite prominently. Now, I know how to get started with contributing, and I am able to walk people through the process. I am also a lot more comfortable taking ownership of organizing and leading events. (I remember a time when I constantly needed to ask for permission or confirmation before executing an action.) I can brush off the fumbles I make as the camera is rolling, and I can continue on with the discussion.</p>

<p>When I revisit the community events that I have participated in over the past 2 years, from Space Reviewers, to Sprints, to Testathons, to Tutorial office hours, I realize how far I have come. I am also reminded of what it was like to be absolutely new to open source and to the community. Although I still feel somewhat new, I’m not a deer in headlights anymore. I’m still trying to find my place in open source, and the best way to do that is to continue showing up and continue helping out. One small PR at a time, one small review at a time. One little blog, one little video…</p>]]></content><author><name></name></author><category term="favorite" /><summary type="html"><![CDATA[Two weeks ago, I joined Raffaella and Sage for Space Reviewers Episode 6, where we reviewed a Django PR during a live stream. This was a fun event. I won’t get into the technical aspects of the review, and I won’t point out the many mistakes I made. Instead, I want to revisit several “getting started with open source” community events and reflect on my personal growth since I first got involved with open source.]]></summary></entry><entry><title type="html">Consensus… Or Collections?</title><link href="http://localhost:4000/2025/06/13/django-consensus-collections.html" rel="alternate" type="text/html" title="Consensus… Or Collections?" /><published>2025-06-13T00:00:00-07:00</published><updated>2025-06-13T00:00:00-07:00</updated><id>http://localhost:4000/2025/06/13/django-consensus-collections</id><content type="html" xml:base="http://localhost:4000/2025/06/13/django-consensus-collections.html"><![CDATA[<p>In the Django process for making changes, one is encouraged to post on the forum and start a discussion with the goal of gaining consensus.</p>

<p>I drafted a proposal for a change to the Triage Workflow, but I don’t think I will post it. The proposal is flawed in its one-size-fits-all approach to the solution. Attempts to “gain consensus” can drive us into this trap, this belief that a solution should reach agreement from every participant in the discussion.</p>

<p>What if gaining consensus is the wrong goal? What if the real goal is to create a collection of solutions?</p>

<p>What if the proposal is not actually about changing the Triage Workflow, but rather about suggesting different layouts of the Trac web interface, where each layout is tailored towards a contributor role? For example, in some applications such as Salesforce, there is a dropdown menu that allows you to “switch roles” and see different widgets on the dashboards based on your role as an Account Executive, or a Sales Development Representative, or any other role that you would like to create and customize.</p>

<p>The Reports Tab achieves this collection-of-solutions approach already. It provides a rich list of preset filters. But, how easy is it to use? How many people know about it? I’m talking specifically about people who are non-super contributors. This could include new contributors, as well as experts who do not contribute on a high frequency basis, as well as community organizers who use the ticket management system for reasons other than development work.</p>

<p>Is there value in a multi-layout solution? Is there a way to display the information from Reports Tab differently, so it is more easily accessible? For example, display a tab for each section of the reports tab, and labeling the current “View Tickets” tab as “Custom Query”.</p>

<p><img src="/assets/images/trac_tabs_for_queues.png" alt="Figure 1: Mockup of tabs for each queue" title="Figure 1: Trac A Tab For Each Queue" /></p>

<p>To be continued…</p>]]></content><author><name></name></author><summary type="html"><![CDATA[In the Django process for making changes, one is encouraged to post on the forum and start a discussion with the goal of gaining consensus.]]></summary></entry><entry><title type="html">Creating A Temporal Table To Track Django Development Process</title><link href="http://localhost:4000/2025/05/26/creating-a-temporal-table-to-track-django-development-process.html" rel="alternate" type="text/html" title="Creating A Temporal Table To Track Django Development Process" /><published>2025-05-26T00:00:00-07:00</published><updated>2025-05-26T00:00:00-07:00</updated><id>http://localhost:4000/2025/05/26/creating-a-temporal-table-to-track-django-development-process</id><content type="html" xml:base="http://localhost:4000/2025/05/26/creating-a-temporal-table-to-track-django-development-process.html"><![CDATA[<h1 id="temporal-table">Temporal Table</h1>

<p>Temporal data refers to data that is associated with time periods. A time period consists of a start time and an end time.</p>

<p>A temporal table is a database table that stores time periods. There are two types of temporal tables. One is application-time versioned table, the other is system-time versioned table. The difference is the meaning behind the stored time value.</p>

<p>In application-time, the stored time value reflects the application state. For example, it can be the activation and deactivation dates of a membership to an organization. Application-time is useful for tracking the history of the application state.</p>

<p>In system-time, the time values reflect the moment the data is modified. System-time is useful for auditing purposes.</p>

<h3 id="sql2011-temporal-features">SQL:2011 Temporal Features</h3>

<p>SQL:2011 introduced the standards for <a href="https://cs.ulb.ac.be/public/_media/teaching/infoh415/tempfeaturessql2011.pdf">temporal features</a>. These standards include defining a time period using the <code class="language-plaintext highlighter-rouge">PERIOD</code> declaration, DML for <code class="language-plaintext highlighter-rouge">UPDATE</code> and <code class="language-plaintext highlighter-rouge">DELETE</code>, period predicates such as <code class="language-plaintext highlighter-rouge">OVERLAPS</code>, <code class="language-plaintext highlighter-rouge">CONTAINS</code>, and <code class="language-plaintext highlighter-rouge">EQUALS</code> to compare time periods and use them in conditional expressions for filtering and ordering.</p>

<p>It also includes table constraints to ensure the time period columns are an ordered pair of datetime/date fields that satisfy the bounds of the time interval when a <code class="language-plaintext highlighter-rouge">PERIOD</code> is declared. It also describes primary key and foreign key constraints applied to the time periods.</p>

<p>PostgreSQL does not yet implement temporal tables. There is ongoing work in <a href="https://commitfest.postgresql.org/patch/5660/">Patch #5660</a> to add support for application-time.</p>

<p>We won’t be using built-in database temporal features. Instead, we will be creating a temporal table by adding the ordered pair of time columns. We will manage the data in the application layer.</p>

<h1 id="trac">Trac</h1>

<p>Django uses <a href="https://trac.edgewall.org/wiki/TracTickets">Trac</a> for ticket management. When we visit a ticket page, we see two main sections: a ticket details section, and a change history section.</p>

<p>Figure 1 is a screenshot of ticket <a href="https://code.djangoproject.com/ticket/12090">#12090</a>. It shows the current state of the ticket, including the fields Reported By, Owned By, Cc, Triage Stage, Has Patch, etc.</p>

<p><img src="/assets/images/trac_ticket_detail.png" alt="Figure 1: Image Of Ticket Change History" title="Figure 1: Django Ticket Detail" /></p>

<p>The “Change History” section shows how the ticket has been modified over time. In Figure 2, we can see the values of ticket fields such as Status, Triage Stage, and Cc transition from their old value to new value. This section also includes comments and attachments that were uploaded to the ticket.</p>

<p><img src="/assets/images/trac_ticket_change_history.png" alt="Figure 2: Image Of Ticket Change History" title="Figure 2: Django Ticket Change History" /></p>

<p>A look into the <a href="https://github.com/edgewall/trac/blob/trunk/trac/db_default.py">Trac schema</a> shows two tables, <code class="language-plaintext highlighter-rouge">ticket</code> and <code class="language-plaintext highlighter-rouge">ticket_changes</code>. These tables correspond to the ticket details and change history sections. Here are their schemas:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">...</span>

<span class="n">Table</span><span class="p">(</span><span class="s">'ticket'</span><span class="p">,</span> <span class="n">key</span><span class="o">=</span><span class="s">'id'</span><span class="p">)[</span>
	<span class="n">Column</span><span class="p">(</span><span class="s">'id'</span><span class="p">,</span> <span class="n">auto_increment</span><span class="o">=</span><span class="bp">True</span><span class="p">),</span>
	<span class="n">Column</span><span class="p">(</span><span class="s">'type'</span><span class="p">),</span>
	<span class="n">Column</span><span class="p">(</span><span class="s">'time'</span><span class="p">,</span> <span class="nb">type</span><span class="o">=</span><span class="s">'int64'</span><span class="p">),</span>
	<span class="n">Column</span><span class="p">(</span><span class="s">'changetime'</span><span class="p">,</span> <span class="nb">type</span><span class="o">=</span><span class="s">'int64'</span><span class="p">),</span>
	<span class="n">Column</span><span class="p">(</span><span class="s">'component'</span><span class="p">),</span>
	<span class="n">Column</span><span class="p">(</span><span class="s">'severity'</span><span class="p">),</span>
	<span class="n">Column</span><span class="p">(</span><span class="s">'priority'</span><span class="p">),</span>
	<span class="n">Column</span><span class="p">(</span><span class="s">'owner'</span><span class="p">),</span>
	<span class="n">Column</span><span class="p">(</span><span class="s">'reporter'</span><span class="p">),</span>
	<span class="n">Column</span><span class="p">(</span><span class="s">'cc'</span><span class="p">),</span>
	<span class="n">Column</span><span class="p">(</span><span class="s">'version'</span><span class="p">),</span>
	<span class="n">Column</span><span class="p">(</span><span class="s">'milestone'</span><span class="p">),</span>
	<span class="n">Column</span><span class="p">(</span><span class="s">'status'</span><span class="p">),</span>
	<span class="n">Column</span><span class="p">(</span><span class="s">'resolution'</span><span class="p">),</span>
	<span class="n">Column</span><span class="p">(</span><span class="s">'summary'</span><span class="p">),</span>
	<span class="n">Column</span><span class="p">(</span><span class="s">'description'</span><span class="p">),</span>
	<span class="n">Column</span><span class="p">(</span><span class="s">'keywords'</span><span class="p">),</span>
	<span class="n">Index</span><span class="p">([</span><span class="s">'time'</span><span class="p">]),</span>
	<span class="n">Index</span><span class="p">([</span><span class="s">'status'</span><span class="p">])],</span>
<span class="n">Table</span><span class="p">(</span><span class="s">'ticket_change'</span><span class="p">,</span> <span class="n">key</span><span class="o">=</span><span class="p">(</span><span class="s">'ticket'</span><span class="p">,</span> <span class="s">'time'</span><span class="p">,</span> <span class="s">'field'</span><span class="p">))[</span>
	<span class="n">Column</span><span class="p">(</span><span class="s">'ticket'</span><span class="p">,</span> <span class="nb">type</span><span class="o">=</span><span class="s">'int'</span><span class="p">),</span>
	<span class="n">Column</span><span class="p">(</span><span class="s">'time'</span><span class="p">,</span> <span class="nb">type</span><span class="o">=</span><span class="s">'int64'</span><span class="p">),</span>
	<span class="n">Column</span><span class="p">(</span><span class="s">'author'</span><span class="p">),</span>
	<span class="n">Column</span><span class="p">(</span><span class="s">'field'</span><span class="p">),</span>
	<span class="n">Column</span><span class="p">(</span><span class="s">'oldvalue'</span><span class="p">),</span>
	<span class="n">Column</span><span class="p">(</span><span class="s">'newvalue'</span><span class="p">),</span>
	<span class="n">Index</span><span class="p">([</span><span class="s">'ticket'</span><span class="p">]),</span>
	<span class="n">Index</span><span class="p">([</span><span class="s">'time'</span><span class="p">])],</span>

<span class="p">...</span>
</code></pre></div></div>

<p>Notice that the <code class="language-plaintext highlighter-rouge">ticket_changes</code> table is not a temporal table. It only contains one time field. That is, it captures a point in time, rather than an interval of time. This makes it difficult to query for information such as the duration of something, or to know if something was valid at a given time. However, it is a log of data changes, and we can use it to reconstruct the temporal data.</p>

<h1 id="creating-temporal-table-for-triage-stages">Creating Temporal Table For Triage Stages</h1>

<p>Trac data is acquired by downloading the html pages for a ticket. Data for the ticket details section is extracted using BeautifulSoup. Data for the “Change History” section is extracted from a javascript variable called <code class="language-plaintext highlighter-rouge">changes</code>.</p>

<p>We are building a Django app. We create a <code class="language-plaintext highlighter-rouge">Ticket</code> model to store the ticket details. Additionally, we add a JSONField called <code class="language-plaintext highlighter-rouge">changes</code> to store the data as-is from the javascript <code class="language-plaintext highlighter-rouge">changes</code> variable .</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">django.db</span> <span class="kn">import</span> <span class="n">models</span>


<span class="k">class</span> <span class="nc">Ticket</span><span class="p">(</span><span class="n">models</span><span class="p">.</span><span class="n">Model</span><span class="p">):</span>
    <span class="n">cc</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="n">TextField</span><span class="p">()</span>
    <span class="n">changetime</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="n">DateTimeField</span><span class="p">(</span><span class="n">null</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="bp">None</span><span class="p">)</span>
    <span class="n">changes</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="n">JSONField</span><span class="p">(</span><span class="n">null</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="bp">None</span><span class="p">)</span>
    <span class="n">component</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">128</span><span class="p">)</span>
    <span class="n">description</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="n">TextField</span><span class="p">()</span>
    <span class="n">easy</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="n">BooleanField</span><span class="p">(</span><span class="n">default</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>
    <span class="n">has_patch</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="n">BooleanField</span><span class="p">(</span><span class="n">default</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>
    <span class="n">keywords</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="n">TextField</span><span class="p">()</span>
    <span class="n">needs_better_patch</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="n">BooleanField</span><span class="p">(</span><span class="n">default</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>
    <span class="n">needs_docs</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="n">BooleanField</span><span class="p">(</span><span class="n">default</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>
    <span class="n">needs_tests</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="n">BooleanField</span><span class="p">(</span><span class="n">default</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>
    <span class="n">owner</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">256</span><span class="p">)</span>
    <span class="n">reporter</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">256</span><span class="p">)</span>
    <span class="n">resolution</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">128</span><span class="p">)</span>
    <span class="n">severity</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">64</span><span class="p">)</span>
    <span class="n">stage</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">64</span><span class="p">)</span>
    <span class="n">status</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">64</span><span class="p">)</span>
    <span class="n">summary</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">256</span><span class="p">)</span>
    <span class="n">time</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="n">DateTimeField</span><span class="p">(</span><span class="n">null</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="bp">None</span><span class="p">)</span>
    <span class="nb">type</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">64</span><span class="p">)</span>
    <span class="n">ui_ux</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="n">BooleanField</span><span class="p">(</span><span class="n">default</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>
    <span class="n">version</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span>
</code></pre></div></div>

<p>Next, we create a QueueHistory model, and the underlying table for this model is our application-time versioned table. We add two datetime fields, <code class="language-plaintext highlighter-rouge">valid_from</code> and <code class="language-plaintext highlighter-rouge">valid_to</code>, and together they create the time period. The other fields in the QueueHistory model hold the information about the state of the development process during this application-time period.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">QueueHistory</span><span class="p">(</span><span class="n">models</span><span class="p">.</span><span class="n">Model</span><span class="p">):</span>
    <span class="n">ticket</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="n">ForeignKey</span><span class="p">(</span><span class="n">Ticket</span><span class="p">,</span> <span class="n">on_delete</span><span class="o">=</span><span class="n">models</span><span class="p">.</span><span class="n">CASCADE</span><span class="p">)</span>
    <span class="n">queue</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">30</span><span class="p">)</span>
    <span class="n">author</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">128</span><span class="p">)</span>
    <span class="n">has_patch</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="n">BooleanField</span><span class="p">(</span><span class="n">default</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>
    <span class="n">needs_better_patch</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="n">BooleanField</span><span class="p">(</span><span class="n">default</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>
    <span class="n">needs_docs</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="n">BooleanField</span><span class="p">(</span><span class="n">default</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>
    <span class="n">needs_tests</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="n">BooleanField</span><span class="p">(</span><span class="n">default</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>
    <span class="n">resolution</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">128</span><span class="p">)</span>
    <span class="n">stage</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">64</span><span class="p">)</span>
    <span class="n">status</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">64</span><span class="p">)</span>
    <span class="n">valid_from</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="n">DateTimeField</span><span class="p">()</span>
    <span class="n">valid_to</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="n">DateTimeField</span><span class="p">()</span>
</code></pre></div></div>

<p>PostgreSQL has <a href="https://www.postgresql.org/docs/current/rangetypes.html#RANGETYPES-BUILTIN%23:~:text=tstzrange">tstzrange</a> and <a href="https://www.postgresql.org/docs/current/rangetypes.html#RANGETYPES-BUILTIN#:~:text=daterange">daterange</a> data types, and Django supports these data types with the fields <a href="https://docs.djangoproject.com/en/5.2/ref/contrib/postgres/fields/#datetimerangefield">DateTimeRangeField</a> and <a href="https://docs.djangoproject.com/en/5.2/ref/contrib/postgres/fields/#daterangefield">DateRangeField</a>. We won’t be using this field type. We’ll be sticking to creating two separate datetime fields. The decision behind this lies in having the flexibility to switch between PostgreSQL and SQLite (which could come in handy if we choose to use Datasette or another tool later).</p>

<p>You may be wondering why this model is called QueueHistory instead of StageHistory. We’ll explain more in the next section, but ideally the queue and stage would be synonymous.</p>

<h1 id="insights-for-the-big-gray-area">Insights For The “Big Gray Area”</h1>

<p>We are building a standalone project, completely independent from Django’s Trac system. This provides flexibility to experiment with new stages.</p>

<p>With the current Django Triage Workflow, there are 3 triage stages:</p>
<ul>
  <li>Unreviewed</li>
  <li>Accepted</li>
  <li>Ready For Checkin</li>
</ul>

<p>There are also ticket <code class="language-plaintext highlighter-rouge">resolution</code> values to indicate why a ticket is closed: duplicate, wontfix, invalid, worksforme, needsinfo, fixed.</p>

<p>In our experiment, we will refer to stages as queues for two reasons: 1) avoid confusion with the original triage stages, and 2) ultimately we want queues to be a feature in the UI of the ticket management system.</p>

<p>We break the original Accepted stage into 3 new queues. We also separate the Needs Info resolution into its own queue, and we add Someday/Maybe as a queue as well.</p>

<p>Our queues are:</p>
<ul>
  <li>Unreviewed</li>
  <li>Needs Info</li>
  <li>Someday/Maybe</li>
  <li>Needs Patch</li>
  <li>Needs Review</li>
  <li>Waiting On Author</li>
  <li>Ready For Checkin</li>
  <li>Closed</li>
  <li>Unknown</li>
</ul>

<p>We define constants for our queue labels.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># constants.py
</span>
<span class="k">class</span> <span class="nc">QueueLabel</span><span class="p">:</span>
    <span class="n">UNREVIEWED</span> <span class="o">=</span> <span class="s">"Unreviewed"</span>

    <span class="c1"># Waiting On Info
</span>    <span class="n">NEEDS_INFO</span> <span class="o">=</span> <span class="s">"Needs Info"</span>
    <span class="n">SOMEDAY_MAYBE</span> <span class="o">=</span> <span class="s">"Someday/Maybe"</span>

    <span class="c1"># Accepted
</span>    <span class="n">NEEDS_PATCH</span> <span class="o">=</span> <span class="s">"Needs Patch"</span>
    <span class="n">NEEDS_REVIEW</span> <span class="o">=</span> <span class="s">"Needs Review"</span>
    <span class="n">WAITING_ON_AUTHOR</span> <span class="o">=</span> <span class="s">"Waiting On Author"</span>
    <span class="n">READY_FOR_CHECKIN</span> <span class="o">=</span> <span class="s">"Ready For Checkin"</span>

    <span class="c1"># Closed
</span>    <span class="n">CLOSED</span> <span class="o">=</span> <span class="s">"Closed"</span>

    <span class="c1"># When combination of fields do not correspond to any known queue
</span>    <span class="n">UNKNOWN</span> <span class="o">=</span> <span class="s">"Unknown"</span>
</code></pre></div></div>

<p>We define a function to calculate the queue value given a snapshot of the ticket state as determined by the following fields: <code class="language-plaintext highlighter-rouge">stage</code>, <code class="language-plaintext highlighter-rouge">status</code>, <code class="language-plaintext highlighter-rouge">has_patch</code>, <code class="language-plaintext highlighter-rouge">needs_better_patch</code>, <code class="language-plaintext highlighter-rouge">needs_docs</code>, and <code class="language-plaintext highlighter-rouge">needs_tests</code>.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">calculate_queue_value</span><span class="p">(</span><span class="n">snapshot</span><span class="p">):</span>
    <span class="k">def</span> <span class="nf">check_values</span><span class="p">(</span><span class="n">snapshot</span><span class="p">,</span> <span class="o">**</span><span class="n">kwargs</span><span class="p">):</span>
        <span class="n">boolean_fields</span> <span class="o">=</span> <span class="p">{</span><span class="s">"has_patch"</span><span class="p">,</span> <span class="s">"needs_better_patch"</span><span class="p">,</span> <span class="s">"needs_docs"</span><span class="p">,</span> <span class="s">"needs_tests"</span><span class="p">}</span>
        <span class="n">string_fields</span> <span class="o">=</span> <span class="p">{</span><span class="s">"stage"</span><span class="p">,</span> <span class="s">"status"</span><span class="p">}</span>
        <span class="k">return</span> <span class="nb">all</span><span class="p">([</span>
            <span class="n">snapshot</span><span class="p">[</span><span class="n">k</span><span class="p">]</span> <span class="ow">is</span> <span class="n">v</span>
            <span class="k">for</span> <span class="n">k</span><span class="p">,</span> <span class="n">v</span> <span class="ow">in</span> <span class="n">kwargs</span><span class="p">.</span><span class="n">items</span><span class="p">()</span>
            <span class="k">if</span> <span class="n">k</span> <span class="ow">in</span> <span class="n">boolean_fields</span>
        <span class="p">])</span> <span class="ow">and</span> <span class="nb">all</span><span class="p">([</span>
            <span class="n">snapshot</span><span class="p">[</span><span class="n">k</span><span class="p">].</span><span class="n">lower</span><span class="p">()</span> <span class="o">==</span> <span class="n">v</span><span class="p">.</span><span class="n">lower</span><span class="p">()</span>
            <span class="k">for</span> <span class="n">k</span><span class="p">,</span> <span class="n">v</span> <span class="ow">in</span> <span class="n">kwargs</span><span class="p">.</span><span class="n">items</span><span class="p">()</span>
            <span class="k">if</span> <span class="n">k</span> <span class="ow">in</span> <span class="n">string_fields</span>
        <span class="p">])</span>

    <span class="k">if</span> <span class="n">check_values</span><span class="p">(</span><span class="n">snapshot</span><span class="p">,</span> <span class="n">stage</span><span class="o">=</span><span class="s">"Unreviewed"</span><span class="p">):</span>
        <span class="k">return</span> <span class="n">QueueLabel</span><span class="p">.</span><span class="n">UNREVIEWED</span>

    <span class="k">if</span> <span class="n">check_values</span><span class="p">(</span><span class="n">snapshot</span><span class="p">,</span> <span class="n">has_patch</span><span class="o">=</span><span class="bp">False</span><span class="p">,</span> <span class="n">status</span><span class="o">=</span><span class="s">"Closed"</span><span class="p">,</span> <span class="n">resolution</span><span class="o">=</span><span class="s">"needsinfo"</span><span class="p">):</span>
        <span class="k">return</span> <span class="n">QueueLabel</span><span class="p">.</span><span class="n">NEEDS_INFO</span>

    <span class="k">if</span> <span class="n">check_values</span><span class="p">(</span><span class="n">snapshot</span><span class="p">,</span> <span class="n">status</span><span class="o">=</span><span class="s">"Closed"</span><span class="p">):</span>
        <span class="k">return</span> <span class="n">QueueLabel</span><span class="p">.</span><span class="n">CLOSED_FIXED</span>

    <span class="k">if</span> <span class="n">check_values</span><span class="p">(</span><span class="n">snapshot</span><span class="p">,</span> <span class="n">stage</span><span class="o">=</span><span class="s">"Someday/Maybe"</span><span class="p">):</span>
        <span class="k">return</span> <span class="n">QueueLabel</span><span class="p">.</span><span class="n">SOMEDAY_MAYBE</span>

    <span class="k">if</span> <span class="n">check_values</span><span class="p">(</span><span class="n">snapshot</span><span class="p">,</span> <span class="n">stage</span><span class="o">=</span><span class="s">"Ready for checkin"</span><span class="p">):</span>
        <span class="k">return</span> <span class="n">QueueLabel</span><span class="p">.</span><span class="n">READY_FOR_CHECKIN</span>

    <span class="k">if</span> <span class="n">check_values</span><span class="p">(</span><span class="n">snapshot</span><span class="p">,</span> <span class="n">stage</span><span class="o">=</span><span class="s">"Accepted"</span><span class="p">,</span> <span class="n">has_patch</span><span class="o">=</span><span class="bp">False</span><span class="p">):</span>
        <span class="k">return</span> <span class="n">QueueLabel</span><span class="p">.</span><span class="n">NEEDS_PATCH</span>

    <span class="k">if</span> <span class="n">check_values</span><span class="p">(</span><span class="n">snapshot</span><span class="p">,</span> <span class="n">stage</span><span class="o">=</span><span class="s">"Accepted"</span><span class="p">,</span> <span class="n">has_patch</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="n">needs_doc</span><span class="o">=</span><span class="bp">False</span><span class="p">,</span> <span class="n">needs_tests</span><span class="o">=</span><span class="bp">False</span><span class="p">,</span> <span class="n">needs_better_patch</span><span class="o">=</span><span class="bp">False</span><span class="p">):</span>
        <span class="k">return</span> <span class="n">QueueLabel</span><span class="p">.</span><span class="n">NEEDS_REVIEW</span>

    <span class="k">if</span> <span class="n">check_values</span><span class="p">(</span><span class="n">snapshot</span><span class="p">,</span> <span class="n">stage</span><span class="o">=</span><span class="s">"Accepted"</span><span class="p">,</span> <span class="n">has_patch</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="n">needs_better_patch</span><span class="o">=</span><span class="bp">True</span><span class="p">):</span>
        <span class="k">return</span> <span class="n">QueueLabel</span><span class="p">.</span><span class="n">WAITING_ON_AUTHOR</span>

    <span class="k">return</span> <span class="n">QueueLabel</span><span class="p">.</span><span class="n">UNKNOWN</span>
</code></pre></div></div>

<h1 id="reconstructing-queue-history">Reconstructing Queue History</h1>

<p>To reconstruct the queue history, we loop through each change item from the <code class="language-plaintext highlighter-rouge">Ticket.changes</code> field to gather the timestamps to fill in the <code class="language-plaintext highlighter-rouge">valid_from</code> and <code class="language-plaintext highlighter-rouge">valid_to</code> values, and we gather the old and new values of fields to get the ticket state snapshots.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">reconstruct_queue_history</span><span class="p">(</span><span class="n">data</span><span class="p">):</span>
    <span class="s">"""
    Creates a list of dicts with the following fields:
        "ticket_id",
        "author",
        "queue",
        "has_patch"
        "needs_better_patch",
        "needs_docs",
        "needs_tests",
        "resolution",
        "stage",
        "status",
        "valid_from",
        "valid_to",
    These dicts are used to create QueueHistory.
    """</span>
    <span class="c1"># Fields that impact queue
</span>    <span class="n">fields</span> <span class="o">=</span> <span class="p">[</span>
        <span class="s">"has_patch"</span><span class="p">,</span>
        <span class="s">"needs_better_patch"</span><span class="p">,</span>
        <span class="s">"needs_docs"</span><span class="p">,</span>
        <span class="s">"needs_tests"</span><span class="p">,</span>
        <span class="s">"stage"</span><span class="p">,</span>
        <span class="s">"status"</span><span class="p">,</span>
    <span class="p">]</span>

    <span class="c1"># Only consider the changes that have modified fields.
</span>    <span class="c1"># Skip changes where the fields being modified do not impact the ticket's queue.
</span>    <span class="n">changes</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="k">for</span> <span class="n">change</span> <span class="ow">in</span> <span class="n">data</span><span class="p">[</span><span class="s">"changes"</span><span class="p">]:</span>
        <span class="n">modified_queue_fields</span> <span class="o">=</span> <span class="nb">set</span><span class="p">(</span><span class="n">fields</span><span class="p">)</span> <span class="o">&amp;</span> <span class="nb">set</span><span class="p">(</span><span class="n">change</span><span class="p">[</span><span class="s">"fields"</span><span class="p">].</span><span class="n">keys</span><span class="p">())</span>
        <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">modified_queue_fields</span><span class="p">):</span>
            <span class="n">changes</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">change</span><span class="p">)</span>
    <span class="n">changes</span> <span class="o">=</span> <span class="nb">sorted</span><span class="p">(</span><span class="n">changes</span><span class="p">,</span> <span class="n">key</span><span class="o">=</span><span class="k">lambda</span> <span class="n">elem</span><span class="p">:</span> <span class="n">elem</span><span class="p">[</span><span class="s">"date"</span><span class="p">],</span> <span class="n">reverse</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
    <span class="n">changes</span><span class="p">.</span><span class="n">append</span><span class="p">({</span>
        <span class="s">"date"</span><span class="p">:</span> <span class="n">data</span><span class="p">[</span><span class="s">"ticket_details"</span><span class="p">][</span><span class="s">"time"</span><span class="p">],</span>
        <span class="s">"author"</span><span class="p">:</span> <span class="n">data</span><span class="p">[</span><span class="s">"ticket_details"</span><span class="p">][</span><span class="s">"reporter"</span><span class="p">],</span>
    <span class="p">})</span>

    <span class="c1"># Work backwards to reconstruct the history of the field values.
</span>    <span class="n">cur_values</span> <span class="o">=</span> <span class="p">{</span>
        <span class="n">field</span><span class="p">:</span> <span class="n">data</span><span class="p">[</span><span class="s">"ticket_details"</span><span class="p">][</span><span class="n">field</span><span class="p">]</span> <span class="k">for</span> <span class="n">field</span> <span class="ow">in</span> <span class="n">fields</span>
    <span class="p">}</span>
    <span class="n">cur_values</span><span class="p">[</span><span class="s">"ticket_id"</span><span class="p">]</span> <span class="o">=</span> <span class="n">data</span><span class="p">[</span><span class="s">"ticket_details"</span><span class="p">][</span><span class="s">"id"</span><span class="p">]</span>
    <span class="n">cur_values</span><span class="p">[</span><span class="s">"valid_from"</span><span class="p">]</span> <span class="o">=</span> <span class="n">changes</span><span class="p">[</span><span class="mi">0</span><span class="p">][</span><span class="s">"date"</span><span class="p">]</span>
    <span class="n">cur_values</span><span class="p">[</span><span class="s">"valid_to"</span><span class="p">]</span> <span class="o">=</span> <span class="n">datetime</span><span class="p">.</span><span class="nb">max</span>
    <span class="n">cur_values</span><span class="p">[</span><span class="s">"author"</span><span class="p">]</span> <span class="o">=</span> <span class="n">changes</span><span class="p">[</span><span class="mi">0</span><span class="p">][</span><span class="s">"author"</span><span class="p">]</span>
    <span class="n">queue_history</span> <span class="o">=</span> <span class="p">[{</span><span class="n">k</span><span class="p">:</span> <span class="n">v</span> <span class="k">for</span> <span class="n">k</span><span class="p">,</span> <span class="n">v</span> <span class="ow">in</span> <span class="n">cur_values</span><span class="p">.</span><span class="n">items</span><span class="p">()}]</span>

    <span class="c1"># Pair the changes to get the date range.
</span>    <span class="k">for</span> <span class="n">change</span><span class="p">,</span> <span class="n">prev_change</span> <span class="ow">in</span> <span class="nb">zip</span><span class="p">(</span><span class="n">changes</span><span class="p">,</span> <span class="n">changes</span><span class="p">[</span><span class="mi">1</span><span class="p">:]):</span>
        <span class="n">cur_values</span><span class="p">[</span><span class="s">"author"</span><span class="p">]</span> <span class="o">=</span> <span class="n">change</span><span class="p">[</span><span class="s">"author"</span><span class="p">]</span>
        <span class="n">cur_values</span><span class="p">[</span><span class="s">"valid_from"</span><span class="p">]</span> <span class="o">=</span> <span class="n">prev_change</span><span class="p">[</span><span class="s">"date"</span><span class="p">]</span>
        <span class="n">cur_values</span><span class="p">[</span><span class="s">"valid_to"</span><span class="p">]</span> <span class="o">=</span> <span class="n">change</span><span class="p">[</span><span class="s">"date"</span><span class="p">]</span>
        <span class="n">modified_queue_fields</span> <span class="o">=</span> <span class="nb">set</span><span class="p">(</span><span class="n">fields</span><span class="p">)</span> <span class="o">&amp;</span> <span class="nb">set</span><span class="p">(</span><span class="n">change</span><span class="p">[</span><span class="s">"fields"</span><span class="p">].</span><span class="n">keys</span><span class="p">())</span>
        <span class="k">for</span> <span class="n">field</span> <span class="ow">in</span> <span class="n">modified_queue_fields</span><span class="p">:</span>
            <span class="n">old_value</span> <span class="o">=</span> <span class="n">change</span><span class="p">[</span><span class="s">"fields"</span><span class="p">][</span><span class="n">field</span><span class="p">][</span><span class="s">"old"</span><span class="p">]</span>
            <span class="n">cur_values</span><span class="p">[</span><span class="n">field</span><span class="p">]</span> <span class="o">=</span> <span class="n">old_value</span>

        <span class="c1"># Add to history
</span>        <span class="n">queue_history</span><span class="p">.</span><span class="n">append</span><span class="p">({</span><span class="n">k</span><span class="p">:</span> <span class="n">v</span> <span class="k">for</span> <span class="n">k</span><span class="p">,</span> <span class="n">v</span> <span class="ow">in</span> <span class="n">cur_values</span><span class="p">.</span><span class="n">items</span><span class="p">()})</span>

    <span class="n">queue_history</span> <span class="o">=</span> <span class="nb">sorted</span><span class="p">(</span><span class="n">queue_history</span><span class="p">,</span> <span class="n">key</span><span class="o">=</span><span class="k">lambda</span> <span class="n">elem</span><span class="p">:</span> <span class="n">elem</span><span class="p">[</span><span class="s">"valid_from"</span><span class="p">])</span>
    <span class="k">return</span> <span class="n">queue_history</span>
</code></pre></div></div>

<p>The QueueHistory objects are created for a ticket and saved to the database. We can run a query to retrieve the QueueHistory items, order by <code class="language-plaintext highlighter-rouge">valid_from</code>, and render the data in a chart.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># views.py
</span><span class="k">def</span> <span class="nf">ticket_detail</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="n">pk</span><span class="p">):</span>
    <span class="n">item</span> <span class="o">=</span> <span class="n">Ticket</span><span class="p">.</span><span class="n">objects</span><span class="p">.</span><span class="nb">filter</span><span class="p">(</span><span class="n">pk</span><span class="o">=</span><span class="n">pk</span><span class="p">).</span><span class="n">first</span><span class="p">()</span>
    <span class="n">queue_histories</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="k">if</span> <span class="n">item</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span><span class="p">:</span>
        <span class="n">queue_histories</span> <span class="o">=</span> <span class="n">QueueHistory</span><span class="p">.</span><span class="n">objects</span><span class="p">.</span><span class="nb">filter</span><span class="p">(</span>
            <span class="n">ticket</span><span class="o">=</span><span class="n">item</span>
        <span class="p">).</span><span class="n">annotate</span><span class="p">(</span>
            <span class="n">valid_to_cleaned</span><span class="o">=</span><span class="n">Case</span><span class="p">(</span>
                <span class="n">When</span><span class="p">(</span>
                    <span class="n">valid_to__year</span><span class="o">=</span><span class="mi">9999</span><span class="p">,</span>
                    <span class="n">then</span><span class="o">=</span><span class="n">datetime</span><span class="p">.</span><span class="n">now</span><span class="p">()</span> <span class="o">+</span> <span class="n">timedelta</span><span class="p">(</span><span class="n">days</span><span class="o">=</span><span class="mi">1</span><span class="p">),</span>
                <span class="p">),</span>
                <span class="n">default</span><span class="o">=</span><span class="n">F</span><span class="p">(</span><span class="s">"valid_to"</span><span class="p">),</span>
            <span class="p">),</span>
        <span class="p">).</span><span class="n">order_by</span><span class="p">(</span><span class="s">"valid_from"</span><span class="p">)</span>

    <span class="n">template</span> <span class="o">=</span> <span class="s">"trac/ticket_detail.html"</span>
    <span class="k">return</span> <span class="n">render</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="n">template</span><span class="p">,</span> <span class="p">{</span><span class="s">"item"</span><span class="p">:</span> <span class="n">item</span><span class="p">,</span> <span class="s">"queue_histories"</span><span class="p">:</span> <span class="n">queue_histories</span><span class="p">})</span>
</code></pre></div></div>

<p>We use ChartJS to create a Gantt chart with time on the x-axis and the queue labels on the y-axis. We no longer have a big gray area. In the example below, we can see how that the ticket transitions between Waiting On Author and Needs Review eight times before it lands on Ready For Checkin. We can also see the how much time the ticket spends in each queue.</p>

<p><img src="/assets/images/ticket_34917_queue_timeline.png" alt="Figure 3: Queue Timeline For Ticket 36917" title="Figure 3: Queue Timeline For Ticket 36917" /></p>

<p>This gives us a clearer picture of what goes on during the development process.</p>
<h1 id="next-steps">Next Steps</h1>

<p>We’ll want to aggregate the data into reports on the component level and queue level.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Temporal Table]]></summary></entry><entry><title type="html">Searching Django Contributor Activities</title><link href="http://localhost:4000/2025/05/19/searching-django-contributor-activities.html" rel="alternate" type="text/html" title="Searching Django Contributor Activities" /><published>2025-05-19T00:00:00-07:00</published><updated>2025-05-19T00:00:00-07:00</updated><id>http://localhost:4000/2025/05/19/searching-django-contributor-activities</id><content type="html" xml:base="http://localhost:4000/2025/05/19/searching-django-contributor-activities.html"><![CDATA[<p>The search tab on Django’s Trac ticket management system is a powerful tool. It can search for git commits that were merged into the main or release branches. It can search for content on the wiki page. It can also search on multiple fields on tickets.</p>

<p>In this <a href="https://youtu.be/NaHzmw31aoA">video</a>, I walk through the specific use case of looking for activities from a contributor. I also demonstrate a <a href="https://django-contributor.ontowhee.com/trac/contributor/">tool</a> that could make it easier to conduct such searches.</p>

<h2 id="searching-on-names">Searching On Names</h2>

<p>The search can be used by contributors looking for tickets mentioning specific phrases, or it can also be used by working groups who are looking for activities from contributors.</p>

<p>Here’s a screenshot of looking up a contributor by their name.</p>

<p><img src="/assets/images/django-trac-search-user.png" alt="Django Trac Search For A User" /></p>

<p>The search is able to match on search terms across multiple fields including summary, description, reporter, owner, cc, and comment contents.</p>

<p>However, the results of one search may not provide the full picture about a contributor. For example, searching on the user display name returns different results than searching on the username. This means we have to try different search terms before we can start to get a complete story. We have to be aware of the nuances between username and display name in the first place! That is not quite apparent in the web interface.</p>

<p>Also, the presentation of the results are very raw and not easy to digest. A better mechanism will save a lot of time for looking up a contributor.</p>

<h2 id="looking-up-a-django-contributor-profile">Looking Up A Django Contributor Profile</h2>

<p>I’m working on a tool, and one of the features allows you to search for a “Django Contributor Profile”. Here’s a screenshot of a contributor’s profile.</p>

<p><img src="/assets/images/django-contributor-profile.png" alt="Django Contributor Profile" /></p>

<p>It provides a summary of the contributor’s activities. The timeline chart gives a sense of how the contributor has been participating on Trac in the recent times. There is also information on which Triage Stage and which component (contrib.admin, Migrations, Testing Framework, etc.) they are most engaged in. This gives an idea of the contributor’s main roles and areas of expertise.</p>

<p>There is a table with a list of tickets. For each ticket, it shows whether the contributor was the reporter, owner, and how many comments they authored on that ticket.</p>

<h2 id="next-steps">Next Steps</h2>

<p>We can get a good sense of a contributor’s involvement with Django. The next step would be to provide access to information on the component and community levels.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[The search tab on Django’s Trac ticket management system is a powerful tool. It can search for git commits that were merged into the main or release branches. It can search for content on the wiki page. It can also search on multiple fields on tickets.]]></summary></entry><entry><title type="html">Dataclass For Django Custom Command Arguments</title><link href="http://localhost:4000/2025/05/14/dataclass-for-django-custom-command-arguments.html" rel="alternate" type="text/html" title="Dataclass For Django Custom Command Arguments" /><published>2025-05-14T00:00:00-07:00</published><updated>2025-05-14T00:00:00-07:00</updated><id>http://localhost:4000/2025/05/14/dataclass-for-django-custom-command-arguments</id><content type="html" xml:base="http://localhost:4000/2025/05/14/dataclass-for-django-custom-command-arguments.html"><![CDATA[<h2 id="dataclasses">Dataclasses</h2>

<p>I was cleaning up a custom Django command yesterday and ended using a dataclass as a way to keep the code DRY.</p>

<p>If dataclasses are new to you, don’t worry. Dataclasses are similar to classes, but they have boilerplates that handle many common method and attribute definitions for you. For example, instead of you having to explicitly define the constructor and assign attributes such as below,</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">MyClass</span><span class="p">:</span>
    <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">param1</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">param2</span><span class="p">:</span> <span class="nb">int</span><span class="p">,</span> <span class="p">...):</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">param1</span> <span class="o">=</span> <span class="n">param1</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">param2</span> <span class="o">=</span> <span class="n">param2</span>
        <span class="p">...</span>
</code></pre></div></div>

<p>this code is already defined and hidden away. You just need to define the fields.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">@</span><span class="n">dataclass</span>
<span class="k">class</span> <span class="nc">MyClass</span><span class="p">:</span>
    <span class="n">param1</span><span class="p">:</span> <span class="nb">str</span>
    <span class="n">param2</span><span class="p">:</span> <span class="nb">int</span>
    <span class="p">...</span>
</code></pre></div></div>

<p>There are many useful features of dataclasses. In my specific use case, I leveraged a dataclass for two purposes:</p>
<ul>
  <li>To group of a set of variables and logic that are commonly used together.</li>
  <li>To centralize default value definitions.</li>
</ul>

<h2 id="the-original-custom-command">The Original Custom Command</h2>

<p>My custom command allows me to scrape a website. It takes in arguments from the command line and builds the url query string for the website. I can run a command like this:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>python manage.py scrape <span class="nt">--start-date</span><span class="o">=</span>2025-05-01 <span class="nt">--end-date</span><span class="o">=</span>2025-05-14 <span class="nt">--max-items-per-page</span><span class="o">=</span>20
</code></pre></div></div>

<p>None of the arguments are required, but they have default values whose constants are defined near the top of the file.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">django.core.management.base</span> <span class="kn">import</span> <span class="n">BaseCommand</span>


<span class="n">BASE_URL</span> <span class="o">=</span> <span class="s">"https://code.djangoproject.com/query"</span>
<span class="n">DATE_STR</span> <span class="o">=</span> <span class="p">(</span><span class="n">datetime</span><span class="p">.</span><span class="n">now</span><span class="p">()</span> <span class="o">-</span> <span class="n">timedelta</span><span class="p">(</span><span class="n">days</span><span class="o">=</span><span class="mi">3</span><span class="p">)).</span><span class="n">strftime</span><span class="p">(</span><span class="s">"%Y-%m-%d"</span><span class="p">)</span>
<span class="n">MAX_ITEMS_PER_PAGE</span> <span class="o">=</span> <span class="mi">100</span>
<span class="n">START_PAGE</span> <span class="o">=</span> <span class="mi">1</span>

<span class="p">...</span>
</code></pre></div></div>

<p>The arguments are registered with their default values by invoking <code class="language-plaintext highlighter-rouge">parser.add_argument(..., default=&lt;CONSTANT&gt;, ...)</code>.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">...</span>

<span class="k">class</span> <span class="nc">Command</span><span class="p">(</span><span class="n">BaseCommand</span><span class="p">):</span>

    <span class="k">def</span> <span class="nf">add_arguments</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">parser</span><span class="p">):</span>
        <span class="n">parser</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"--start-date"</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="n">DATE_STR</span><span class="p">,</span> <span class="n">help</span><span class="o">=</span><span class="s">"Start date in format YYYY-mm-dd"</span><span class="p">)</span>
        <span class="n">parser</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"--end-date"</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="s">""</span><span class="p">,</span> <span class="n">help</span><span class="o">=</span><span class="s">"End date in format YYYY-mm-dd"</span><span class="p">)</span>
        <span class="n">parser</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"--start-page"</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="n">START_PAGE</span><span class="p">,</span> <span class="nb">type</span><span class="o">=</span><span class="nb">int</span><span class="p">)</span>
        <span class="n">parser</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"--max-items-per-page"</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="n">MAX_ITEMS_PER_PAGE</span><span class="p">,</span> <span class="nb">type</span><span class="o">=</span><span class="nb">int</span><span class="p">)</span>

<span class="p">...</span>
</code></pre></div></div>

<p>When the command is run, it instantiates an object for the scraper class using the values from the command line arguments.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">...</span>

<span class="k">class</span> <span class="nc">Command</span><span class="p">(</span><span class="n">BaseCommand</span><span class="p">):</span>
	<span class="p">...</span>

    <span class="k">def</span> <span class="nf">handle</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="o">*</span><span class="n">args</span><span class="p">,</span> <span class="o">**</span><span class="n">options</span><span class="p">):</span>
        <span class="n">scraper</span> <span class="o">=</span> <span class="n">TracScraper</span><span class="p">(</span>
            <span class="n">options</span><span class="p">[</span><span class="s">"start_date"</span><span class="p">],</span>
            <span class="n">options</span><span class="p">[</span><span class="s">"end_date"</span><span class="p">],</span>
            <span class="n">options</span><span class="p">[</span><span class="s">"start_page"</span><span class="p">],</span>
            <span class="n">options</span><span class="p">[</span><span class="s">"max_items_per_page"</span><span class="p">],</span>
        <span class="p">)</span>
        <span class="n">scraper</span><span class="p">.</span><span class="n">scrape</span><span class="p">()</span>

<span class="p">...</span>
</code></pre></div></div>

<p>The constructor for the scraper class consists of keyword arguments which are used to initialize the object attributes. The default values for the keyword arguments are coming from the constants defined near the top of the file. The constructor also includes logic to build the url query string from the attributes.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">...</span>

<span class="k">class</span> <span class="nc">TracScraper</span><span class="p">:</span>
    <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">start_date</span><span class="o">=</span><span class="n">DATE_STR</span><span class="p">,</span> <span class="n">end_date</span><span class="o">=</span><span class="n">DATE_STR</span><span class="p">,</span> <span class="n">start_page</span><span class="o">=</span><span class="n">START_PAGE</span><span class="p">,</span> <span class="n">max_items_per_page</span><span class="o">=</span><span class="n">MAX_ITEMS_PER_PAGE</span><span class="p">):</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">start_date</span> <span class="o">=</span> <span class="n">start_date</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">end_date</span> <span class="o">=</span> <span class="n">end_date</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">start_page</span> <span class="o">=</span> <span class="n">start_page</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">max_items_per_page</span> <span class="o">=</span> <span class="n">max_items_per_page</span>

        <span class="n">query</span> <span class="o">=</span> <span class="sa">f</span><span class="s">"changetime=</span><span class="si">{</span><span class="bp">self</span><span class="p">.</span><span class="n">start_date</span><span class="si">}</span><span class="s">..</span><span class="si">{</span><span class="bp">self</span><span class="p">.</span><span class="n">end_date</span><span class="si">}</span><span class="s">&amp;start_page=</span><span class="si">{</span><span class="bp">self</span><span class="p">.</span><span class="n">start_page</span><span class="si">}</span><span class="s">&amp;max_items_per_page=</span><span class="si">{</span><span class="bp">self</span><span class="p">.</span><span class="n">max_items_per_page</span><span class="si">}</span><span class="s">"</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">url</span> <span class="o">=</span> <span class="sa">f</span><span class="s">"</span><span class="si">{</span><span class="n">BASE_URL</span><span class="si">}</span><span class="s">?</span><span class="si">{</span><span class="n">query</span><span class="si">}</span><span class="s">"</span>

    <span class="k">def</span> <span class="nf">scrape</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
        <span class="p">...</span>
</code></pre></div></div>

<p>This seems typical for a custom command. However, there are a few places of redundancy.</p>
<ul>
  <li>While global constants are used to define the default values, referencing them in the function signature of <code class="language-plaintext highlighter-rouge">TracScraper.__init__()</code> can feel unwieldy and repetitive when there is a much larger number of command line arguments.</li>
  <li>When another scraper class is introduced for the same website, not only will it likely have a similar function signature for its constructor class, but it will also need the logic for building the url.</li>
</ul>

<h2 id="using-dataclass">Using Dataclass</h2>

<p>I now use a dataclass that includes a field for each command line argument. It also includes the <code class="language-plaintext highlighter-rouge">base_url</code>. The global constants near the top of the file have been removed and their values are set directly on the fields.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">dataclasses</span> <span class="kn">import</span> <span class="n">dataclass</span>
<span class="kn">from</span> <span class="nn">django.core.management.base</span> <span class="kn">import</span> <span class="n">BaseCommand</span>


<span class="o">@</span><span class="n">dataclass</span>
<span class="k">class</span> <span class="nc">Params</span><span class="p">:</span>
    <span class="n">start_date</span><span class="p">:</span> <span class="nb">str</span> <span class="o">=</span> <span class="p">(</span><span class="n">datetime</span><span class="p">.</span><span class="n">now</span><span class="p">()</span> <span class="o">-</span> <span class="n">timedelta</span><span class="p">(</span><span class="n">days</span><span class="o">=</span><span class="mi">3</span><span class="p">)).</span><span class="n">strftime</span><span class="p">(</span><span class="s">"%Y-%m-%d"</span><span class="p">)</span>
    <span class="n">end_date</span><span class="p">:</span> <span class="nb">str</span> <span class="o">=</span> <span class="s">""</span>
    <span class="n">max_items_per_page</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="mi">100</span>
    <span class="n">start_page</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="mi">1</span>

    <span class="n">base_url</span><span class="p">:</span> <span class="nb">str</span> <span class="o">=</span> <span class="s">"https://code.djangoproject.com/query"</span>

    <span class="p">...</span>

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

<p>I add a field for <code class="language-plaintext highlighter-rouge">start_url</code> that defaults to an empty string. Then in the <code class="language-plaintext highlighter-rouge">__post_init__()</code> method, I included the logic that builds and assigns the query string to <code class="language-plaintext highlighter-rouge">start_url</code>.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">...</span>

<span class="o">@</span><span class="n">dataclass</span>
<span class="k">class</span> <span class="nc">Params</span><span class="p">:</span>
    <span class="p">...</span>

    <span class="n">start_url</span><span class="p">:</span> <span class="nb">str</span> <span class="o">=</span> <span class="s">""</span>

    <span class="k">def</span> <span class="nf">__post_init__</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
        <span class="n">query_params</span> <span class="o">=</span> <span class="sa">f</span><span class="s">"changetime=</span><span class="si">{</span><span class="bp">self</span><span class="p">.</span><span class="n">start_date</span><span class="si">}</span><span class="s">..</span><span class="si">{</span><span class="bp">self</span><span class="p">.</span><span class="n">end_date</span><span class="si">}</span><span class="s">&amp;max=</span><span class="si">{</span><span class="bp">self</span><span class="p">.</span><span class="n">max_items_per_page</span><span class="si">}</span><span class="s">&amp;page=</span><span class="si">{</span><span class="bp">self</span><span class="p">.</span><span class="n">start_page</span><span class="si">}</span><span class="s">"</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">start_url</span> <span class="o">=</span> <span class="sa">f</span><span class="s">"</span><span class="si">{</span><span class="bp">self</span><span class="p">.</span><span class="n">base_url</span><span class="si">}</span><span class="s">&amp;</span><span class="si">{</span><span class="n">query_params</span><span class="si">}</span><span class="s">"</span>

<span class="p">...</span>
</code></pre></div></div>

<p>When registering the command line arguments, an instance of the <code class="language-plaintext highlighter-rouge">Params</code> dataclass is created with all the fields assuming their default values.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">Command</span><span class="p">(</span><span class="n">BaseCommand</span><span class="p">):</span>

    <span class="k">def</span> <span class="nf">add_arguments</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">parser</span><span class="p">):</span>
        <span class="c1"># Instantiate a dataclass where all fields assume their default values.
</span>        <span class="n">p</span> <span class="o">=</span> <span class="n">Params</span><span class="p">()</span>

        <span class="n">parser</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"--start-date"</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="n">p</span><span class="p">.</span><span class="n">start_date</span><span class="p">,</span> <span class="n">help</span><span class="o">=</span><span class="s">"Start date in format YYYY-mm-dd"</span><span class="p">)</span>
        <span class="n">parser</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"--end-date"</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="n">p</span><span class="p">.</span><span class="n">end_date</span> <span class="n">help</span><span class="o">=</span><span class="s">"End date in format YYYY-mm-dd"</span><span class="p">)</span>
        <span class="n">parser</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"--start-page"</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="n">p</span><span class="p">.</span><span class="n">start_page</span><span class="p">,</span> <span class="nb">type</span><span class="o">=</span><span class="nb">int</span><span class="p">)</span>
        <span class="n">parser</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"--max-items-per-page"</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="n">p</span><span class="p">.</span><span class="n">max_items_per_page</span><span class="p">,</span> <span class="nb">type</span><span class="o">=</span><span class="nb">int</span><span class="p">)</span>

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

<p>When the command is run, it instantiates an object for the dataclass using the values from the command line arguments, then passes this <code class="language-plaintext highlighter-rouge">Params</code> object to instantiate the scraper class.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">Command</span><span class="p">(</span><span class="n">BaseCommand</span><span class="p">):</span>
    <span class="p">...</span>

    <span class="k">def</span> <span class="nf">handle</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="o">*</span><span class="n">args</span><span class="p">,</span> <span class="o">**</span><span class="n">options</span><span class="p">):</span>
        <span class="n">p</span> <span class="o">=</span> <span class="n">Params</span><span class="p">(</span>
            <span class="n">options</span><span class="p">[</span><span class="s">"start_date"</span><span class="p">],</span>
            <span class="n">options</span><span class="p">[</span><span class="s">"end_date"</span><span class="p">],</span>
            <span class="n">options</span><span class="p">[</span><span class="s">"start_page"</span><span class="p">],</span>
            <span class="n">options</span><span class="p">[</span><span class="s">"max_items_per_page"</span><span class="p">]</span>
        <span class="p">)</span>
        <span class="n">scraper</span> <span class="o">=</span> <span class="n">TracScraper</span><span class="p">(</span><span class="n">p</span><span class="p">)</span>
        <span class="n">scraper</span><span class="p">.</span><span class="n">scrape</span><span class="p">()</span>
</code></pre></div></div>

<p>The constructor for the scraper class now consists of just the <code class="language-plaintext highlighter-rouge">Params</code> dataclass. It no longer needs to be concerned about defining the function signature with keyword arguments and their corresponding default values. It also doesn’t need to be concerned about the logic for building the url query string. All the variables are grouped together, including the <code class="language-plaintext highlighter-rouge">start_url</code> variable derived from the command line arguments.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">TracScraper</span><span class="p">:</span>
    <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">params</span><span class="p">:</span> <span class="n">Param</span><span class="p">):</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">params</span> <span class="o">=</span> <span class="n">params</span>

    <span class="k">def</span> <span class="nf">scrape</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
        <span class="p">...</span>
</code></pre></div></div>

<h2 id="full-code">Full Code</h2>

<p>Here is the original custom command file:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">django.core.management.base</span> <span class="kn">import</span> <span class="n">BaseCommand</span>


<span class="n">BASE_URL</span> <span class="o">=</span> <span class="s">"https://code.djangoproject.com/query"</span>
<span class="n">DATE_STR</span> <span class="o">=</span> <span class="p">(</span><span class="n">datetime</span><span class="p">.</span><span class="n">now</span><span class="p">()</span> <span class="o">-</span> <span class="n">timedelta</span><span class="p">(</span><span class="n">days</span><span class="o">=</span><span class="mi">3</span><span class="p">)).</span><span class="n">strftime</span><span class="p">(</span><span class="s">"%Y-%m-%d"</span><span class="p">)</span>
<span class="n">MAX_ITEMS_PER_PAGE</span> <span class="o">=</span> <span class="mi">100</span>
<span class="n">START_PAGE</span> <span class="o">=</span> <span class="mi">1</span>


<span class="k">class</span> <span class="nc">Command</span><span class="p">(</span><span class="n">BaseCommand</span><span class="p">):</span>

    <span class="k">def</span> <span class="nf">add_arguments</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">parser</span><span class="p">):</span>
        <span class="n">parser</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"--start-date"</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="n">DATE_STR</span><span class="p">,</span> <span class="n">help</span><span class="o">=</span><span class="s">"Start date in format YYYY-mm-dd"</span><span class="p">)</span>
        <span class="n">parser</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"--end-date"</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="s">""</span><span class="p">,</span> <span class="n">help</span><span class="o">=</span><span class="s">"End date in format YYYY-mm-dd"</span><span class="p">)</span>
        <span class="n">parser</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"--start-page"</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="n">START_PAGE</span><span class="p">,</span> <span class="nb">type</span><span class="o">=</span><span class="nb">int</span><span class="p">)</span>
        <span class="n">parser</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"--max-items-per-page"</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="n">MAX_ITEMS_PER_PAGE</span><span class="p">,</span> <span class="nb">type</span><span class="o">=</span><span class="nb">int</span><span class="p">)</span>

    <span class="k">def</span> <span class="nf">handle</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="o">*</span><span class="n">args</span><span class="p">,</span> <span class="o">**</span><span class="n">options</span><span class="p">):</span>
        <span class="n">scraper</span> <span class="o">=</span> <span class="n">TracScraper</span><span class="p">(</span>
            <span class="n">options</span><span class="p">[</span><span class="s">"start_date"</span><span class="p">],</span>
            <span class="n">options</span><span class="p">[</span><span class="s">"end_date"</span><span class="p">],</span>
            <span class="n">options</span><span class="p">[</span><span class="s">"start_page"</span><span class="p">],</span>
            <span class="n">options</span><span class="p">[</span><span class="s">"max_items_per_page"</span><span class="p">],</span>
        <span class="p">)</span>
        <span class="n">scraper</span><span class="p">.</span><span class="n">scrape</span><span class="p">()</span>

<span class="k">class</span> <span class="nc">TracScraper</span><span class="p">:</span>
    <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">start_date</span><span class="o">=</span><span class="n">DATE_STR</span><span class="p">,</span> <span class="n">end_date</span><span class="o">=</span><span class="n">DATE_STR</span><span class="p">,</span> <span class="n">start_page</span><span class="o">=</span><span class="n">START_PAGE</span><span class="p">,</span> <span class="n">max_items_per_page</span><span class="o">=</span><span class="n">MAX_ITEMS_PER_PAGE</span><span class="p">):</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">start_date</span> <span class="o">=</span> <span class="n">start_date</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">end_date</span> <span class="o">=</span> <span class="n">end_date</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">start_page</span> <span class="o">=</span> <span class="n">start_page</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">max_items_per_page</span> <span class="o">=</span> <span class="n">max_items_per_page</span>

        <span class="n">query</span> <span class="o">=</span> <span class="sa">f</span><span class="s">"changetime=</span><span class="si">{</span><span class="n">start_date</span><span class="si">}</span><span class="s">..</span><span class="si">{</span><span class="n">end_date</span><span class="si">}</span><span class="s">&amp;start_page=</span><span class="si">{</span><span class="n">start_page</span><span class="si">}</span><span class="s">&amp;max_items_per_page=</span><span class="si">{</span><span class="n">max_items_per_page</span><span class="si">}</span><span class="s">"</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">url</span> <span class="o">=</span> <span class="sa">f</span><span class="s">"</span><span class="si">{</span><span class="n">BASE_URL</span><span class="si">}</span><span class="s">?</span><span class="si">{</span><span class="n">query</span><span class="si">}</span><span class="s">"</span>

    <span class="k">def</span> <span class="nf">scrape</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
        <span class="p">...</span>
</code></pre></div></div>

<p>Here is the updated file in one piece after using the dataclass:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">dataclasses</span> <span class="kn">import</span> <span class="n">dataclass</span>
<span class="kn">from</span> <span class="nn">django.core.management.base</span> <span class="kn">import</span> <span class="n">BaseCommand</span>


<span class="o">@</span><span class="n">dataclass</span>
<span class="k">class</span> <span class="nc">Params</span><span class="p">:</span>
    <span class="n">start_date</span><span class="p">:</span> <span class="nb">str</span> <span class="o">=</span> <span class="p">(</span><span class="n">datetime</span><span class="p">.</span><span class="n">now</span><span class="p">()</span> <span class="o">-</span> <span class="n">timedelta</span><span class="p">(</span><span class="n">days</span><span class="o">=</span><span class="mi">3</span><span class="p">)).</span><span class="n">strftime</span><span class="p">(</span><span class="s">"%Y-%m-%d"</span><span class="p">)</span>
    <span class="n">end_date</span><span class="p">:</span> <span class="nb">str</span> <span class="o">=</span> <span class="s">""</span>
    <span class="n">start_page</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="mi">1</span>
    <span class="n">max_items_per_page</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="mi">1000</span>

    <span class="n">base_url</span><span class="p">:</span> <span class="nb">str</span> <span class="o">=</span> <span class="s">"https://code.djangoproject.com/query"</span>
    <span class="n">start_url</span><span class="p">:</span> <span class="nb">str</span> <span class="o">=</span> <span class="s">""</span>

    <span class="k">def</span> <span class="nf">__post_init__</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
        <span class="n">query_params</span> <span class="o">=</span> <span class="sa">f</span><span class="s">"changetime=</span><span class="si">{</span><span class="bp">self</span><span class="p">.</span><span class="n">start_date</span><span class="si">}</span><span class="s">..</span><span class="si">{</span><span class="bp">self</span><span class="p">.</span><span class="n">end_date</span><span class="si">}</span><span class="s">&amp;max=</span><span class="si">{</span><span class="bp">self</span><span class="p">.</span><span class="n">max_items_per_page</span><span class="si">}</span><span class="s">&amp;page=</span><span class="si">{</span><span class="bp">self</span><span class="p">.</span><span class="n">start_page</span><span class="si">}</span><span class="s">"</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">start_url</span> <span class="o">=</span> <span class="sa">f</span><span class="s">"</span><span class="si">{</span><span class="bp">self</span><span class="p">.</span><span class="n">base_url</span><span class="si">}</span><span class="s">&amp;</span><span class="si">{</span><span class="n">query_params</span><span class="si">}</span><span class="s">"</span>


<span class="k">class</span> <span class="nc">Command</span><span class="p">(</span><span class="n">BaseCommand</span><span class="p">):</span>

    <span class="k">def</span> <span class="nf">add_arguments</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">parser</span><span class="p">):</span>
        <span class="n">p</span> <span class="o">=</span> <span class="n">Params</span><span class="p">()</span>
        <span class="n">parser</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"--start-date"</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="n">p</span><span class="p">.</span><span class="n">start_date</span><span class="p">,</span> <span class="n">help</span><span class="o">=</span><span class="s">"Start date in format YYYY-mm-dd"</span><span class="p">)</span>
        <span class="n">parser</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"--end-date"</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="n">p</span><span class="p">.</span><span class="n">end_date</span><span class="p">,</span> <span class="n">help</span><span class="o">=</span><span class="s">"End date in format YYYY-mm-dd"</span><span class="p">)</span>
        <span class="n">parser</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"--start-page"</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="n">p</span><span class="p">.</span><span class="n">start_page</span><span class="p">,</span> <span class="nb">type</span><span class="o">=</span><span class="nb">int</span><span class="p">)</span>
        <span class="n">parser</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"--max-items-per-page"</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="n">p</span><span class="p">.</span><span class="n">max_items_per_page</span><span class="p">,</span> <span class="nb">type</span><span class="o">=</span><span class="nb">int</span><span class="p">)</span>

    <span class="k">def</span> <span class="nf">handle</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="o">*</span><span class="n">args</span><span class="p">,</span> <span class="o">**</span><span class="n">options</span><span class="p">):</span>
        <span class="n">p</span> <span class="o">=</span> <span class="n">Params</span><span class="p">(</span>
            <span class="n">options</span><span class="p">[</span><span class="s">"start_date"</span><span class="p">],</span>
            <span class="n">options</span><span class="p">[</span><span class="s">"end_date"</span><span class="p">],</span>
            <span class="n">options</span><span class="p">[</span><span class="s">"start_page"</span><span class="p">],</span>
            <span class="n">options</span><span class="p">[</span><span class="s">"max_items_per_page"</span><span class="p">]</span>
        <span class="p">)</span>
        <span class="n">scraper</span> <span class="o">=</span> <span class="n">TracScraper</span><span class="p">(</span><span class="n">p</span><span class="p">)</span>
        <span class="n">scraper</span><span class="p">.</span><span class="n">scrape</span><span class="p">()</span>

<span class="k">class</span> <span class="nc">TracScraper</span><span class="p">:</span>
    <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">params</span><span class="p">:</span> <span class="n">Param</span><span class="p">):</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">params</span> <span class="o">=</span> <span class="n">params</span>

    <span class="k">def</span> <span class="nf">scrape</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
        <span class="p">...</span>
</code></pre></div></div>

<h1 id="final-thoughts">Final Thoughts</h1>

<p>What are your thoughts on this particular use case for grouping command line arguments into a dataclass?</p>

<p>I think it is interesting and it also seems like a natural use case. I like how much easier it is to create the scraper class without worrying about redefining the default values for the keyword arguments. It makes the function signature much shorter. It also reduces the likelihood of assigning the wrong default values and having them be out of sync between the scraper class and the command line argument registry.</p>

<p>How have you used dataclasses? What do you like about them?</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Dataclasses]]></summary></entry><entry><title type="html">Another Perspective Of The Django Triage Workflow</title><link href="http://localhost:4000/2025/05/09/another-perspective-django-triage-workflow.html" rel="alternate" type="text/html" title="Another Perspective Of The Django Triage Workflow" /><published>2025-05-09T00:00:00-07:00</published><updated>2025-05-09T00:00:00-07:00</updated><id>http://localhost:4000/2025/05/09/another-perspective-django-triage-workflow</id><content type="html" xml:base="http://localhost:4000/2025/05/09/another-perspective-django-triage-workflow.html"><![CDATA[<h2 id="the-cost-of-wrong-triage-stages">The Cost Of Wrong Triage Stages</h2>

<p>Sometimes the Django development process can be confusing because the ticket flags are not appropriately set. Here are a few scenarios that contributors may find themselves in:</p>

<ul>
  <li>A PR author addresses feedback from a review, but does not unset the <code class="language-plaintext highlighter-rouge">Patch Needs Improvement</code>, <code class="language-plaintext highlighter-rouge">Needs Docs</code>, and <code class="language-plaintext highlighter-rouge">Needs Tests</code> flags to get the ticket back into the review queue.</li>
  <li>A merger gets a message from a PR author asking why a ticket isn’t being reviewed. The merger replies to explain the development process and remind the PR author to unset the <code class="language-plaintext highlighter-rouge">Patch Needs Improvement</code>, <code class="language-plaintext highlighter-rouge">Needs Docs</code>, and <code class="language-plaintext highlighter-rouge">Needs Tests</code> flags.</li>
  <li>A reviewer starts to review a ticket from the review queue, but after poring over Trac and Github, determines that the ticket should be in the <code class="language-plaintext highlighter-rouge">Ready For Checkin</code> stage.</li>
  <li>A bug reporter gets confused and feels shutdown when a ticket they had just opened is marked as <code class="language-plaintext highlighter-rouge">Closed</code> with the resolution set to <code class="language-plaintext highlighter-rouge">Needs Info</code>.</li>
</ul>

<p>In each scenario, the ticket is in the wrong stage, or the contributor’s understanding is not aligned with the intention of the stage. There’s an overhead for all contributors involved.</p>

<p>Without changing any of the underlying mechanisms, where can we start to help contributors better understand the expectations in the development process so they can collaborate more smoothly? Let’s start by understanding the current workflow.</p>

<h2 id="current-triage-workflow">Current Triage Workflow</h2>

<p>Below is a diagram taken from the Django contributing documentation depicting the current <a href="https://docs.djangoproject.com/en/5.2/internals/contributing/triaging-tickets/#triage-workflow">triage workflow</a>.</p>

<p><img src="/assets/images/django-triage-workflow.png" alt="Django Triage Workflow" /></p>

<p>In this diagram, there is a lane for “Open tickets” consisting of 3 triage stages: <code class="language-plaintext highlighter-rouge">Unreviewed</code>, <code class="language-plaintext highlighter-rouge">Accepted</code>, and <code class="language-plaintext highlighter-rouge">Ready for Checkin</code>. There is another lane for “Closed tickets” consisting of different states for the resolution property of a ticket (<code class="language-plaintext highlighter-rouge">duplicate</code>, <code class="language-plaintext highlighter-rouge">wontfix</code>, <code class="language-plaintext highlighter-rouge">invalid</code>, <code class="language-plaintext highlighter-rouge">needsinfo</code>, <code class="language-plaintext highlighter-rouge">worksforme</code>, and <code class="language-plaintext highlighter-rouge">fixed</code>).</p>

<p>It is a good starting point. It may seem simple and straightforward at first, until you get into the contribution process and start to experience some miscommunications and uncertainty. The documentation itself acknowledges the <a href="https://docs.djangoproject.com/en/5.2/internals/contributing/triaging-tickets/#triage-workflow?:~:text=The%20big%20gray%20area">big gray area</a> of the “Accepted” stage.</p>

<h2 id="another-perspective-of-the-triage-workflow">Another Perspective Of The Triage Workflow</h2>

<p>Here is a different take on the triage workflow diagram. Note that it doesn’t change the underlying mechanisms of the triage stages.</p>

<p><img src="/assets/images/proposal-triage-workflow.png" alt="Proposed Triage Workflow" /></p>

<p>It introduces two minor conceptual adjustments:</p>
<ul>
  <li>Explicitly name the 3 distinct stages for Accepted stage: “Needs Patch”, “Needs Review”, and “Waiting On Author”.</li>
  <li>Organizes the triage stages along a horizontal axis based on the ticket’s progress. That is, how much work remains in order to get the ticket to the finish line.</li>
</ul>

<h3 id="3-distinct-stages-for-accepted-stage">3 Distinct Stages For Accepted Stage</h3>

<p>Despite mentioning the big gray area of the Accepted stage, the contributing documentation already does a great job of outlining 3 distinct stages. All that was missing was having explicit names for them:</p>
<ul>
  <li>“Needs Patch”</li>
  <li>“Needs Review”</li>
  <li>“Waiting On Author”</li>
</ul>

<p>These names are chosen because they make it clear what action needs to happen and which contributor role is responsible for taking that action to move the ticket forward. Having distinct stages and explicit names makes it easier to understand where a ticket stands at a glance. It frees the contributor from the cognitive load of computing 4 booleans and makes it easier to move the ticket into the correct queue.</p>

<p>Imagine being a PR author who has just finished addressing feedback from a review. The author can now go to the ticket and adjust the triage stage from “Waiting On Author” to “Needs Review”. The ticket lands in the review queue. Having explicit stages is so much easier compared to the current workflow, where the author needs to remember to unset the flags for <code class="language-plaintext highlighter-rouge">Patch Needs Improvement</code>, <code class="language-plaintext highlighter-rouge">Needs Docs</code>, and <code class="language-plaintext highlighter-rouge">Needs Tests</code>.</p>

<p>Similarly, imagine being a reviewer who has just finished adding feedback to a PR. The reviewer can change the triage stage from “Needs Review” to “Waiting on Author”. They will still need to consider setting the <code class="language-plaintext highlighter-rouge">Needs Docs</code> and <code class="language-plaintext highlighter-rouge">Needs Tests</code> flags. However, I think having a triage stage name of “Waiting On Author” provides a much better experience. It creates the image of tossing the potato back to the author, which is more intuitive compared to toggling the value of <code class="language-plaintext highlighter-rouge">Patch Needs Improvement</code>.</p>

<h3 id="depicting-progress">Depicting Progress</h3>

<p>Mapping the triage stages along a horizontal axis helps depict how far along the ticket is to the finish line. The <code class="language-plaintext highlighter-rouge">Needs Info</code> resolution state is moved out of the “Closed tickets” section. It is considered to have a lower progress status, so it is placed further towards the left on the axis.</p>

<p>With the existing workflow, when a maintainer marks a ticket as <code class="language-plaintext highlighter-rouge">Closed</code> and <code class="language-plaintext highlighter-rouge">Needs Info</code>, a bug filer might assume that the maintainer is unfairly suggesting that the progress is 100% (“Closed”). However, in this new diagram, the progress for the ticket can now be interpreted as closer to 10%, and the bug filer can feel that this is a more fair assessment and be more cooperative.</p>

<h2 id="thoughts">Thoughts?</h2>

<p>Does the new view of the triage workflow help you better understand what you need to do to move a ticket forward?</p>

<p>This may not be perfect, and it may be an oversimplification of the Django triage workflow, but hopefully it brings some clarity and reduces the confusion and communication overhead.</p>

<p>A few questions to entertain:</p>

<ul>
  <li>Instead of doing mental gymnastics with the flags <code class="language-plaintext highlighter-rouge">Has Patch</code>, <code class="language-plaintext highlighter-rouge">Patch Needs Improvement</code>, <code class="language-plaintext highlighter-rouge">Needs Documentation</code>, and <code class="language-plaintext highlighter-rouge">Needs Tests</code>, what if contributors can easily glance at a ticket and know which stage it is in?</li>
  <li>What if contributors can easily move a ticket from one stage to another?</li>
  <li>What if contributors can easily pull up the list of tickets in each stage on the “View Tickets” tab, instead of having to switch to the “Reports” tab or find in their bookmarks?</li>
  <li>What low hanging fruits can be built into the UI that makes navigating the contributor process more intuitive? What would cut down on the overhead for contributors?</li>
</ul>

<p>[Next post coming soon…]</p>]]></content><author><name></name></author><category term="favorite" /><summary type="html"><![CDATA[The Cost Of Wrong Triage Stages]]></summary></entry><entry><title type="html">Event Organizing</title><link href="http://localhost:4000/2024/12/01/event-organizing.html" rel="alternate" type="text/html" title="Event Organizing" /><published>2024-12-01T00:00:00-08:00</published><updated>2024-12-01T00:00:00-08:00</updated><id>http://localhost:4000/2024/12/01/event-organizing</id><content type="html" xml:base="http://localhost:4000/2024/12/01/event-organizing.html"><![CDATA[<p>It is December. Djangonaut Space Session 3 is wrapping up it’s final week. It makes me happy knowing that the program helps transform a lot of potential into real results.</p>

<p>I want to share my most proud accomplishment as a Session Coordinator — putting together the Djangonaut Showcase event! I’ve never organized events before, and this was a great learning experience for me.</p>

<h2 id="djangonaut-showcase">Djangonaut Showcase</h2>

<p>It started when I added a small agenda item added to our organizer meeting notes that said:</p>

<blockquote>
  <p>Ideas:</p>
  <ul>
    <li>Showcase event</li>
  </ul>

</blockquote>

<p>That item got the ball rolling! During the meeting, I briefly explained that the motivation for this event came from my own desire to have a similar event when I was a Djangonaut. I wanted to share what I learned and to connect with my peers. There were also several sources of feedback that we received during the current session where people expressed the same desire! I was excited to know that I wasn’t alone.</p>

<p>Once I explained the idea behind the event, the organizers were onboard! Then, I spent time during a weekend co-writing session to draft up the proposal. I shared the proposal during the next organizer meeting, and we had a very good discussion to refine the logistics and details. After the discussion, I created a list of action items. Then, it was time to execute!</p>

<p>Send out announcement. Get people to sign up. Send out instructions. Schedule time slots. Check-in to make sure everyone was on track. Prepare to host the event. I made sure to stay organized by putting information on a spreadsheet along with due dates. Timeliness was important every step of the way.</p>

<p>We had a dry-run to make sure I knew how to use Zoom as a host. I practiced delivering the introduction. I’m not very good with speaking and having conversations. My co-organizer suggested writing up a script. So after the dry-run, I wrote up a script and practiced. I recorded myself and played it back several times until I felt comfortable with all the things I had to say and the transitions from one topic to another. The evening of the event, I made sure to go to bed early and get a lot of rest.</p>

<p>The event was a huge success! Two successes, actually! We had two events to accommodate timezones and availabilities. Officers, Stars, and Astronomers showed up to support the Djangonaut speakers. Djangonauts were excited to share their learnings and experiences. Everyone got swept away during the Q&amp;A sessions.</p>

<p>I’m so glad to have a lot of help from the organizers to make this event successful. Was it perfect? No, and that’s ok. Is everyone feeling inspired and motivated and more connected? I think so!</p>

<p>I’m looking forward to seeing the community continue to grow. I’m looking forward to seeing more collaboration among Djangonauts, Stars, Astronomers, Organizers, etc., including myself. I’m personally looking forward to making more contributions, both in code and community organization. My favorite part is working with wonderful, smart, kind people!</p>

<h2 id="organization-process">Organization Process</h2>

<p>Here is breakdown of the steps I took to organize this small, yet impactful, event. It’s a process I will adapt and apply to other events and technical projects, too.</p>

<ol>
  <li>Have an idea for an event in your head.</li>
  <li>Sit down to some quiet time during a co-writing session and write the proposal for the event. Have details about the logistics and also the goals.</li>
  <li>Share your written idea with other organizers to get feedback and refine the details.</li>
  <li>Write up action items. Create a list of things that need to be done. Assign deadlines. Note details about things you need, such as permissions to accounts/tools (zoom, email, etc) to send out communications.</li>
  <li>Execute. Be mindful of timelines.</li>
  <li>Keep an eye on the sign ups and participation level. If not enough interest is being express, take action early and make announcements. Make sure your messaging is explicit about the expectations for the participants during the sign up process and the event itself.</li>
  <li>Prepare for hosting the event. Write up an outline of how the event will flow. Write up a script for what you will say. Write up backup questions to ask in case there is a lull in the audience. Practice, record, practice. Have a dry-run with the help of your co-organizers. Lean into them for feedback and support.</li>
  <li>Make sure to get a eat well and sleep well before the event.</li>
  <li>Host the event!</li>
  <li>Celebrate your accomplishments with friends.</li>
  <li>Follow up and collect feedback on the event, if possible, so you can learn from the results and improve on the next iteration!</li>
</ol>]]></content><author><name></name></author><summary type="html"><![CDATA[It is December. Djangonaut Space Session 3 is wrapping up it’s final week. It makes me happy knowing that the program helps transform a lot of potential into real results.]]></summary></entry></feed>