Repository: dj-stripe/dj-stripe

Issue:
Using admin.ModelAdmin subclasses in admin.py
Hey, what are thoughts on using subclasses of `admin.ModelAdmin` for things like the Charge and Invoice? Currently the library does `admin.site.register()` calls for most models (e.g. https://github.com/kavdev/dj-stripe/blob/adcb64ee8691ca34fc55f302ee28e5075697ce96/djstripe/admin.py#L129-L129). An advantage of using `admin.ModelAdmin` subclasses would be that they could be inherited from by projects that use the dj-stripe library, and then specific class attributes overridden to suit the needs of the project.

I can do a PR for this if it would be useful?


Discussion:
Hi @jameshiew!

Yeah our admin code is in desperate need of an update. That code was written quite a long time ago :)

I completely agree and would greatly appreciate a PR; please use the 1.0.0 branch as a base.
Hey @kavdev , it looks like most if not all model fields in the admin pages are editable - I think it will make sense to make most of the fields readonly (definitely things like `stripe_id`) since changes made in the admin panel don't sync back to Stripe, at least for the current time. Will PR soon!

![screen shot 2016-12-07 at 21 40 12](https://cloud.githubusercontent.com/assets/2990876/20987805/f594c568-bcc5-11e6-9fec-2c83425b3f00.png)


Resolving patch:
diff --git a/djstripe/admin.py b/djstripe/admin.py
--- a/djstripe/admin.py
+++ b/djstripe/admin.py
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
 """
 .. module:: djstripe.admin.
 
@@ -12,8 +11,10 @@
 
 from django.contrib import admin
 
-from .models import Event, EventProcessingException, Transfer, Charge, Plan
-from .models import Invoice, InvoiceItem, Subscription, Customer
+from .models import (
+    Charge, Customer, Event, EventProcessingException, Invoice, InvoiceItem,
+    Plan, Subscription, Transfer
+)
 
 
 class CustomerHasSourceListFilter(admin.SimpleListFilter):
@@ -100,7 +101,7 @@ def lookups(self, request, model_admin):
         """
         statuses = [
             [x, x.replace("_", " ").title()]
-            for x in Subscription.objects.all().values_list(
+            for x in Subscription.objects.values_list(
                 "status",
                 flat=True
             ).distinct()
@@ -120,56 +121,39 @@ def queryset(self, request, queryset):
             return queryset.filter(subscriptions__status=self.value()).distinct()
 
 
-def send_charge_receipt(modeladmin, request, queryset):
-    """Function for sending receipts from the admin if a receipt is not sent for a specific charge."""
-    for charge in queryset:
-        charge.send_receipt()
-
-
-admin.site.register(
-    Charge,
-    list_display=[
-        "stripe_id",
-        "customer",
-        "amount",
-        "description",
-        "paid",
-        "disputed",
-        "refunded",
-        "fee",
-        "receipt_sent",
-        "stripe_timestamp",
-    ],
-    search_fields=[
-        "stripe_id",
-        "customer__stripe_id",
-        "invoice__stripe_id",
-    ],
-    list_filter=[
-        "paid",
-        "disputed",
-        "refunded",
-        "stripe_timestamp",
-    ],
-    raw_id_fields=[
-        "customer",
-    ],
-    actions=(send_charge_receipt,),
-)
+@admin.register(EventProcessingException)
+class EventProcessingExceptionAdmin(admin.ModelAdmin):
+    list_display = ("message", "event", "created")
+    raw_id_fields = ("event", )
+    search_fields = ("message", "traceback", "data")
 
-admin.site.register(
-    EventProcessingException,
-    list_display=[
-        "message",
-        "event",
-        "created"
-    ],
-    search_fields=[
-        "message",
-        "traceback",
-        "data"
-    ],
-)
+    def has_add_permission(self, request):
+        return False
+
+
+class StripeObjectAdmin(admin.ModelAdmin):
+    """Base class for all StripeObject-based model admins"""
+
+    def get_list_display(self, request):
+        return ("stripe_id", ) + self.list_display + ("stripe_timestamp", "livemode")
+
+    def get_list_filter(self, request):
+        return self.list_filter + ("stripe_timestamp", "livemode")
+
+    def get_readonly_fields(self, request, obj=None):
+        return self.readonly_fields + ("stripe_id", "stripe_timestamp")
+
+    def get_search_fields(self, request):
+        return self.search_fields + ("stripe_id", )
+
+    def get_fieldsets(self, request, obj=None):
+        common_fields = ("livemode", "stripe_id", "stripe_timestamp")
+        # Have to remove the fields from the common set, otherwise they'll show up twice.
+        fields = [f for f in self.get_fields(request, obj) if f not in common_fields]
+        return (
+            (None, {"fields": common_fields}),
+            (self.model.__name__, {"fields": fields}),
+        )
 
 
 def reprocess_events(modeladmin, request, queryset):
@@ -189,47 +173,21 @@ def reprocess_events(modeladmin, request, queryset):
         if event.process(force=True):
             processed += 1
 
-    modeladmin.message_user(
-        request,
-        "{processed}/{total} event(s) successfully re-processed.".format(
-            processed=processed, total=queryset.count()))
+    message = "{processed}/{total} event(s) successfully re-processed."
+    total = queryset.count()
+    modeladmin.message_user(request, message.format(processed=processed, total=total))
 
 
 reprocess_events.short_description = "Re-process selected webhook events"
 
 
-admin.site.register(
-    Event,
-    raw_id_fields=["customer"],
-    list_display=[
-        "stripe_id",
-        "type",
-        "livemode",
-        "valid",
-        "processed",
-        "stripe_timestamp"
-    ],
-    list_filter=[
-        "type",
-        "created",
-        "valid",
-        "processed"
-    ],
-    search_fields=[
-        "stripe_id",
-        "customer__stripe_id",
-        "validated_message"
-    ],
-    actions=[
-        reprocess_events
-    ],
-)
-
-
-class SubscriptionInline(admin.TabularInline):
+class SubscriptionInline(admin.StackedInline):
     """A TabularInline for use models.Subscription."""
 
     model = Subscription
+    extra = 0
+    readonly_fields = ("stripe_id", "stripe_timestamp")
+    show_change_link = True
 
 
 def subscription_status(customer):
@@ -255,51 +213,15 @@ def cancel_subscription(modeladmin, request, queryset):
 
 cancel_subscription.short_description = "Cancel selected subscriptions"
 
-admin.site.register(
-    Subscription,
-    raw_id_fields=[
-        "customer",
-        "plan",
-    ],
-    list_display=[
-        "stripe_id",
-        "status",
-        "stripe_timestamp",
-    ],
-    list_filter=[
-        "status",
-    ],
-    search_fields=[
-        "stripe_id",
-    ],
-    actions=[cancel_subscription]
-)
 
-
-admin.site.register(
-    Customer,
-    raw_id_fields=["subscriber"],
-    list_display=[
-        "stripe_id",
-        "subscriber",
-        subscription_status,
-        "stripe_timestamp"
-    ],
-    list_filter=[
-        CustomerHasSourceListFilter,
-        CustomerSubscriptionStatusListFilter
-    ],
-    search_fields=[
-        "stripe_id"
-    ],
-    inlines=[SubscriptionInline]
-)
-
-
-class InvoiceItemInline(admin.TabularInline):
-    """A TabularInline for use models.InvoiceItem."""
+class InvoiceItemInline(admin.StackedInline):
+    """A TabularInline for use InvoiceItem."""
 
     model = InvoiceItem
+    extra = 0
+    readonly_fileds = ("stripe_id", "stripe_timestamp")
+    raw_id_fields = ("customer", "subscription")
+    show_change_link = True
 
 
 def customer_has_source(obj):
@@ -312,89 +234,94 @@ def customer_has_source(obj):
 
 def customer_email(obj):
     """Return a string representation of the customer's email."""
-    return str(obj.customer.subscriber.email)
+    if obj.customer.subscriber:
+        return str(obj.customer.subscriber.email)
+    else:
+        return ""
 
 
 customer_email.short_description = "Customer"
 
 
-admin.site.register(
-    Invoice,
-    raw_id_fields=["customer"],
-    readonly_fields=('stripe_timestamp',),
-    list_display=[
-        "stripe_id",
-        "paid",
-        "forgiven",
-        "closed",
-        customer_email,
-        customer_has_source,
-        "period_start",
-        "period_end",
-        "subtotal",
-        "total",
-        "stripe_timestamp"
-    ],
-    search_fields=[
-        "stripe_id",
-        "customer__stripe_id"
-    ],
-    list_filter=[
-        InvoiceCustomerHasSourceListFilter,
-        "paid",
-        "forgiven",
-        "closed",
-        "attempted",
-        "attempt_count",
-        "stripe_timestamp",
-        "date",
-        "period_end",
-        "total"
-    ],
-    inlines=[InvoiceItemInline]
-)
-
-
-admin.site.register(
-    Transfer,
-    list_display=[
-        "stripe_id",
-        "amount",
-        "status",
-        "date",
-        "description",
-        "stripe_timestamp"
-    ],
-    search_fields=[
-        "stripe_id",
-    ]
-)
-
-
-class PlanAdmin(admin.ModelAdmin):
-    """An Admin for use with models.Plan."""
+@admin.register(Charge)
+class ChargeAdmin(StripeObjectAdmin):
+    list_display = (
+        "customer", "amount", "description", "paid", "disputed", "refunded",
+        "fee", "receipt_sent"
+    )
+    search_fields = ("stripe_id", "customer__stripe_id", "invoice__stripe_id")
+    list_filter = (
+        "status", "paid", "disputed", "refunded", "fraudulent", "captured",
+    )
+    raw_id_fields = ("customer", "source", "transfer")
+
+
+@admin.register(Customer)
+class CustomerAdmin(StripeObjectAdmin):
+    raw_id_fields = ("subscriber", "default_source")
+    list_display = ("subscriber", subscription_status)
+    list_filter = (CustomerHasSourceListFilter, CustomerSubscriptionStatusListFilter)
+    inlines = (SubscriptionInline, )
+
+
+@admin.register(Event)
+class EventAdmin(StripeObjectAdmin):
+    raw_id_fields = ("customer", )
+    list_display = ("type", "created", "valid", "processed")
+    list_filter = ("type", "created", "valid", "processed")
+    actions = (reprocess_events, )
+    # radio_fields = {"valid": admin.HORIZONTAL}
+
+    def has_add_permission(self, request):
+        return False
+
+
+@admin.register(Invoice)
+class InvoiceAdmin(StripeObjectAdmin):
+    list_display = (
+        "paid", "forgiven", "closed", customer_email, customer_has_source,
+        "period_start", "period_end", "subtotal", "total"
+    )
+    list_filter = (
+        InvoiceCustomerHasSourceListFilter, "paid", "forgiven", "closed", "attempted",
+        "date", "period_end",
+    )
+    raw_id_fields = ("customer", "charge", "subscription")
+    search_fields = ("customer__stripe_id", )
+    inlines = (InvoiceItemInline, )
+
+
+@admin.register(Plan)
+class PlanAdmin(StripeObjectAdmin):
+    radio_fields = {"interval": admin.HORIZONTAL}
 
     def save_model(self, request, obj, form, change):
         """Update or create objects using our custom methods that sync with Stripe."""
         if change:
             obj.update_name()
-
         else:
             Plan.get_or_create(**form.cleaned_data)
 
     def get_readonly_fields(self, request, obj=None):
         """Return extra readonly_fields."""
-        readonly_fields = list(self.readonly_fields)
+        readonly_fields = super(PlanAdmin, self).get_readonly_fields(request, obj)
+
         if obj:
-            readonly_fields.extend([
-                'stripe_id',
-                'amount',
-                'currency',
-                'interval',
-                'interval_count',
-                'trial_period_days'])
+            readonly_fields += (
+                "amount", "currency", "interval", "interval_count", "trial_period_days"
+            )
 
         return readonly_fields
 
 
-admin.site.register(Plan, PlanAdmin)
+@admin.register(Subscription)
+class SubscriptionAdmin(StripeObjectAdmin):
+    raw_id_fields = ("customer", )
+    list_display = ("customer", "status")
+    list_filter = ("status", )
+    actions = (cancel_subscription, )
+
+
+@admin.register(Transfer)
+class TransferAdmin(StripeObjectAdmin):
+    list_display = ("amount", "status", "date", "description")


Test patch: