Skip to content

Commit c20911e

Browse files
committed
Household/Individual change history
1 parent 2eb5605 commit c20911e

File tree

17 files changed

+1458
-177
lines changed

17 files changed

+1458
-177
lines changed

src/country_workspace/migrations/0010_householdevent_and_more.py renamed to src/country_workspace/migrations/0010_householdevent_individualevent_and_more.py

Lines changed: 41 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Generated by Django 5.1.7 on 2025-04-01 12:50
1+
# Generated by Django 5.1.7 on 2025-04-02 06:25
22

33
import concurrency.fields
44
import django.db.models.deletion
@@ -37,14 +37,22 @@ class Migration(migrations.Migration):
3737
},
3838
),
3939
migrations.CreateModel(
40-
name="IndividualFlexFieldsFlexFilesRemovedEvent",
40+
name="IndividualEvent",
4141
fields=[
4242
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
4343
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
4444
("pgh_label", models.TextField(help_text="The event label.")),
45+
("id", models.IntegerField()),
46+
("name", models.CharField(max_length=255, verbose_name="Name")),
47+
("last_checked", models.DateTimeField(blank=True, default=None, null=True)),
48+
("errors", models.JSONField(blank=True, default=dict, editable=False)),
4549
("flex_fields", models.JSONField(blank=True, default=dict)),
4650
("flex_files", models.BinaryField(blank=True, null=True)),
4751
("removed", models.BooleanField(default=False, verbose_name="Removed")),
52+
("checksum", models.CharField(blank=True, max_length=300, null=True, verbose_name="checksum")),
53+
("last_modified", models.DateTimeField(auto_now=True)),
54+
("version", concurrency.fields.IntegerVersionField(default=0, help_text="record revision number")),
55+
("system_fields", models.JSONField(blank=True, default=dict)),
4856
],
4957
options={
5058
"abstract": False,
@@ -68,27 +76,13 @@ class Migration(migrations.Migration):
6876
pgtrigger.migrations.AddTrigger(
6977
model_name="individual",
7078
trigger=pgtrigger.compiler.Trigger(
71-
name="insert_insert",
72-
sql=pgtrigger.compiler.UpsertTriggerSql(
73-
func='INSERT INTO "country_workspace_individualflexfieldsflexfilesremovedevent" ("flex_fields", "flex_files", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "removed") VALUES (NEW."flex_fields", NEW."flex_files", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."removed"); RETURN NULL;',
74-
hash="546ae6ba96473bd4d36645086336ffd8ac4dddb0",
75-
operation="INSERT",
76-
pgid="pgtrigger_insert_insert_3c11b",
77-
table="country_workspace_individual",
78-
when="AFTER",
79-
),
80-
),
81-
),
82-
pgtrigger.migrations.AddTrigger(
83-
model_name="individual",
84-
trigger=pgtrigger.compiler.Trigger(
85-
name="update_update",
79+
name="updates_update",
8680
sql=pgtrigger.compiler.UpsertTriggerSql(
8781
condition='WHEN (OLD."flex_fields" IS DISTINCT FROM (NEW."flex_fields") OR OLD."flex_files" IS DISTINCT FROM (NEW."flex_files") OR OLD."removed" IS DISTINCT FROM (NEW."removed"))',
88-
func='INSERT INTO "country_workspace_individualflexfieldsflexfilesremovedevent" ("flex_fields", "flex_files", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "removed") VALUES (NEW."flex_fields", NEW."flex_files", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."removed"); RETURN NULL;',
89-
hash="08e91e85897e2824d36900cd8cf56593b9313078",
82+
func='INSERT INTO "country_workspace_individualevent" ("batch_id", "checksum", "errors", "flex_fields", "flex_files", "household_id", "id", "last_checked", "last_modified", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "removed", "system_fields", "version") VALUES (NEW."batch_id", NEW."checksum", NEW."errors", NEW."flex_fields", NEW."flex_files", NEW."household_id", NEW."id", NEW."last_checked", NEW."last_modified", NEW."name", _pgh_attach_context(), NOW(), \'updates\', NEW."id", NEW."removed", NEW."system_fields", NEW."version"); RETURN NULL;',
83+
hash="4955797cf04e1d9c8dec414ad7900d2b008da145",
9084
operation="UPDATE",
91-
pgid="pgtrigger_update_update_6a215",
85+
pgid="pgtrigger_updates_update_ea7f7",
9286
table="country_workspace_individual",
9387
when="AFTER",
9488
),
@@ -127,7 +121,31 @@ class Migration(migrations.Migration):
127121
),
128122
),
129123
migrations.AddField(
130-
model_name="individualflexfieldsflexfilesremovedevent",
124+
model_name="individualevent",
125+
name="batch",
126+
field=models.ForeignKey(
127+
db_constraint=False,
128+
on_delete=django.db.models.deletion.DO_NOTHING,
129+
related_name="+",
130+
related_query_name="+",
131+
to="country_workspace.batch",
132+
),
133+
),
134+
migrations.AddField(
135+
model_name="individualevent",
136+
name="household",
137+
field=models.ForeignKey(
138+
blank=True,
139+
db_constraint=False,
140+
null=True,
141+
on_delete=django.db.models.deletion.DO_NOTHING,
142+
related_name="+",
143+
related_query_name="+",
144+
to="country_workspace.household",
145+
),
146+
),
147+
migrations.AddField(
148+
model_name="individualevent",
131149
name="pgh_context",
132150
field=models.ForeignKey(
133151
db_constraint=False,
@@ -138,12 +156,12 @@ class Migration(migrations.Migration):
138156
),
139157
),
140158
migrations.AddField(
141-
model_name="individualflexfieldsflexfilesremovedevent",
159+
model_name="individualevent",
142160
name="pgh_obj",
143161
field=models.ForeignKey(
144162
db_constraint=False,
145163
on_delete=django.db.models.deletion.DO_NOTHING,
146-
related_name="flex_fields_flex_files_removed_events",
164+
related_name="events",
147165
to="country_workspace.individual",
148166
),
149167
),

src/country_workspace/models/base.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,6 @@ def validate_with_checker(self) -> bool:
114114
self.save(update_fields=["last_checked", "errors"])
115115
return not bool(errors)
116116

117-
def last_changes(self) -> "Any": ...
118-
119-
def diff(self, first: int | None = None, second: int | None = None) -> "Any": ...
120-
121117
def is_valid(self) -> bool | None:
122118
if not self.last_checked:
123119
return None

src/country_workspace/models/individual.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
from hope_flex_fields.models import DataChecker
1212

1313

14-
@pghistory.track(fields=["flex_fields", "flex_files", "removed"])
14+
@pghistory.track(
15+
pghistory.UpdateEvent("updates", condition=pghistory.AnyChange("flex_fields", "flex_files", "removed"))
16+
)
1517
class Individual(Validable, BaseModel):
1618
household = models.ForeignKey(Household, on_delete=models.CASCADE, null=True, blank=True, related_name="members")
1719
system_fields = models.JSONField(default=dict, blank=True)

src/country_workspace/workspaces/admin/hh_ind.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@
1010
from django.forms import Form
1111
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
1212
from django.shortcuts import render
13+
from django.template.defaultfilters import capfirst
1314
from django.template.response import TemplateResponse
1415
from django.urls import reverse
1516
from django.utils.translation import gettext as _
1617

18+
from ...cache.manager import cache_manager
1719
from ...models import AsyncJob
1820
from ...state import state
1921
from ..options import WorkspaceModelAdmin
@@ -62,6 +64,7 @@ class BeneficiaryBaseAdmin(AdminAutoCompleteSearchMixin, SelectedProgramMixin, W
6264
title = None
6365
title_plural = None
6466
list_per_page = 20
67+
object_history_template = "workspace/individual/object_history.html"
6568

6669
def has_validate_permission(self, request: HttpRequest) -> bool:
6770
return request.user.has_perm("country_workspace.validate_beneficiary")
@@ -226,3 +229,53 @@ def change_view(
226229
def save_model(self, request: HttpRequest, obj: "Validable", form: Form, change: bool) -> None:
227230
super().save_model(request, obj, form, change)
228231
obj.validate_with_checker()
232+
233+
def history_view(
234+
self, request: HttpRequest, object_id: str, extra_context: dict[str, Any] | None = None
235+
) -> TemplateResponse:
236+
obj = self.get_object(request, unquote(object_id))
237+
etag = cache_manager.build_key_from_request(request, "history", obj.last_modified)
238+
if response := cache_manager.retrieve(etag):
239+
return response
240+
history = []
241+
prev = {}
242+
field_names = [f.name for __, f in obj.checker.get_fields()]
243+
for entry in obj.events.select_related("pgh_context").all():
244+
changes = {}
245+
for field_name in field_names:
246+
old_value = prev.get(field_name, "")
247+
new_value = entry.flex_fields.get(field_name, "")
248+
if old_value != new_value:
249+
changes[field_name] = {"from": old_value, "to": new_value}
250+
history.append(
251+
{
252+
"changes": changes,
253+
"date": entry.pgh_created_at,
254+
"pgh_label": entry.pgh_label,
255+
"user": entry.pgh_context.metadata["user"],
256+
}
257+
)
258+
prev = entry.flex_fields
259+
history.reverse()
260+
context = {
261+
**self.admin_site.each_context(request),
262+
"modeladmin": self,
263+
"title": _("Change history: %s") % obj,
264+
"subtitle": None,
265+
"history": history,
266+
"action_list": None,
267+
"page_range": None,
268+
"page_var": None,
269+
"pagination_required": False,
270+
"module_name": str(capfirst(self.opts.verbose_name_plural)),
271+
"original": obj,
272+
"opts": self.opts,
273+
"preserved_filters": self.get_preserved_filters(request),
274+
**(extra_context or {}),
275+
}
276+
return TemplateResponse(
277+
request,
278+
self.object_history_template,
279+
context,
280+
headers={"Etag": etag},
281+
)

src/country_workspace/workspaces/admin/household.py

Lines changed: 1 addition & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,14 @@
1-
from typing import TYPE_CHECKING, Any
1+
from typing import TYPE_CHECKING
22

3-
import dictdiffer
43
from admin_extra_buttons.buttons import LinkButton
54
from admin_extra_buttons.decorators import link
65
from django.contrib.admin import register
7-
from django.contrib.admin.utils import unquote
86
from django.http import HttpRequest
9-
from django.template.response import TemplateResponse
107
from django.urls import reverse
11-
from django.utils.text import capfirst
128
from django.utils.translation import gettext as _
139

1410
from country_workspace.workspaces.admin.cleaners.actions import push_to_hope
1511

16-
from ...cache.manager import cache_manager
1712
from ...state import state
1813
from ..models import CountryHousehold
1914
from ..sites import workspace
@@ -41,43 +36,6 @@ class CountryHouseholdAdmin(BeneficiaryBaseAdmin):
4136
actions = [*BeneficiaryBaseAdmin.actions, push_to_hope]
4237
object_history_template = "workspace/household/object_history.html"
4338

44-
def history_view(
45-
self, request: HttpRequest, object_id: str, extra_context: dict[str, Any] | None = None
46-
) -> TemplateResponse:
47-
obj: "CountryHousehold" = self.get_object(request, unquote(object_id))
48-
etag = cache_manager.build_key_from_request(request, "view", getattr(request.user, "pk", ""), obj.last_modified)
49-
if response := cache_manager.retrieve(etag):
50-
return response
51-
history = []
52-
prev = {}
53-
for entry in obj.events.values():
54-
changes = list(dictdiffer.diff(prev, entry["flex_fields"]))
55-
history.append({"changes": changes, **entry})
56-
prev = entry["flex_fields"]
57-
58-
context = {
59-
**self.admin_site.each_context(request),
60-
"modeladmin": self,
61-
"title": _("Change history: %s") % obj,
62-
"subtitle": None,
63-
"history": history,
64-
"action_list": None,
65-
"page_range": None,
66-
"page_var": None,
67-
"pagination_required": False,
68-
"module_name": str(capfirst(self.opts.verbose_name_plural)),
69-
"original": obj,
70-
"opts": self.opts,
71-
"preserved_filters": self.get_preserved_filters(request),
72-
**(extra_context or {}),
73-
}
74-
return TemplateResponse(
75-
request,
76-
self.object_history_template,
77-
context,
78-
headers={"Etag": etag},
79-
)
80-
8139
def get_list_display(self, request: HttpRequest) -> list[str]:
8240
program: "CountryProgram | None"
8341
if program := self.get_selected_program(request):
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
{% extends "workspace/_base.html" %}{% load admin_urls i18n workspace_urls %}
2+
{% block page-title %}{% endblock %}
3+
4+
{% block content %}
5+
<div id="content-main">
6+
<div id="change-history" class="module">
7+
<table class="history">
8+
<thead>
9+
<tr>
10+
<th>Date</th>
11+
<th>User</th>
12+
<th>
13+
<div class="flex">
14+
<div class="grow field_name">Field</div>
15+
<div class="flex-none old_value">Old</div>
16+
<div class="flex-none new_value">New</div>
17+
</div>
18+
</th>
19+
</tr>
20+
</thead>
21+
<tbody>
22+
{% for entry in history %}
23+
<tr class="hover:bg-yellow-200">
24+
<th class="w-32 whitespace-nowrap p-2 px-5">
25+
{% admin_url "pghistory.events" pk=entry.pgh_id %}
26+
{{ entry.date }}
27+
</th>
28+
<td class="w-20 px-5">{{ entry.user.username }}</td>
29+
<td class="px-5">
30+
{% for field, data in entry.changes.items %}
31+
<div class="flex over:bg-yellow-100">
32+
<div class="grow field_name">{{ field }}</div>
33+
<div class="flex-none old_value">{{ data.from }}</div>
34+
<div class="flex-none new_value">{{ data.to }}</div>
35+
</div>
36+
{% endfor %}
37+
</td>
38+
</tr>
39+
{% endfor %}
40+
</tbody>
41+
</table>
42+
</div>
43+
</div>
44+
{% endblock content %}
Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,8 @@
1-
{% extends "workspace/_base.html" %}{% load admin_urls i18n workspace_urls %}
1+
{% extends "workspace/hh_ind_object_history.html" %}{% load admin_urls i18n workspace_urls %}
22
{% block page-title %}
33
{% url "workspace:workspaces_countryhousehold_changelist" as wcl %}
44
&rsaquo; <a href="{% add_preserved_filters wcl %}">Households</a>{% admin_url modeladmin %}
55
&rsaquo; <a href="{% url "workspace:workspaces_countryhousehold_change" original.pk %}">{{ original }}</a>
66
{% admin_url original %}
77
&rsaquo; changes
8-
98
{% endblock page-title %}
10-
11-
{% block content %}
12-
<div id="content-main">
13-
<div id="change-history" class="module">
14-
<table>
15-
{% for entry in history %}
16-
{% if entry.changes %}
17-
<tr>
18-
<th class="nowrap">{{ entry.pgh_created_at }}</th>
19-
<td>{{ entry.pgh_label }}</td>
20-
{# <td>{{ entry.flex_fields }}</td> #}
21-
<td>{{ entry.changes }}</td>
22-
</tr>
23-
{% endif %}
24-
{% endfor %}
25-
</table>
26-
</div>
27-
</div>
28-
{% endblock content %}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{% extends "workspace/hh_ind_object_history.html" %}{% load admin_urls i18n workspace_urls %}
2+
{% block page-title %}
3+
{% url "workspace:workspaces_countryhousehold_changelist" as wcl %}
4+
{% url "workspace:workspaces_countryhousehold_change" original.household.pk as wcf %}
5+
&rsaquo; <a href="{% add_preserved_filters wcl %}">Households</a>
6+
&rsaquo; <a href="{% add_preserved_filters wcf %}">{{ original.household }}</a>{% admin_url original.household %}
7+
&rsaquo; <a href="{% url "workspace:workspaces_countryindividual_changelist" %}?household__exact={{ original.household.pk }}">members</a> {% admin_url modeladmin household__exact=original.household.pk %}
8+
&rsaquo; <a href="{% url "workspace:workspaces_countryindividual_change" original.pk %}?household__exact={{ original.household.pk }}">{{ original }}</a> {% admin_url modeladmin household__exact=original.household.pk %}
9+
&rsaquo; changes
10+
{% endblock page-title %}

src/country_workspace/workspaces/theme/static/css/styles.css

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)