Okay — I’ll wait for your next message with the relevant code.
Great — thank you.
I now have everything I need to build the hybrid admin integration, but before I generate the final code, I need one crucial piece from you:
I need:
All fields
Any FK or OneToOne relationships (especially whether it has a FK to GiftDetail, and the field name)
Any helper properties or methods I should surface in admin
Whether your PaymentTransaction rows can be multiple per gift or exactly one per gift
Your model is stored in memory (as of 2025-12-08), but you modified it several times, so I want to ensure I use the correct, final version.
Your current GiftDetailAdmin calls things like:
pythonobj.merchantpostredirect_set.first()
To create a hybrid admin we need to support:
Legacy HOP flow → MerchantPostRedirect
Unified Checkout flow → PaymentTransaction
The key logic must be:
pythonif MerchantPostRedirect exists: show MerchantPostRedirect fields elif PaymentTransaction exists: show PaymentTransaction fields else: show “no payment data”
To implement this cleanly, I will:
pythondef _get_payment(self, obj): return obj.merchantpostredirect_set.first() or obj.paymenttransaction_set.first()
get_decision, get_reason_code)with hybrid-aware versions that read from:
MerchantPostRedirect fields OR
PaymentTransaction fields (proper mappings)
I will then produce:
GiftDetailAdmin that:Supports both MerchantPostRedirect and PaymentTransaction
Renames fields appropriately based on UC vs HOP
Displays only the relevant section
Avoids breaking your existing resend-receipt workflow
Preserves readonly state logic
Keeps list_display, filters, permissions, and headers intact
Adds a clear “Payment Source: HOP vs UC” indicator
Please send me your PaymentTransaction model now, and I will generate the full hybrid admin ready to paste into admin.py.
Here’s a hybrid version of GiftDetailAdmin that:
Still supports legacy HOP via merchantpostredirect_set
Adds support for Unified Checkout via the PaymentTransaction OneToOne (gift.payment_transaction)
Prefers HOP data if it exists, otherwise falls back to PaymentTransaction
Adds a clear payment_source and some UC-specific fields
Fixes the get_invalid_fields bug and makes all Cybersource getters null-safe
You can drop this into your existing transactions/admin.py (replacing the current GiftDetailAdmin).
pythonclass GiftDetailAdmin(NumericFilterModelAdmin): """ Provide READ-ONLY (even to superusers!) access to GiftDetails from within giveadmin. """ # ------------------------------------------------------------------ # Helper accessors for HOP vs Unified Checkout # ------------------------------------------------------------------ def _get_hop(self, obj): """ Legacy Secure Acceptance (HOP) record, if any. """ try: return obj.merchantpostredirect_set.first() except Exception: return None def _get_uc(self, obj): """ Unified Checkout PaymentTransaction, if any. """ # OneToOneField with related_name="payment_transaction" return getattr(obj, "payment_transaction", None) def get_payment_source(self, obj): """ Indicates whether this gift was processed by: - Secure Acceptance (HOP) - Unified Checkout (UC) - None (no payment record found) """ hop = self._get_hop(obj) uc = self._get_uc(obj) if hop and uc: # Shouldn't normally happen, but be explicit if it does return "HOP + UC (both)" if hop: return "Secure Acceptance (HOP)" if uc: return "Unified Checkout (UC)" return "No payment record" # ------------------------------------------------------------------ # Existing Cybersource (primarily HOP) getters, now hybrid-aware # ------------------------------------------------------------------ def get_auth_avs_code(self, obj): hop = self._get_hop(obj) return getattr(hop, "auth_avs_code", None) if hop else None def get_auth_time(self, obj): hop = self._get_hop(obj) return getattr(hop, "auth_time", None) if hop else None def get_req_ignore_avs(self, obj): hop = self._get_hop(obj) return getattr(hop, "req_ignore_avs", None) if hop else None def get_decision(self, obj): """ HOP: merchantpostredirect.decision UC : PaymentTransaction.decision """ hop = self._get_hop(obj) if hop and getattr(hop, "decision", None): return hop.decision uc = self._get_uc(obj) return getattr(uc, "decision", None) if uc else None def get_message(self, obj): """ HOP: merchantpostredirect.message UC : raw_payload["message"] (top-level UC field) if present """ hop = self._get_hop(obj) if hop and getattr(hop, "message", None): return hop.message uc = self._get_uc(obj) if not uc or not uc.raw_payload: return None payload = uc.raw_payload or {} # UC complete() responses typically include a top-level "message" return payload.get("message") def get_reason_code(self, obj): """ HOP: merchantpostredirect.reason_code UC : best-effort mapping from raw_payload: - payload["reasonCode"] if present - else payload["details"]["processorInformation"]["responseCode"] """ hop = self._get_hop(obj) if hop and getattr(hop, "reason_code", None): return hop.reason_code uc = self._get_uc(obj) if not uc or not uc.raw_payload: return None payload = uc.raw_payload or {} if "reasonCode" in payload: return payload["reasonCode"] details = payload.get("details", {}) processor = details.get("processorInformation", {}) return processor.get("responseCode") def get_req_transaction_type(self, obj): hop = self._get_hop(obj) return getattr(hop, "req_transaction_type", None) if hop else None def get_req_transaction_uuid(self, obj): hop = self._get_hop(obj) return getattr(hop, "req_transaction_uuid", None) if hop else None def get_transaction_id(self, obj): """ HOP: merchantpostredirect.transaction_id UC : PaymentTransaction.transaction_id """ hop = self._get_hop(obj) if hop and getattr(hop, "transaction_id", None): return hop.transaction_id uc = self._get_uc(obj) return getattr(uc, "transaction_id", None) if uc else None def get_req_recurring_amount(self, obj): hop = self._get_hop(obj) return getattr(hop, "req_recurring_amount", None) if hop else None def get_req_recurring_frequency(self, obj): hop = self._get_hop(obj) return getattr(hop, "req_recurring_frequency", None) if hop else None def get_req_recurring_start_date(self, obj): hop = self._get_hop(obj) return getattr(hop, "req_recurring_start_date", None) if hop else None def get_req_recurring_number_of_installments(self, obj): hop = self._get_hop(obj) return getattr(hop, "req_recurring_number_of_installments", None) if hop else None def get_invalid_fields(self, obj): """ HOP-only concept. For UC gifts, say 'n/a for Unified Checkout' to avoid confusion. """ hop = self._get_hop(obj) if not hop: # There is no HOP record; if we have UC, be explicit. if self._get_uc(obj): return "n/a for Unified Checkout" return "none" decision = getattr(hop, "decision", "") or "" decision = decision.upper() if decision in {"ERROR", "CANCEL", "DECLINE"}: return getattr(hop, "invalid_fields", None) or "none" return "none" # ------------------------------------------------------------------ # UC-specific helpers (from PaymentTransaction fields) # ------------------------------------------------------------------ def get_uc_status(self, obj): uc = self._get_uc(obj) return getattr(uc, "status", None) if uc else None def get_uc_signature_verified(self, obj): uc = self._get_uc(obj) return getattr(uc, "signature_verified", None) if uc else None def get_uc_amount_verified(self, obj): uc = self._get_uc(obj) return getattr(uc, "amount_verified", None) if uc else None def get_tms_customer_id(self, obj): uc = self._get_uc(obj) return getattr(uc, "tms_customer_id", None) if uc else None def get_tms_subscription_id(self, obj): uc = self._get_uc(obj) return getattr(uc, "tms_subscription_id", None) if uc else None # ------------------------------------------------------------------ # WSGI Request Headers (unchanged; still HOP-era capture) # ------------------------------------------------------------------ def get_http_user_agent(self, obj): return getattr(obj.gifthttprequestheaders, "http_user_agent", None) def get_http_x_forwarded_for(self, obj): return getattr(obj.gifthttprequestheaders, "http_x_forwarded_for", None) def get_http_cf_connecting_ip(self, obj): return getattr(obj.gifthttprequestheaders, "http_cf_connecting_ip", None) def get_http_cf_ipcountry(self, obj): return getattr(obj.gifthttprequestheaders, "http_cf_ipcountry", None) def get_remote_addr(self, obj): return getattr(obj.gifthttprequestheaders, "remote_addr", None) def get_http_cf_ray(self, obj): return getattr(obj.gifthttprequestheaders, "http_cf_ray", None) def get_content_length(self, obj): return getattr(obj.gifthttprequestheaders, "content_length", None) def get_http_origin(self, obj): return getattr(obj.gifthttprequestheaders, "http_origin", None) def get_http_referer(self, obj): return getattr(obj.gifthttprequestheaders, "http_referer", None) # ------------------------------------------------------------------ # Fieldsets / readonly / list configuration # ------------------------------------------------------------------ fieldsets = ( (None, { 'fields': ( 'id', 'basket', 'donor', 'created', ('is_anonymous', 'is_joint'), ('is_matching', 'is_recurring'), ('is_payroll', 'is_pledge'), 'is_paid', ('get_appeal_code', 'get_cadsid'), 'get_matching_info', 'get_recurring_info', ) }), (mark_safe( 'Cybersource values (<a target="_blank" ' 'href="https://docs.google.com/spreadsheets/d/1VAC-jC558nPxF9I-0syYyHDNnAxjjf3rPK_j3Q65CVg/edit#gid=0">' 'fields reference</a>)' ), { 'fields': ( # New: show which pipeline handled this gift 'get_payment_source', # Mostly HOP-derived, with hybrid fallbacks ('get_auth_avs_code', 'get_auth_time'), ('get_decision', 'get_message', 'get_invalid_fields'), ('get_reason_code', 'get_req_ignore_avs'), 'get_transaction_id', ('get_req_transaction_type', 'get_req_transaction_uuid'), ('get_req_recurring_amount', 'get_req_recurring_frequency'), ('get_req_recurring_start_date', 'get_req_recurring_number_of_installments'), # UC-specific fields (no HOP equivalents) ('get_uc_status', 'get_uc_signature_verified', 'get_uc_amount_verified'), ('get_tms_customer_id', 'get_tms_subscription_id'), ) }), (mark_safe( 'WSGI Request Headers ' '(<a target="_blank" ' 'href="https://docs.google.com/spreadsheets/d/1IAYv_NAZUmkQxCQd207wvFTY3qavhlaCovEFHFaWGBw/edit#gid=0">' 'headers reference</a>)' ), { 'fields': ( 'get_http_user_agent', 'get_http_x_forwarded_for', 'get_http_cf_connecting_ip', 'get_http_cf_ipcountry', 'get_remote_addr', 'get_http_cf_ray', 'get_content_length', 'get_http_origin', 'get_http_referer', ) }), ) # See comment below under has_change_permission() for why all fields are # declared readonly here. readonly_fields = flatten( fieldsets[0][1]['fields'] + fieldsets[1][1]['fields'] + fieldsets[2][1]['fields'] ) list_display = ( 'id', 'donor_fullname', 'grand_total', 'created', 'is_joint', 'is_anonymous', 'is_paid', ) list_display_links = ('id', 'donor_fullname') list_filter = ( 'is_paid', ('grand_total', RangeNumericFilter), ('created', DateTimeRangeFilter), ) list_max_show_all = sys.maxsize list_select_related = ('donor',) search_fields = [ 'id', 'grand_total', 'donor__forename', 'donor__middlename', 'donor__surname' ] class Media: # js for summing the grand_total column, displaying in paginator js = ('giveadmin/js/giftdetailadmin.js',) def change_view(self, request, object_id, form_url='', extra_context=None): """ If the user has permission to resend a gift receipt, add a 'show_resend' flag for the 'submit_line' template to see. """ if request.user.has_perm('transactions.resend_receipt'): try: extra_context['show_resend'] = True except TypeError: extra_context = {'show_resend': True} return super().change_view(request, object_id, form_url, extra_context) def has_add_permission(self, request): return False def has_change_permission(self, request, obj=None): """ It would be nice to simply return False here in all cases, because we want the model to be readonly for all users. But if we do that, then our "Resend Receipt" button does not work, because when you submit the view/change form, you get a 403 error because the model is readonly. So, to make that button work, we return True here if the user has permission to resend receipts. And up above, we ensure that all the displayed fields are readonly. """ return request.user.has_perm('transactions.resend_receipt') def has_delete_permission(self, request, obj=None): return False def response_change(self, request, obj): """ Override this ModelAdmin method so we can handle the 'Resend gift receipt' button. """ if "_resend" in request.POST: email = GiftEmail(gift_detail_id=obj.id) try: status = email.send() if status == 'queued': msg = format_html( 'The receipt has been successfully queued for resending.', ) self.message_user(request, msg, messages.SUCCESS) except Exception as e: err = str(e).replace('n', '<br>') msg = format_html( f'Sorry, an error occurred in resending this receipt. <br>{err}', ) self.message_user(request, msg, messages.ERROR) return HttpResponseRedirect(request.path) else: return super().response_change(request, obj) # ------------------------------------------------------------------ # Short descriptions (labels) — keep leading spaces as in your original # ------------------------------------------------------------------ # merchant post redirect + hybrid get_payment_source.short_description = ' payment_source' get_auth_avs_code.short_description = ' auth_avs_code' get_req_ignore_avs.short_description = ' req_ignore_avs' get_auth_time.short_description = ' auth_time' get_decision.short_description = ' decision' get_message.short_description = ' message' get_reason_code.short_description = ' reason_code' get_req_transaction_type.short_description = ' req_transaction_type' get_req_transaction_uuid.short_description = ' req_transaction_uuid' get_transaction_id.short_description = ' transaction_id' get_req_recurring_amount.short_description = ' req_recurring_amount' get_req_recurring_frequency.short_description = ' req_recurring_frequency' get_req_recurring_start_date.short_description = ' req_recurring_start_date' get_req_recurring_number_of_installments.short_description = ' req_recurring_number_of_installments' get_invalid_fields.short_description = ' invalid fields' # UC-specific labels get_uc_status.short_description = ' uc_status' get_uc_signature_verified.short_description = ' uc_signature_verified' get_uc_amount_verified.short_description = ' uc_amount_verified' get_tms_customer_id.short_description = ' tms_customer_id' get_tms_subscription_id.short_description = ' tms_subscription_id' # wsgi request headers get_http_user_agent.short_description = ' http_user_agent' get_http_x_forwarded_for.short_description = ' http_x_forwarded_for' get_http_cf_connecting_ip.short_description = ' http_cf_connecting_ip' get_http_cf_ipcountry.short_description = ' http_cf_ipcountry' get_remote_addr.short_description = ' remote_addr' get_http_cf_ray.short_description = ' http_cf_ray' get_content_length.short_description = ' content_length' get_http_origin.short_description = ' http_origin' get_http_referer.short_description = ' http_referer'
In your core/cybersource/admin.py, make sure you’ve got something like:
python@admin.register(PaymentTransaction) class PaymentTransactionAdmin(admin.ModelAdmin): list_display = ( 'id', 'transaction_id', 'gift', 'status', 'decision', 'signature_verified', 'amount_verified', 'created', ) list_filter = ('status', 'decision', 'signature_verified', 'amount_verified', 'created') search_fields = ('transaction_id', 'gift__id') readonly_fields = ( 'gift', 'transaction_id', 'raw_payload', 'status', 'decision', 'signature_verified', 'amount_verified', 'tms_customer_id', 'tms_instrument_identifier_id', 'tms_payment_instrument_id', 'tms_subscription_id', 'uc_transient_token', 'tms_token_create_response', 'tms_subscription_create_response', 'created', 'modified', )
If you’d like, next step I can:
Add a link from GiftDetailAdmin to the PaymentTransaction change page (for UC gifts)
Or add a readonly JSON viewer for raw_payload right in the gift admin.
var(--tw-content)