Skip to content

Commit 439ac54

Browse files
committed
Clean up formatting of timestamps
1 parent 3cd4327 commit 439ac54

19 files changed

Lines changed: 299 additions & 63 deletions

astra_app/core/membership_requests_datatables.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from collections.abc import Callable, Mapping, Sequence
2+
from datetime import datetime
23

34
from django.db.models import Prefetch, Q, QuerySet
45
from django.templatetags.static import static
@@ -24,6 +25,12 @@
2425
from core.views_utils import _normalize_str
2526

2627

28+
def _format_membership_timestamp_display(value: datetime | None) -> str:
29+
if value is None:
30+
return ""
31+
return date_format(localtime(value), "Y-m-d H:i")
32+
33+
2734
def resolve_requested_by(
2835
username: str,
2936
*,
@@ -755,7 +762,8 @@ def serialize_note_group(
755762
"is_custos": bool(header_entry.get("is_custos", False)),
756763
"avatar_kind": avatar_kind,
757764
"avatar_url": avatar_url,
758-
"timestamp_display": date_format(localtime(note.timestamp), "DATETIME_FORMAT"),
765+
"timestamp": note.timestamp.isoformat() if note.timestamp is not None else None,
766+
"timestamp_display": _format_membership_timestamp_display(note.timestamp),
759767
"entries": [
760768
serialize_note_entry(entry=entry, contacted_email_by_id=contacted_email_by_id)
761769
for entry in group.get("entries", [])
@@ -782,7 +790,8 @@ def _serialize_contacted_email_detail(email_modal: dict[str, object]) -> dict[st
782790
"recipient_delivery_summary_note": str(email_modal.get("recipient_delivery_summary_note") or ""),
783791
"logs": [
784792
{
785-
"date_display": log["date"].strftime("%Y-%m-%d %H:%M:%S %Z") if log.get("date") else "",
793+
"date": log["date"].isoformat() if isinstance(log.get("date"), datetime) else None,
794+
"date_display": _format_membership_timestamp_display(log.get("date")),
786795
"status": str(log.get("status") or ""),
787796
"message": str(log.get("message") or ""),
788797
"exception_type": str(log.get("exception_type") or ""),

astra_app/core/templates/core/_membership_email_modal.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ <h6 class="text-muted">Delivery logs</h6>
9494
<tbody>
9595
{% for log in modal.logs %}
9696
<tr>
97-
<td>{{ log.date|date:"Y-m-d H:i:s T" }}</td>
97+
<td>{{ log.date|date:"Y-m-d H:i" }}</td>
9898
<td>{{ log.status }}</td>
9999
<td>
100100
{{ log.message }}

astra_app/core/tests/test_membership_request_email_modal.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11

2+
import datetime
23
from unittest.mock import patch
34

45
from django.conf import settings
5-
from django.test import TestCase
6+
from django.template import Context, Template
7+
from django.test import SimpleTestCase, TestCase, override_settings
68
from django.urls import reverse
9+
from django.utils import timezone
710
from post_office.models import STATUS, Email, EmailTemplate, Log, RecipientDeliveryStatus
811

912
from core.freeipa.user import FreeIPAUser
@@ -13,6 +16,45 @@
1316
from core.tests.utils_test_data import ensure_core_categories
1417

1518

19+
class MembershipEmailModalTemplateTests(SimpleTestCase):
20+
@override_settings(TIME_ZONE="UTC", USE_TZ=True)
21+
def test_delivery_logs_render_canonical_timestamp_format(self) -> None:
22+
rendered = Template(
23+
"""{% include 'core/_membership_email_modal.html' with modal=modal %}"""
24+
).render(
25+
Context(
26+
{
27+
"modal": {
28+
"modal_id": "membership-email-modal-1",
29+
"to": ["alice@example.com"],
30+
"subject": "Approval notice",
31+
"from_email": "noreply@example.com",
32+
"cc": [],
33+
"bcc": [],
34+
"reply_to": "committee@example.com",
35+
"recipient_delivery_summary": "Delivered",
36+
"recipient_delivery_summary_note": "",
37+
"headers": [],
38+
"html": "<p>HTML body</p>",
39+
"text": "Plain text body",
40+
"logs": [
41+
{
42+
"date": timezone.make_aware(datetime.datetime(2026, 4, 21, 12, 34, 56)),
43+
"status": "sent",
44+
"message": "sent",
45+
"exception_type": "",
46+
}
47+
],
48+
}
49+
}
50+
)
51+
)
52+
53+
self.assertIn("2026-04-21 12:34", rendered)
54+
self.assertNotIn("2026-04-21 12:34:56", rendered)
55+
self.assertNotIn("2026-04-21 12:34 UTC", rendered)
56+
57+
1658
class MembershipRequestEmailModalTests(TestCase):
1759
def setUp(self) -> None:
1860
super().setUp()

astra_app/core/tests/test_membership_requests_datatables_api.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,8 @@ def test_membership_request_note_details_endpoint_returns_grouped_content_only(s
585585
membership_type_id="individual",
586586
status=MembershipRequest.Status.pending,
587587
)
588+
note_timestamp = timezone.make_aware(datetime.datetime(2026, 4, 21, 12, 34, 56))
589+
log_timestamp = timezone.make_aware(datetime.datetime(2026, 4, 21, 13, 45, 56))
588590
email = Email.objects.create(
589591
from_email="noreply@example.com",
590592
to="alice@example.com",
@@ -593,17 +595,19 @@ def test_membership_request_note_details_endpoint_returns_grouped_content_only(s
593595
html_message="<p>HTML body</p>",
594596
headers={"Reply-To": "committee@example.com"},
595597
)
596-
Log.objects.create(
598+
log = Log.objects.create(
597599
email=email,
598600
status=STATUS.sent,
599601
message="sent",
600602
exception_type="",
601603
)
602-
Note.objects.create(
604+
Log.objects.filter(pk=log.pk).update(date=log_timestamp)
605+
note = Note.objects.create(
603606
membership_request=pending_request,
604607
username="reviewer",
605608
action={"type": "contacted", "kind": "approved", "email_id": email.id},
606609
)
610+
Note.objects.filter(pk=note.pk).update(timestamp=note_timestamp)
607611

608612
reviewer = self._make_freeipa_user(
609613
"reviewer",
@@ -633,6 +637,8 @@ def test_membership_request_note_details_endpoint_returns_grouped_content_only(s
633637
self.assertNotIn("approvals", payload)
634638
self.assertNotIn("disapprovals", payload)
635639
self.assertNotIn("current_user_vote", payload)
640+
self.assertEqual(payload["groups"][0]["timestamp"], note_timestamp.isoformat())
641+
self.assertEqual(payload["groups"][0]["timestamp_display"], "2026-04-21 12:34")
636642
contacted_email = payload["groups"][0]["entries"][0]["contacted_email"]
637643

638644
self.assertEqual(contacted_email["email_id"], email.id)
@@ -642,6 +648,8 @@ def test_membership_request_note_details_endpoint_returns_grouped_content_only(s
642648
self.assertEqual(contacted_email["reply_to"], "committee@example.com")
643649
self.assertEqual(contacted_email["html"], "<p>HTML body</p>")
644650
self.assertEqual(contacted_email["text"], "Plain text body")
651+
self.assertEqual(contacted_email["logs"][0]["date"], log_timestamp.isoformat())
652+
self.assertEqual(contacted_email["logs"][0]["date_display"], "2026-04-21 13:45")
645653

646654
def test_pending_endpoint_rejects_undocumented_query_parameter(self) -> None:
647655
reviewer = self._make_freeipa_user(

frontend/src/membership-audit-log/__tests__/membershipAuditLogPage.test.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,22 @@ import { afterEach, describe, expect, it, vi } from "vitest";
44
import MembershipAuditLogPage from "../MembershipAuditLogPage.vue";
55
import type { MembershipAuditLogBootstrap } from "../types";
66

7+
function expectedLocalTimestamp(value: string): string {
8+
const parsed = new Date(value);
9+
const year = String(parsed.getFullYear());
10+
const month = String(parsed.getMonth() + 1).padStart(2, "0");
11+
const day = String(parsed.getDate()).padStart(2, "0");
12+
const hour = String(parsed.getHours()).padStart(2, "0");
13+
const minute = String(parsed.getMinutes()).padStart(2, "0");
14+
const timezoneOffsetMinutes = -parsed.getTimezoneOffset();
15+
const offsetSign = timezoneOffsetMinutes >= 0 ? "+" : "-";
16+
const absoluteOffsetMinutes = Math.abs(timezoneOffsetMinutes);
17+
const offsetHours = String(Math.floor(absoluteOffsetMinutes / 60)).padStart(2, "0");
18+
const offsetMinutes = String(absoluteOffsetMinutes % 60).padStart(2, "0");
19+
20+
return `${year}-${month}-${day} ${hour}:${minute} UTC${offsetSign}${offsetHours}:${offsetMinutes}`;
21+
}
22+
723
function flushPromises(): Promise<void> {
824
return new Promise((resolve) => {
925
setTimeout(resolve, 0);
@@ -27,6 +43,7 @@ describe("MembershipAuditLogPage", () => {
2743
});
2844

2945
it("loads and renders audit log rows with request response details", async () => {
46+
const createdAt = "2026-04-23T12:00:00+00:00";
3047
const fetchMock = vi.fn(async () => {
3148
return new Response(
3249
JSON.stringify({
@@ -36,7 +53,7 @@ describe("MembershipAuditLogPage", () => {
3653
data: [
3754
{
3855
log_id: 10,
39-
created_at: "2026-04-23T12:00:00+00:00",
56+
created_at: createdAt,
4057
actor_username: "reviewer",
4158
target: {
4259
kind: "user",
@@ -77,7 +94,8 @@ describe("MembershipAuditLogPage", () => {
7794
expect(wrapper.text()).toContain("alice");
7895
expect(wrapper.text()).toContain("Individual");
7996
expect(wrapper.text()).toContain("Requested");
80-
expect(wrapper.text()).toContain("Thu, 23 Apr 2026 12:00:00 +0000");
97+
expect(wrapper.text()).toContain(expectedLocalTimestamp(createdAt));
98+
expect(wrapper.text()).not.toContain("Thu, 23 Apr 2026 12:00:00 +0000");
8199
expect(wrapper.text()).toContain("Request responses");
82100
expect(wrapper.text()).toContain("Contributions");
83101
expect(wrapper.text()).toContain("Patch submissions");

frontend/src/membership-audit-log/components/AuditLogTable.vue

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22
import { computed, ref } from "vue";
33
44
import TableBase from "../../shared/components/TableBase.vue";
5+
import { formatMembershipTimestamp } from "../../shared/membershipPresentation";
56
import { fillUrlTemplate } from "../../shared/urlTemplates";
6-
import { formatAuditLogAction, formatAuditLogDateTime, formatAuditLogExpiresAt } from "../types";
7+
import { formatAuditLogAction, formatAuditLogExpiresAt } from "../types";
78
import type { AuditLogRequestResponseSegment, AuditLogRow } from "../types";
89
910
const props = defineProps<{
@@ -132,7 +133,7 @@ function segmentKey(segment: AuditLogRequestResponseSegment, index: number): str
132133
<div v-if="asRow(row).request">
133134
<a :href="requestHref(asRow(row))">Request #{{ asRow(row).request?.request_id }}</a>
134135
</div>
135-
<div>{{ formatAuditLogDateTime(asRow(row).created_at) }}</div>
136+
<div>{{ formatMembershipTimestamp(asRow(row).created_at) }}</div>
136137
</td>
137138
<td>{{ asRow(row).actor_username }}</td>
138139
<td>

frontend/src/membership-audit-log/types.ts

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,6 @@ export function buildMembershipAuditLogRouteUrl(state: MembershipAuditLogRouteSt
135135
return `${url.pathname}${url.search}`;
136136
}
137137

138-
const SHORT_WEEKDAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
139138
const SHORT_MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
140139
const ACTION_LABELS: Record<string, string> = {
141140
requested: "Requested",
@@ -162,25 +161,6 @@ function parseDate(value: string | null | undefined): Date | null {
162161
return parsed;
163162
}
164163

165-
function pad2(value: number): string {
166-
return String(value).padStart(2, "0");
167-
}
168-
169-
export function formatAuditLogDateTime(value: string): string {
170-
const parsed = parseDate(value);
171-
if (!parsed) {
172-
return "";
173-
}
174-
const weekday = SHORT_WEEKDAY_NAMES[parsed.getUTCDay()] || "";
175-
const day = pad2(parsed.getUTCDate());
176-
const month = SHORT_MONTH_NAMES[parsed.getUTCMonth()] || "";
177-
const year = parsed.getUTCFullYear();
178-
const hour = pad2(parsed.getUTCHours());
179-
const minute = pad2(parsed.getUTCMinutes());
180-
const second = pad2(parsed.getUTCSeconds());
181-
return `${weekday}, ${day} ${month} ${year} ${hour}:${minute}:${second} +0000`;
182-
}
183-
184164
export function formatAuditLogAction(action: string): string {
185165
const normalized = String(action || "").trim().toLowerCase();
186166
return ACTION_LABELS[normalized] || normalized.replace(/_/g, " ");

frontend/src/membership-request-detail/MembershipRequestDetailPage.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script setup lang="ts">
22
import { computed, onMounted, ref } from "vue";
33
4+
import { formatMembershipTimestamp } from "../shared/membershipPresentation";
45
import MembershipNotesCard from "../membership-requests/components/MembershipNotesCard.vue";
56
import MembershipRequestDetailActions from "../membership-requests/components/MembershipRequestDetailActions.vue";
67
import type {
@@ -263,7 +264,7 @@ onMounted(async () => {
263264
<dd class="col-sm-9">{{ statusDisplay }}</dd>
264265

265266
<dt class="col-sm-3">Requested at</dt>
266-
<dd class="col-sm-9">{{ payload.request.requested_at }}</dd>
267+
<dd class="col-sm-9">{{ formatMembershipTimestamp(payload.request.requested_at) }}</dd>
267268

268269
<template v-if="payload.request.requested_by.show">
269270
<dt class="col-sm-3">Requested by</dt>

frontend/src/membership-request-detail/__tests__/membershipRequestDetailPage.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,22 @@ import { afterEach, describe, expect, it, vi } from "vitest";
44
import MembershipRequestDetailPage from "../MembershipRequestDetailPage.vue";
55
import type { MembershipRequestDetailBootstrap } from "../types";
66

7+
function expectedLocalTimestamp(value: string): string {
8+
const parsed = new Date(value);
9+
const year = String(parsed.getFullYear());
10+
const month = String(parsed.getMonth() + 1).padStart(2, "0");
11+
const day = String(parsed.getDate()).padStart(2, "0");
12+
const hour = String(parsed.getHours()).padStart(2, "0");
13+
const minute = String(parsed.getMinutes()).padStart(2, "0");
14+
const timezoneOffsetMinutes = -parsed.getTimezoneOffset();
15+
const offsetSign = timezoneOffsetMinutes >= 0 ? "+" : "-";
16+
const absoluteOffsetMinutes = Math.abs(timezoneOffsetMinutes);
17+
const offsetHours = String(Math.floor(absoluteOffsetMinutes / 60)).padStart(2, "0");
18+
const offsetMinutes = String(absoluteOffsetMinutes % 60).padStart(2, "0");
19+
20+
return `${year}-${month}-${day} ${hour}:${minute} UTC${offsetSign}${offsetHours}:${offsetMinutes}`;
21+
}
22+
723
function flushPromises(): Promise<void> {
824
return new Promise((resolve) => {
925
setTimeout(resolve, 0);
@@ -41,6 +57,55 @@ describe("MembershipRequestDetailPage", () => {
4157
vi.restoreAllMocks();
4258
});
4359

60+
it("renders requested-at with the shared membership timestamp format", async () => {
61+
const requestedAt = "2026-04-26T10:00:00+00:00";
62+
vi.stubGlobal(
63+
"fetch",
64+
vi.fn().mockResolvedValue(
65+
new Response(
66+
JSON.stringify({
67+
viewer: {
68+
mode: "committee",
69+
},
70+
request: {
71+
id: 42,
72+
status: "pending",
73+
requested_at: requestedAt,
74+
requested_by: { show: false, username: "", full_name: "", deleted: false },
75+
requested_for: { show: false, kind: "user", label: "", username: "", organization_id: null, deleted: false },
76+
membership_type: { name: "Mirror" },
77+
responses: [],
78+
},
79+
committee: {
80+
reopen: { show: false },
81+
actions: {
82+
canRequestInfo: true,
83+
showOnHoldApprove: false,
84+
},
85+
},
86+
}),
87+
),
88+
),
89+
);
90+
91+
const wrapper = mount(MembershipRequestDetailPage, {
92+
props: { bootstrap },
93+
global: {
94+
stubs: {
95+
MembershipNotesCard: true,
96+
MembershipRequestDetailActions: true,
97+
},
98+
},
99+
});
100+
101+
await flushPromises();
102+
await flushPromises();
103+
104+
expect(wrapper.text()).toContain("Requested at");
105+
expect(wrapper.text()).toContain(expectedLocalTimestamp(requestedAt));
106+
expect(wrapper.text()).not.toContain(requestedAt);
107+
});
108+
44109
it("posts to the reopen endpoint with CSRF, refetches detail, and renders warning/deleted markers", async () => {
45110
const fetchMock = vi
46111
.fn()

0 commit comments

Comments
 (0)